import { bubble } from "@web3r/flowerkit/evt"; import type { TGetFromServerArgs } from "@web3r/flowerkit/net"; import { getFromServer } from "@web3r/flowerkit/net"; import { getDocument } from "ssr-window"; import type { IResp } from "../../types/api"; /** * Неизвестный ответ от REST по-умолчанию */ type TDefaultResp = Record; /** * Функция-адаптер для преобразования ответа сервера. * @template TResponse Тип исходного ответа. * @template TResult Тип результата после адаптации. */ export type TAdapterFn, TResult = TResponse> = (resp: TResponse) => TResult; /** * Тип промиса API-запроса с поддержкой цепочки валидации. * @template TResult Тип результата API-запроса. */ export type TApiClientRequest = Promise & { validate: ( schema: TValidationSchema, options?: Record, ) => Promise; }; /** * Параметры инициализации API-клиента. */ export type TArgs = Partial<{ url: string; getAdapterFn: TAdapterFn; }>; /** * Асинхронная функция-адаптер для преобразования ответа сервера. * @template TResponse Тип исходного ответа. * @template TResult Тип результата после адаптации. */ export type TAsyncAdapterFn, TResult = TResponse> = ( resp: TResponse ) => PromiseLike; /** * Универсальный асинхронный адаптер для извлечения `data` из `IResp`. */ export type TAsyncRespAdapterFn = (resp: IResp) => PromiseLike; /** * Допустимые значения заголовка `Content-Type` для API-запросов. */ export type TContentType = | "application/json" | "application/octet-stream" | "text/plain" | "text/xml" | "text/html" | "multipart/form-data"; /** * Допустимые HTTP-методы запроса. */ export type TMethod = | "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "TRACE" | "HEAD"; /** * Синхронный или асинхронный адаптер ответа сервера. * @template TResponse Тип исходного ответа. * @template TResult Тип результата после адаптации. */ export type TRequestAdapterFn, TResult = TResponse> = | TAdapterFn | TAsyncAdapterFn; /** * Параметры HTTP-запроса API-клиента. * @template TResponse Тип исходного ответа. * @template TResult Тип результата после адаптации. */ export type TRequestProps, TResult = TResponse> = { signal?: AbortSignal; endpoint: string; data?: unknown; method?: TMethod; contentType?: TContentType; responseType?: TResponseType; getAdapterFn?: TRequestAdapterFn; credentials?: RequestCredentials; headers?: Record; timeout?: number; }; /** * Универсальный адаптер для извлечения `data` из `IResp`. */ export type TRespAdapterFn = (resp: IResp) => TData; /** * Тип ожидаемого тела ответа. */ export type TResponseType = "text" | "blob" | "arrayBuffer" | "json"; type TAllowedMethod = NonNullable["method"]>; type TAllowedContentType = NonNullable["contentType"]>; type TAllowedData = TGetFromServerArgs["data"]; const isPlainObject = (value: unknown): value is Record => { return typeof value === "object" && value !== null && !Array.isArray(value); }; const getAllowedMethod = (method: TMethod): TAllowedMethod => { switch (method) { case "GET": case "POST": case "PUT": case "DELETE": case "HEAD": case "OPTIONS": case "TRACE": return method; case "PATCH": return "POST"; default: return "GET"; } }; const getAllowedContentType = (contentType: TContentType): TAllowedContentType => { switch (contentType) { case "application/json": case "multipart/form-data": return contentType; default: return "auto"; } }; const getAllowedData = (data: unknown): TAllowedData => { if (typeof data === "undefined" || data === null) { return null; } if (data instanceof FormData) { return data; } if (isPlainObject(data)) { return data; } return null; }; /** * Базовый контракт yup-схемы для валидации ответа. * @template TValue Тип входного значения для валидации. * @template TResult Тип результата после валидации. */ export type TValidationSchema = { validate?: (value: TValue, options?: Record) => Promise | TResult; validateSync?: (value: TValue, options?: Record) => TResult; }; /** * Клиент для работы с API. Singleton. * @example * const Api = new ApiClient({ * url: process.env.API_URL, * ver: "api/v1" * }); * await Api.post({ * endpoint: "myEndpoint/", * data: new FormData() * }).then(data => data); */ export class ApiClient { /** * Singleton-экземпляр API-клиента. */ static instance: ApiClient | null; /** * Конфигурация API-клиента по умолчанию. * Используется как fallback для `url` и `getAdapterFn`. */ static defaults: Required = { url: String(process.env?.API_URL ?? "/api/v1"), getAdapterFn: (resp) => resp, }; /** * Имена всплывающих событий результата API-запросов. */ static events = { success: "apiClient::success", error: "apiClient::error", }; private getAdapterFn: TAdapterFn; #version: string; #url: string; /** * Сохраняет точные входной и выходной типы синхронного или асинхронного адаптера. * @template TResponse Тип исходного ответа. * @template TResult Тип результата после адаптации. * @param adapter Адаптер ответа. * @returns Переданный адаптер с сохраненными типами. */ static defineAdapter = ( adapter: TAdapterFn ): TAdapterFn => adapter; constructor(props: TArgs) { if (!ApiClient.instance) { ApiClient.instance = this; } this.url = props?.url ?? ApiClient.defaults.url; this.getAdapterFn = typeof props?.getAdapterFn === "function" ? props.getAdapterFn : ApiClient.defaults.getAdapterFn; return ApiClient.instance; } /** * Получает базовый URL API * @returns {String} базовый URL */ get url() { return this.#url; } /** * Устанавливает базовый URL API * @param url{String} новый базовый URL */ set url(url) { this.#url = url; } /** * Формирует полный URL endpoint из базового URL и относительного пути * @param baseUrl{String} базовый URL * @param endpoint{String} относительный путь endpoint * @returns {String} полный URL endpoint */ static getEndpoint(baseUrl: string, endpoint: string) { return endpoint.startsWith("/") || baseUrl.endsWith("/") ? `${baseUrl}${endpoint}` : `${baseUrl}/${endpoint}`; } /** * Отправляет GET запрос на endpoint * @template TResponse - тип ответа сервера * @template TResult - тип результата после адаптера * @param params{Object} параметры запроса * @param params.endpoint{String} адрес endpoint * @param params.data{Object=} данные запроса * @param params.signal{AbortSignal=} сигнал для отмены запроса * @param params.responseType{String=} тип ожидаемого ответа (text, blob, arrayBuffer, json) * @param params.getAdapterFn{Function=} функция-адаптер для преобразования ответа * @param params.credentials{String=} режим передачи credentials * @param params.headers{Object=} дополнительные заголовки * @param params.timeout{Number=} таймаут запроса в миллисекундах * @returns {Promise} промис с результатом запроса */ get({ endpoint, data, ...props }: TRequestProps): TApiClientRequest { return this.#appendValidationToRequest(this.#getRequestResult({ ...props, endpoint, data, method: "GET", })); } /** * Отправляет POST запрос на endpoint * @template TResponse - тип ответа сервера * @template TResult - тип результата после адаптера * @param params{Object} параметры запроса * @param params.endpoint{String} адрес endpoint * @param params.data{Object=} данные запроса * @param params.signal{AbortSignal=} сигнал для отмены запроса * @param params.contentType{String=} тип контента запроса * @param params.responseType{String=} тип ожидаемого ответа (text, blob, arrayBuffer, json) * @param params.getAdapterFn{Function=} функция-адаптер для преобразования ответа * @param params.credentials{String=} режим передачи credentials * @param params.headers{Object=} дополнительные заголовки * @param params.timeout{Number=} таймаут запроса в миллисекундах * @returns {Promise} промис с результатом запроса */ post({ endpoint, data, ...props }: TRequestProps): TApiClientRequest { return this.#appendValidationToRequest(this.#getRequestResult({ ...props, endpoint, data, method: "POST", })); } /** * Добавляет к промису запроса чейнинговый метод `validate`. * @private * @template TResult Тип результата API-запроса. * @param request{Promise} промис запроса. * @returns {TApiClientRequest} расширенный промис запроса. */ #appendValidationToRequest(request: Promise): TApiClientRequest { const withValidation = request as TApiClientRequest; withValidation.validate = ( schema: TValidationSchema, options: Record = {} ): Promise => { const hasAsyncValidation = !!schema && typeof schema.validate === "function"; const hasSyncValidation = !!schema && typeof schema.validateSync === "function"; if (!hasAsyncValidation && !hasSyncValidation) { return Promise.reject(new TypeError("[ApiClient] Invalid validation schema. Use yup schema with validate or validateSync")); } return request.then((resp: TResult) => { if (hasAsyncValidation) { return Promise.resolve((schema.validate as (value: TResult, opts?: Record) => Promise | TValidated)(resp, options)); } return Promise.resolve((schema.validateSync as (value: TResult, opts?: Record) => TValidated)(resp, options)); }); }; return withValidation; } /** * Выполняет запрос * @param endpoint{String} endpoint запроса * @param data{Object=} объект или FormData с данными * @param signal{AbortSignal} экземпляр вызова AbortSignal * @param type{String=} тип ожидаемого ответа * @param contentType{String=} тип контента запроса * @param method{String=} метод запроса * @param credentials{String=} * @param headers * @returns {Promise} */ async #getRequestResult({ signal, endpoint, data, method = "GET", contentType = "application/json", responseType = "json", getAdapterFn, credentials = "include", headers = { "X-Requested-With": "XMLHttpRequest", }, timeout = 20000, }: TRequestProps): Promise { const root = getDocument().documentElement; const url = ApiClient.getEndpoint(this.url, endpoint); const adapter = (getAdapterFn ?? this.getAdapterFn) as TRequestAdapterFn; const request = getFromServer({ type: responseType, url, timeout, data: getAllowedData(data), method: getAllowedMethod(method), contentType: getAllowedContentType(contentType), signal, credentials, headers, }); return await request .then(async (resp: TResponse) => { const adaptedResp = await adapter(resp); bubble(root, ApiClient.events.success, { url, data, resp: adaptedResp }); return adaptedResp; }) .catch((resp: unknown) => { bubble(root, ApiClient.events.error, { url, data, resp }); return Promise.reject(resp); }); } }