diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 0000000..20c0c0e Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts new file mode 100644 index 0000000..d5f2bd6 --- /dev/null +++ b/openapi-ts.config.ts @@ -0,0 +1,21 @@ +import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts"; + +export default defineConfig({ + input: "../back/combined.yaml", + output: "src/client", + plugins: [ + ...defaultPlugins, + { + asClass: true, + name: "@hey-api/sdk", + validator: "zod", + }, + { + name: "@hey-api/client-next", + runtimeConfigPath:"./src/hey-api-config.ts", + }, + { + name: "@hey-api/typescript", + }, + ], +}); diff --git a/package.json b/package.json index 0edbf83..944bdcd 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "prettier:write": "prettier --write \"**/*.{ts,tsx}\"", "test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest", "storybook": "storybook dev -p 6006", - "storybook:build": "storybook build" + "storybook:build": "storybook build", + "openapi-ts": "openapi-ts" }, "dependencies": { "@mantine/core": "8.1.2", @@ -38,11 +39,13 @@ "react-imask": "^7.6.1", "react-redux": "^9.2.0", "redux-persist": "^6.0.0", - "sharp": "^0.34.3" + "sharp": "^0.34.3", + "zod": "^4.0.10" }, "devDependencies": { "@babel/core": "^7.27.4", "@eslint/js": "^9.29.0", + "@hey-api/openapi-ts": "0.80.1", "@ianvs/prettier-plugin-sort-imports": "^4.4.2", "@storybook/nextjs": "^8.6.8", "@storybook/react": "^8.6.8", diff --git a/src/app/consent/components/ConsentButton/ConsentButton.tsx b/src/app/consent/components/ConsentButton/ConsentButton.tsx index 87d1e07..0849a02 100644 --- a/src/app/consent/components/ConsentButton/ConsentButton.tsx +++ b/src/app/consent/components/ConsentButton/ConsentButton.tsx @@ -1,15 +1,17 @@ "use client"; import { FC, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { useSelector } from "react-redux"; import { Button, Text } from "@mantine/core"; +import { Auth } from "@/client"; import SCOPES from "@/constants/scopes"; import { Scopes } from "@/enums/Scopes"; import { notifications } from "@/lib/notifications"; import { RootState } from "@/lib/store"; -import { AuthService } from "@/mocks/authService"; const ConsentButton: FC = () => { + const searchParams = useSearchParams(); const auth = useSelector((state: RootState) => state.auth); const [clientName, setClientName] = useState(Scopes.UNDEFINED); const [serviceRequiredAccess, setServiceRequiredAccess] = @@ -25,18 +27,17 @@ const ConsentButton: FC = () => { }; const requestConsent = () => { - if (!auth.loginChallenge || auth.scope.length === 0) return; - - new AuthService() - .requestConsent(auth.loginChallenge) - .then(response => response.data) - .then(({ clientName }) => { - setClientName(clientName); - }) - .catch(error => { - console.error(error); - notifications.error({ message: error.toString() }); - }); + // if (!auth.loginChallenge || auth.scope.length === 0) return; + // new AuthService() + // .requestConsent(auth.loginChallenge) + // .then(response => response.data) + // .then(({ clientName }) => { + // setClientName(clientName); + // }) + // .catch(error => { + // console.error(error); + // notifications.error({ message: error.toString() }); + // }); }; useEffect(() => { @@ -45,18 +46,40 @@ const ConsentButton: FC = () => { }, []); const confirmAccess = () => { - if (!auth.loginChallenge) return; - - new AuthService() - .approveConsent(auth.loginChallenge) + const consentChallenge = searchParams.get("consent_challenge"); + if (!consentChallenge) { + console.error("Consent challenge is missing in the URL"); + return; + } + Auth.postAuthConsentAccept({ + body: { consent_challenge: consentChallenge }, + }) .then(response => response.data) - .then(({ redirectUrl }) => { - window.location.href = redirectUrl; - }) - .catch(error => { - console.error(error); - notifications.error({ message: error.toString() }); + .then(response => { + if (!response) { + console.error("Response is empty"); + return; + } + const { redirect_url, ok, message } = response; + notifications.guess(ok, { message }); + + if (redirect_url) { + window.location.href = redirect_url; + } else { + console.error("Redirect URL is missing in the response"); + } }); + // if (!auth.loginChallenge) return; + // new AuthService() + // .approveConsent(auth.loginChallenge) + // .then(response => response.data) + // .then(({ redirectUrl }) => { + // window.location.href = redirectUrl; + // }) + // .catch(error => { + // console.error(error); + // notifications.error({ message: error.toString() }); + // }); }; return ( diff --git a/src/app/verify-phone/components/VerifyPhoneForm/VerifyPhoneForm.tsx b/src/app/verify-phone/components/VerifyPhoneForm/VerifyPhoneForm.tsx index 72c849b..8b057a2 100644 --- a/src/app/verify-phone/components/VerifyPhoneForm/VerifyPhoneForm.tsx +++ b/src/app/verify-phone/components/VerifyPhoneForm/VerifyPhoneForm.tsx @@ -7,10 +7,9 @@ import { Button, PinInput, Stack } from "@mantine/core"; import { useForm } from "@mantine/form"; import ResendVerificationCode from "@/app/verify-phone/components/ResendVerificationCode/ResendVerificationCode"; import style from "@/app/verify-phone/components/VerifyPhoneForm/VerifyPhone.module.css"; -import { setScope } from "@/lib/features/auth/authSlice"; +import { Auth } from "@/client"; import { notifications } from "@/lib/notifications"; -import { RootState, useAppDispatch } from "@/lib/store"; -import { AuthService } from "@/mocks/authService"; +import { RootState } from "@/lib/store"; type VerifyNumberForm = { code: string; @@ -26,25 +25,30 @@ const VerifyPhoneForm: FC = () => { }, }); const authState = useSelector((state: RootState) => state.auth); - const dispatch = useAppDispatch(); const handleSubmit = (values: VerifyNumberForm) => { - if (!authState.phoneNumber || !authState.loginChallenge) return; - new AuthService() - .approveLogin( - authState.phoneNumber, - values.code, - authState.loginChallenge - ) + if (!authState.phoneNumber || !authState.loginChallenge) return; + console.log(authState.phoneNumber.replace(/ /g, "")); + + Auth.postAuthOtpVerify({ + body: { + phone_number: authState.phoneNumber.replace(" ", ""), + login_challenge: authState.loginChallenge, + otp: values.code, + }, + }) .then(response => response.data) - .then(({ redirectUrl, scope }) => { - dispatch(setScope(scope)); - window.location.href = redirectUrl; - }) - .catch(error => { - console.error(error); - notifications.error({ message: error.toString() }); + .then(response => { + if (!response) return; + const { redirect_url, ok } = response; + if (!ok) { + notifications.error({ + message: "Ошибка при подтверждении номера", + }); + return; + } + window.location.href = redirect_url; }); }; diff --git a/src/client/client.gen.ts b/src/client/client.gen.ts new file mode 100644 index 0000000..9bc529e --- /dev/null +++ b/src/client/client.gen.ts @@ -0,0 +1,17 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ClientOptions } from './types.gen'; +import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client'; +import { createClientConfig } from '../hey-api-config'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client = createClient(createClientConfig(createConfig())); \ No newline at end of file diff --git a/src/client/client/client.ts b/src/client/client/client.ts new file mode 100644 index 0000000..cedbe50 --- /dev/null +++ b/src/client/client/client.ts @@ -0,0 +1,177 @@ +import type { Client, Config, RequestOptions } from './types'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + // @ts-expect-error + const request: Client['request'] = async (options) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body && opts.bodySerializer) { + opts.body = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.body === '') { + opts.headers.delete('Content-Type'); + } + + for (const fn of interceptors.request._fns) { + if (fn) { + await fn(opts); + } + } + + const url = buildUrl(opts); + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response = await _fetch(url, { + ...opts, + body: opts.body as ReqInit['body'], + }); + + for (const fn of interceptors.response._fns) { + if (fn) { + response = await fn(response, opts); + } + } + + const result = { + response, + }; + + if (response.ok) { + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + return { + data: {}, + ...result, + }; + } + + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'json': + case 'text': + data = await response[parseAs](); + break; + case 'stream': + return { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error._fns) { + if (fn) { + finalError = (await fn(error, response, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + return { + error: finalError, + ...result, + }; + }; + + return { + buildUrl, + connect: (options) => request({ ...options, method: 'CONNECT' }), + delete: (options) => request({ ...options, method: 'DELETE' }), + get: (options) => request({ ...options, method: 'GET' }), + getConfig, + head: (options) => request({ ...options, method: 'HEAD' }), + interceptors, + options: (options) => request({ ...options, method: 'OPTIONS' }), + patch: (options) => request({ ...options, method: 'PATCH' }), + post: (options) => request({ ...options, method: 'POST' }), + put: (options) => request({ ...options, method: 'PUT' }), + request, + setConfig, + trace: (options) => request({ ...options, method: 'TRACE' }), + }; +}; diff --git a/src/client/client/index.ts b/src/client/client/index.ts new file mode 100644 index 0000000..15d3742 --- /dev/null +++ b/src/client/client/index.ts @@ -0,0 +1,21 @@ +export type { Auth } from '../core/auth'; +export type { QuerySerializerOptions } from '../core/bodySerializer'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer'; +export { buildClientParams } from '../core/params'; +export { createClient } from './client'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + TDataShape, +} from './types'; +export { createConfig } from './utils'; diff --git a/src/client/client/types.ts b/src/client/client/types.ts new file mode 100644 index 0000000..cb40f7d --- /dev/null +++ b/src/client/client/types.ts @@ -0,0 +1,173 @@ +import type { Auth } from '../core/auth'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types'; +import type { Middleware } from './utils'; + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + throwOnError: ThrowOnError; + }> { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, +> = ThrowOnError extends true + ? Promise<{ + data: TData extends Record ? TData[keyof TData] : TData; + response: Response; + }> + : Promise< + ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; + } + ) & { + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, +>( + options: Omit, 'method'>, +) => RequestResult; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: Pick & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = OmitKeys, 'body' | 'path' | 'query' | 'url'> & + Omit; + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys, 'body' | 'headers' | 'url'> & TData + : OmitKeys, 'body' | 'url'> & + TData & + Pick, 'headers'> + : TData extends { headers?: any } + ? OmitKeys, 'headers' | 'url'> & + TData & + Pick, 'body'> + : OmitKeys, 'url'> & TData; diff --git a/src/client/client/utils.ts b/src/client/client/utils.ts new file mode 100644 index 0000000..77af041 --- /dev/null +++ b/src/client/client/utils.ts @@ -0,0 +1,406 @@ +import { getAuthToken } from '../core/auth'; +import type { + QuerySerializer, + QuerySerializerOptions, +} from '../core/bodySerializer'; +import { jsonBodySerializer } from '../core/bodySerializer'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer'; +import type { Client, ClientOptions, Config, RequestOptions } from './types'; + +interface PathSerializer { + path: Record; + url: string; +} + +const PATH_PARAM_RE = /\{[^{}]+\}/g; + +type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +type ArraySeparatorStyle = ArrayStyle | MatrixStyle; + +const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved, + explode: true, + name, + style: 'form', + value, + ...array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + + return; + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header || typeof header !== 'object') { + continue; + } + + const iterator = + header instanceof Headers ? header.entries() : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (options: Options) => void | Promise; + +type ResInterceptor = ( + response: Res, + options: Options, +) => Res | Promise; + +class Interceptors { + _fns: (Interceptor | null)[]; + + constructor() { + this._fns = []; + } + + clear() { + this._fns = []; + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this._fns[id] ? id : -1; + } else { + return this._fns.indexOf(id); + } + } + exists(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + return !!this._fns[index]; + } + + eject(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = null; + } + } + + update(id: number | Interceptor, fn: Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = fn; + return id; + } else { + return false; + } + } + + use(fn: Interceptor) { + this._fns = [...this._fns, fn]; + return this._fns.length - 1; + } +} + +// `createInterceptors()` response, meant for external use as it does not +// expose internals +export interface Middleware { + error: Pick>, 'eject' | 'use'>; + request: Pick>, 'eject' | 'use'>; + response: Pick>, 'eject' | 'use'>; +} + +// do not add `Middleware` as return type so we can use _fns internally +export const createInterceptors = () => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/src/client/core/auth.ts b/src/client/core/auth.ts new file mode 100644 index 0000000..451c7f3 --- /dev/null +++ b/src/client/core/auth.ts @@ -0,0 +1,40 @@ +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = + typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/src/client/core/bodySerializer.ts b/src/client/core/bodySerializer.ts new file mode 100644 index 0000000..98ce779 --- /dev/null +++ b/src/client/core/bodySerializer.ts @@ -0,0 +1,88 @@ +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/src/client/core/params.ts b/src/client/core/params.ts new file mode 100644 index 0000000..ba35263 --- /dev/null +++ b/src/client/core/params.ts @@ -0,0 +1,151 @@ +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + { + in: Slot; + map?: string; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + (params[field.in] as Record)[name] = arg; + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else { + for (const [slot, allowed] of Object.entries( + config.allowExtra ?? {}, + )) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/src/client/core/pathSerializer.ts b/src/client/core/pathSerializer.ts new file mode 100644 index 0000000..d692cf0 --- /dev/null +++ b/src/client/core/pathSerializer.ts @@ -0,0 +1,179 @@ +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; diff --git a/src/client/core/types.ts b/src/client/core/types.ts new file mode 100644 index 0000000..2dd4106 --- /dev/null +++ b/src/client/core/types.ts @@ -0,0 +1,118 @@ +import type { Auth, AuthToken } from './auth'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer'; + +export interface Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, +> { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + connect: MethodFn; + delete: MethodFn; + get: MethodFn; + getConfig: () => Config; + head: MethodFn; + options: MethodFn; + patch: MethodFn; + post: MethodFn; + put: MethodFn; + request: RequestFn; + setConfig: (config: Config) => Config; + trace: MethodFn; +} + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: + | 'CONNECT' + | 'DELETE' + | 'GET' + | 'HEAD' + | 'OPTIONS' + | 'PATCH' + | 'POST' + | 'PUT' + | 'TRACE'; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..e64537d --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/src/client/sdk.gen.ts b/src/client/sdk.gen.ts new file mode 100644 index 0000000..a91917a --- /dev/null +++ b/src/client/sdk.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Options as ClientOptions, TDataShape, Client } from './client'; +import type { PostAuthOtpRequestData, PostAuthOtpRequestResponses, PostAuthOtpRequestErrors, PostAuthOtpVerifyData, PostAuthOtpVerifyResponses, PostAuthOtpVerifyErrors, PostAuthConsentAcceptData, PostAuthConsentAcceptResponses, PostAuthConsentAcceptErrors } from './types.gen'; +import { zPostAuthOtpRequestData, zPostAuthOtpRequestResponse, zPostAuthOtpVerifyData, zPostAuthOtpVerifyResponse, zPostAuthConsentAcceptData, zPostAuthConsentAcceptResponse } from './zod.gen'; +import { client as _heyApiClient } from './client.gen'; + +export type Options = ClientOptions & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +export class Auth { + /** + * Request OTP + */ + public static postAuthOtpRequest(options: Options) { + return (options.client ?? _heyApiClient).post({ + requestValidator: async (data) => { + return await zPostAuthOtpRequestData.parseAsync(data); + }, + responseValidator: async (data) => { + return await zPostAuthOtpRequestResponse.parseAsync(data); + }, + url: '/auth/otp/request', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + /** + * Verify OTP + */ + public static postAuthOtpVerify(options: Options) { + return (options.client ?? _heyApiClient).post({ + requestValidator: async (data) => { + return await zPostAuthOtpVerifyData.parseAsync(data); + }, + responseValidator: async (data) => { + return await zPostAuthOtpVerifyResponse.parseAsync(data); + }, + url: '/auth/otp/verify', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + /** + * Accept consent + */ + public static postAuthConsentAccept(options: Options) { + return (options.client ?? _heyApiClient).post({ + requestValidator: async (data) => { + return await zPostAuthConsentAcceptData.parseAsync(data); + }, + responseValidator: async (data) => { + return await zPostAuthConsentAcceptResponse.parseAsync(data); + }, + url: '/auth/consent/accept', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } +} \ No newline at end of file diff --git a/src/client/types.gen.ts b/src/client/types.gen.ts new file mode 100644 index 0000000..dde33b2 --- /dev/null +++ b/src/client/types.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type RequestOtpRequest = { + /** + * Phone number to send OTP to + */ + phone_number: string; +}; + +export type RequestOtpResponse = { + /** + * Confirmation message + */ + message: string; + /** + * Status of the request + */ + ok: boolean; +}; + +export type VerifyOtpRequest = { + /** + * Phone number to verify OTP for + */ + phone_number: string; + /** + * One-time password to verify + */ + otp: string; + /** + * Login challenge for verification + */ + login_challenge: string; +}; + +export type VerifyOtpResponse = { + /** + * URL to redirect to after successful verification + */ + redirect_url: string; + /** + * Status of the verification + */ + ok: boolean; +}; + +export type AcceptConsentRequest = { + /** + * The consent challenge to accept + */ + consent_challenge: string; +}; + +export type AcceptConsentResponse = { + /** + * URL to redirect to after accepting consent + */ + redirect_url: string; + /** + * Status of the consent acceptance + */ + ok: boolean; + message: string; +}; + +export type PostAuthOtpRequestData = { + body: RequestOtpRequest; + path?: never; + query?: never; + url: '/auth/otp/request'; +}; + +export type PostAuthOtpRequestErrors = { + /** + * Bad request + */ + 400: RequestOtpResponse; +}; + +export type PostAuthOtpRequestError = PostAuthOtpRequestErrors[keyof PostAuthOtpRequestErrors]; + +export type PostAuthOtpRequestResponses = { + /** + * OTP requested successfully + */ + 200: RequestOtpResponse; +}; + +export type PostAuthOtpRequestResponse = PostAuthOtpRequestResponses[keyof PostAuthOtpRequestResponses]; + +export type PostAuthOtpVerifyData = { + body: VerifyOtpRequest; + path?: never; + query?: never; + url: '/auth/otp/verify'; +}; + +export type PostAuthOtpVerifyErrors = { + /** + * Bad request + */ + 400: VerifyOtpResponse; +}; + +export type PostAuthOtpVerifyError = PostAuthOtpVerifyErrors[keyof PostAuthOtpVerifyErrors]; + +export type PostAuthOtpVerifyResponses = { + /** + * OTP verified successfully + */ + 200: VerifyOtpResponse; +}; + +export type PostAuthOtpVerifyResponse = PostAuthOtpVerifyResponses[keyof PostAuthOtpVerifyResponses]; + +export type PostAuthConsentAcceptData = { + body: AcceptConsentRequest; + path?: never; + query?: never; + url: '/auth/consent/accept'; +}; + +export type PostAuthConsentAcceptErrors = { + /** + * Bad request + */ + 400: AcceptConsentResponse; +}; + +export type PostAuthConsentAcceptError = PostAuthConsentAcceptErrors[keyof PostAuthConsentAcceptErrors]; + +export type PostAuthConsentAcceptResponses = { + /** + * Consent accepted successfully + */ + 200: AcceptConsentResponse; +}; + +export type PostAuthConsentAcceptResponse = PostAuthConsentAcceptResponses[keyof PostAuthConsentAcceptResponses]; + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; \ No newline at end of file diff --git a/src/client/zod.gen.ts b/src/client/zod.gen.ts new file mode 100644 index 0000000..2ec8c3d --- /dev/null +++ b/src/client/zod.gen.ts @@ -0,0 +1,66 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zRequestOtpRequest = z.object({ + phone_number: z.string().max(15) +}); + +export const zRequestOtpResponse = z.object({ + message: z.string(), + ok: z.boolean() +}); + +export const zVerifyOtpRequest = z.object({ + phone_number: z.string().max(15), + otp: z.string().max(6), + login_challenge: z.string() +}); + +export const zVerifyOtpResponse = z.object({ + redirect_url: z.string(), + ok: z.boolean() +}); + +export const zAcceptConsentRequest = z.object({ + consent_challenge: z.string() +}); + +export const zAcceptConsentResponse = z.object({ + redirect_url: z.string(), + ok: z.boolean(), + message: z.string() +}); + +export const zPostAuthOtpRequestData = z.object({ + body: zRequestOtpRequest, + path: z.optional(z.never()), + query: z.optional(z.never()) +}); + +/** + * OTP requested successfully + */ +export const zPostAuthOtpRequestResponse = zRequestOtpResponse; + +export const zPostAuthOtpVerifyData = z.object({ + body: zVerifyOtpRequest, + path: z.optional(z.never()), + query: z.optional(z.never()) +}); + +/** + * OTP verified successfully + */ +export const zPostAuthOtpVerifyResponse = zVerifyOtpResponse; + +export const zPostAuthConsentAcceptData = z.object({ + body: zAcceptConsentRequest, + path: z.optional(z.never()), + query: z.optional(z.never()) +}); + +/** + * Consent accepted successfully + */ +export const zPostAuthConsentAcceptResponse = zAcceptConsentResponse; \ No newline at end of file diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx index 37869ee..cce55cc 100644 --- a/src/components/LoginForm/LoginForm.tsx +++ b/src/components/LoginForm/LoginForm.tsx @@ -4,11 +4,14 @@ import { FC, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Button, Stack } from "@mantine/core"; import { useForm } from "@mantine/form"; +import { Auth } from "@/client"; import PhoneInput from "@/components/PhoneInput/PhoneInput"; -import { useAppDispatch } from "@/lib/store"; -import { setLoginChallenge, setPhoneNumber } from "@/lib/features/auth/authSlice"; -import { AuthService } from "@/mocks/authService"; +import { + setLoginChallenge, + setPhoneNumber, +} from "@/lib/features/auth/authSlice"; import { notifications } from "@/lib/notifications"; +import { useAppDispatch } from "@/lib/store"; type LoginForm = { phoneNumber: string; @@ -39,21 +42,39 @@ const LoginForm: FC = ({ loginChallenge, isCreatingId = false }) => { }, [loginChallenge]); const handleSubmit = (values: LoginForm) => { - dispatch(setPhoneNumber(values.phoneNumber)); - - new AuthService().requestLogin(values.phoneNumber) + const phoneNumber = values.phoneNumber.replace(/ /g, ""); + dispatch(setPhoneNumber(phoneNumber)); + Auth.postAuthOtpRequest({ + body: { phone_number: phoneNumber }, + }) .then(response => response.data) - .then(({ ok, message }) => { - if (!ok) { - notifications.error({ message }); - } else { + .then(response => { + console.log(response); + if (!response) { + notifications.error({ + message: "Ошибка при отправке запроса", + }); + return; + } + const { ok, message } = response; + notifications.guess(ok, { message }); + if (ok) { router.push("/verify-phone"); } - }) - .catch(error => { - console.error(error); - notifications.error({ message: error.toString() }); - }) + }); + // new AuthService().requestLogin(values.phoneNumber) + // .then(response => response.data) + // .then(({ ok, message }) => { + // if (!ok) { + // notifications.error({ message }); + // } else { + // router.push("/verify-phone"); + // } + // }) + // .catch(error => { + // console.error(error); + // notifications.error({ message: error.toString() }); + // }) }; const navigateToCreateId = () => router.push("/create-id"); diff --git a/src/hey-api-config.ts b/src/hey-api-config.ts new file mode 100644 index 0000000..4327c35 --- /dev/null +++ b/src/hey-api-config.ts @@ -0,0 +1,6 @@ +import type { CreateClientConfig } from './client/client.gen'; + +export const createClientConfig: CreateClientConfig = (config) => ({ + ...config, + baseUrl: 'http://id.logidex.ru/api', +}); \ No newline at end of file