From 75bd97e8f91a642b73f4713684361b19f49e1871 Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Fri, 3 Jul 2026 10:51:56 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20runtime-?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлен корневой runtime export HttpClient и createApiClient - настроена сборка JS и d.ts для публичного API пакета - добавлен тест ручной сборки operations через createApiClient - добавлена документация по ручному клиенту без OpenAPI - версия пакета поднята до 5.1.0 --- README.md | 60 +++++ bun.lock | 1 + package.json | 18 +- src/client/create-api-client.ts | 44 +++ src/client/http-client.ts | 448 +++++++++++++++++++++++++++++++ src/index.ts | 20 ++ tests/unit/manual-client.test.ts | 97 +++++++ tsconfig.build.json | 14 + 8 files changed, 698 insertions(+), 4 deletions(-) create mode 100644 src/client/create-api-client.ts create mode 100644 src/client/http-client.ts create mode 100644 src/index.ts create mode 100644 tests/unit/manual-client.test.ts create mode 100644 tsconfig.build.json diff --git a/README.md b/README.md index e82f7ef..5788c45 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,66 @@ await api.users.getAll( ); ``` +## Ручной Клиент Без OpenAPI + +Если в проекте нет OpenAPI спецификации, можно использовать `HttpClient` напрямую из пакета и писать operation-функции вручную тем же стилем, который использует generated SDK. + +В этом сценарии `@gromlab/api-codegen` должен быть зависимостью проекта: + +```bash +bun add @gromlab/api-codegen +``` + +Пример ручной операции: + +```typescript +import { + ContentType, + HttpClient, + createApiClient, + type ApiRequestClient, + type RequestParams, +} from '@gromlab/api-codegen'; + +type User = { + id: string; + email: string; +}; + +type CreateUserPayload = { + email: string; +}; + +export const createUser = ( + http: ApiRequestClient, + body: CreateUserPayload, + requestParams: RequestParams = {}, +) => + http.request({ + path: '/users', + method: 'POST', + body, + type: ContentType.Json, + format: 'json', + secure: true, + ...requestParams, + }); + +const http = new HttpClient({ + baseUrl: 'https://api.example.com', +}); + +export const api = createApiClient(http, { + users: { + create: createUser, + }, +}); + +const user = await api.users.create({ email: 'user@example.com' }); +``` + +Ручной режим не связан с автогенерацией. Если вы запускаете `npx @gromlab/api-codegen` и генерируете REST SDK из OpenAPI, generated-код по-прежнему содержит и экспортирует собственные `HttpClient`, `createApiClient`, types и operations без runtime-зависимости от `@gromlab/api-codegen`. + ## API Как npm-Пакет Типичный workflow: diff --git a/bun.lock b/bun.lock index f0dedc2..81114d9 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "execa": "^8.0.0", "msw": "^2.0.0", "tmp": "^0.2.1", + "typescript": "^5", }, "peerDependencies": { "typescript": "^5", diff --git a/package.json b/package.json index 1949285..a426ebb 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,17 @@ { "name": "@gromlab/api-codegen", - "version": "5.0.1", - "description": "CLI tool to generate TypeScript API client from OpenAPI specification", + "version": "5.1.0", + "description": "CLI tool to generate TypeScript API client from OpenAPI specification and runtime HTTP client for manual APIs", "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "bin": { "api-codegen": "dist/cli.js" }, @@ -17,7 +26,7 @@ "node": ">=18" }, "scripts": { - "build": "bun build src/cli.ts --target=node --outdir=dist --format=esm --external=@biomejs/* && cp -r src/templates dist/", + "build": "bun build src/cli.ts --target=node --outdir=dist --format=esm --external=@biomejs/* && bun build src/index.ts --target=node --outfile=dist/index.js --format=esm && tsc -p tsconfig.build.json && cp -r src/templates dist/", "dev": "bun run src/cli.ts", "test": "bun test", "test:unit": "bun test tests/unit", @@ -43,7 +52,8 @@ "@types/tmp": "^0.2.6", "execa": "^8.0.0", "msw": "^2.0.0", - "tmp": "^0.2.1" + "tmp": "^0.2.1", + "typescript": "^5" }, "peerDependencies": { "typescript": "^5" diff --git a/src/client/create-api-client.ts b/src/client/create-api-client.ts new file mode 100644 index 0000000..b9beb50 --- /dev/null +++ b/src/client/create-api-client.ts @@ -0,0 +1,44 @@ +import type { ApiRequestClient } from './http-client.js'; + +export type ApiOperation = ( + client: TClient, + ...args: any[] +) => any; + +export type ApiTree = { + readonly [key: string]: ApiOperation | ApiTree; +}; + +export type BoundApi = { + readonly [K in keyof TTree]: TTree[K] extends ( + client: TClient, + ...args: infer Args + ) => infer Result + ? (...args: Args) => Result + : TTree[K] extends ApiTree + ? BoundApi + : never; +}; + +export const createApiClient = < + TClient extends ApiRequestClient, + const TTree extends ApiTree, +>( + client: TClient, + tree: TTree, +): BoundApi => { + const bindNode = (node: ApiOperation | ApiTree): unknown => { + if (typeof node === 'function') { + return (...args: unknown[]) => node(client, ...args); + } + + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + bindNode(value as ApiOperation | ApiTree), + ]), + ); + }; + + return bindNode(tree) as BoundApi; +}; diff --git a/src/client/http-client.ts b/src/client/http-client.ts new file mode 100644 index 0000000..e38c304 --- /dev/null +++ b/src/client/http-client.ts @@ -0,0 +1,448 @@ +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + secure?: boolean; + path: string; + type?: ContentType; + query?: QueryParamsType; + format?: ResponseFormat; + body?: unknown; + baseUrl?: string; + cancelToken?: CancelToken; + timeout?: number; +} + +export type RequestParams = Omit; + +export interface RequestContext { + url: string; + request: FullRequestParams; + retryCount: number; + retry: () => Promise; +} + +export type RequestInterceptor = ( + params: FullRequestParams, + context: RequestContext, +) => FullRequestParams | Promise; + +export type ResponseInterceptor = ( + response: HttpResponse, + context: RequestContext, +) => HttpResponse | Promise>; + +export type ErrorInterceptor = ( + error: unknown, + context: RequestContext, +) => TResult | Promise; + +export type ParamsSerializer = (query: QueryParamsType) => string; +export type ResponseParser = (response: Response, format?: ResponseFormat) => unknown | Promise; +export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export interface ApiRequestClient { + request(params: FullRequestParams): Promise; +} + +export interface ApiConfig extends Omit { + baseUrl?: string; + customFetch?: FetchLike; + paramsSerializer?: ParamsSerializer; + responseParser?: ResponseParser; + onRequest?: RequestInterceptor; + onResponse?: ResponseInterceptor; + onError?: ErrorInterceptor; +} + +export interface HttpResponse extends Response { + data: D; + error: E; +} + +export class ApiError extends Error { + public readonly status: number; + public readonly statusText: string; + public readonly response: Response; + public readonly data: unknown; + public readonly error: E; + public readonly request: FullRequestParams; + + constructor(response: Response, request: FullRequestParams, data: unknown, error: E) { + super(`Request failed with status ${response.status} ${response.statusText}`.trim()); + this.name = 'ApiError'; + this.status = response.status; + this.statusText = response.statusText; + this.response = response; + this.data = data; + this.error = error; + this.request = request; + } +} + +export type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = 'application/json', + JsonApi = 'application/vnd.api+json', + FormData = 'multipart/form-data', + UrlEncoded = 'application/x-www-form-urlencoded', + Text = 'text/plain', +} + +export class HttpClient implements ApiRequestClient { + public baseUrl = ''; + private abortControllers = new Map(); + private customFetch: FetchLike = (...fetchParams) => fetch(...fetchParams); + private paramsSerializer?: ParamsSerializer; + private responseParser?: ResponseParser; + private onRequest?: RequestInterceptor; + private onResponse?: ResponseInterceptor; + private onError?: ErrorInterceptor; + + private baseRequestParams: RequestParams = { + credentials: 'same-origin', + headers: {}, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }; + + constructor({ + baseUrl, + customFetch, + paramsSerializer, + responseParser, + onRequest, + onResponse, + onError, + ...baseRequestParams + }: ApiConfig = {}) { + if (typeof baseUrl === 'string') { + this.baseUrl = baseUrl; + } + + this.customFetch = customFetch || this.customFetch; + this.paramsSerializer = paramsSerializer; + this.responseParser = responseParser; + this.onRequest = onRequest; + this.onResponse = onResponse; + this.onError = onError; + this.baseRequestParams = this.mergeRequestParams(this.baseRequestParams, baseRequestParams); + } + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return `${encodedKey}=${encodeURIComponent(typeof value === 'number' ? value : `${value}`)}`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join('&'); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + + if (this.paramsSerializer) { + return this.paramsSerializer(query); + } + + const keys = Object.keys(query).filter((key) => 'undefined' !== typeof query[key]); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join('&'); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? `?${queryString}` : ''; + } + + protected buildRequestUrl(baseUrl: string | undefined, path: string, query?: QueryParamsType): string { + return `${baseUrl || this.baseUrl || ''}${path}${this.addQueryParams(query)}`; + } + + protected createRequestContext( + request: FullRequestParams, + retryCount: number, + retry: () => Promise, + ): RequestContext { + return { + url: this.buildRequestUrl(request.baseUrl, request.path, request.query), + request, + retryCount, + retry, + }; + } + + protected updateRequestContext(context: RequestContext, request: FullRequestParams) { + context.request = request; + context.url = this.buildRequestUrl(request.baseUrl, request.path, request.query); + } + + protected mergeHeaders(...headers: Array): HeadersInit { + const mergedHeaders = new Headers(); + + headers.forEach((headers) => { + if (!headers) { + return; + } + + new Headers(headers).forEach((value, key) => mergedHeaders.set(key, value)); + }); + + return Object.fromEntries(mergedHeaders.entries()); + } + + protected mergeRequestParams>( + params1: T, + params2?: Partial, + ): T { + return { + ...params1, + ...(params2 || {}), + headers: this.mergeHeaders(params1.headers, params2?.headers), + } as T; + } + + protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + protected createRequestSignal = ( + signal?: AbortSignal | null, + cancelToken?: CancelToken, + timeout?: number, + ): { signal: AbortSignal | null; cleanup: () => void } => { + const signals: AbortSignal[] = []; + let timeoutId: ReturnType | undefined; + + if (signal) { + signals.push(signal); + } + + if (cancelToken) { + const cancelSignal = this.createAbortSignal(cancelToken); + + if (cancelSignal) { + signals.push(cancelSignal); + } + } + + if (typeof timeout === 'number' && timeout > 0) { + const timeoutController = new AbortController(); + timeoutId = setTimeout(() => timeoutController.abort(), timeout); + signals.push(timeoutController.signal); + } + + const cleanupTimeout = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + + if (signals.length === 0) { + return { signal: null, cleanup: cleanupTimeout }; + } + + if (signals.length === 1) { + return { signal: signals[0] || null, cleanup: cleanupTimeout }; + } + + const abortController = new AbortController(); + const abortRequest = () => abortController.abort(); + + signals.forEach((signal) => { + if (signal.aborted) { + abortController.abort(); + } else { + signal.addEventListener('abort', abortRequest, { once: true }); + } + }); + + return { + signal: abortController.signal, + cleanup: () => { + cleanupTimeout(); + signals.forEach((signal) => signal.removeEventListener('abort', abortRequest)); + }, + }; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === 'object' || typeof input === 'string') ? JSON.stringify(input) : input, + [ContentType.JsonApi]: (input: any) => + input !== null && (typeof input === 'object' || typeof input === 'string') ? JSON.stringify(input) : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== 'string' ? JSON.stringify(input) : input, + [ContentType.FormData]: (input: any) => { + if (input instanceof FormData) { + return input; + } + + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === 'object' && property !== null + ? JSON.stringify(property) + : `${property}`, + ); + return formData; + }, new FormData()); + }, + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected parseResponse = async ( + response: Response, + responseFormat?: ResponseFormat, + ): Promise> => { + const parsedResponse = response as HttpResponse; + parsedResponse.data = null as unknown as T; + parsedResponse.error = null as unknown as E; + + if (!responseFormat && !this.responseParser) { + return parsedResponse; + } + + const responseToParse = response.clone(); + + await Promise.resolve( + this.responseParser + ? this.responseParser(responseToParse, responseFormat) + : responseToParse[responseFormat as ResponseFormat](), + ) + .then((data) => { + if (parsedResponse.ok) { + parsedResponse.data = data as T; + } else { + parsedResponse.error = data as E; + } + }) + .catch((error) => { + if (parsedResponse.ok) { + throw error; + } + + parsedResponse.error = error as E; + }); + + return parsedResponse; + }; + + public request = async (requestParams: FullRequestParams): Promise => { + return this.requestWithRetry(requestParams, 0); + }; + + private requestWithRetry = async ( + requestParams: FullRequestParams, + retryCount: number, + ): Promise => { + let request = this.mergeRequestParams(this.baseRequestParams, requestParams) as FullRequestParams; + request.baseUrl = request.baseUrl || this.baseUrl; + request.secure = typeof request.secure === 'boolean' ? request.secure : this.baseRequestParams.secure; + + const context = this.createRequestContext( + request, + retryCount, + () => this.requestWithRetry(requestParams, retryCount + 1), + ); + + let cleanupSignal = () => {}; + let cancelToken: CancelToken | undefined; + + const cleanupRequest = () => { + cleanupSignal(); + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }; + + try { + if (this.onRequest) { + request = await this.onRequest(request, context); + this.updateRequestContext(context, request); + } + + const { + body, + path, + type, + query, + format, + baseUrl, + cancelToken: requestCancelToken, + timeout, + ...params + } = request; + + cancelToken = requestCancelToken; + const { signal, cleanup } = this.createRequestSignal(params.signal, cancelToken, timeout); + cleanupSignal = cleanup; + + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const response = await this.customFetch(context.url, { + ...params, + headers: this.mergeHeaders( + params.headers, + type && type !== ContentType.FormData ? { 'Content-Type': type } : undefined, + ), + signal, + body: typeof body === 'undefined' || body === null ? null : payloadFormatter(body), + }); + + const parsedResponse = await this.parseResponse(response, format); + + if (!parsedResponse.ok) { + throw new ApiError(parsedResponse, request, parsedResponse.error || parsedResponse.data, parsedResponse.error); + } + + const finalResponse = this.onResponse + ? await this.onResponse(parsedResponse, context) + : parsedResponse; + + return finalResponse.data; + } catch (error) { + cleanupRequest(); + + if (this.onError) { + return this.onError(error, context); + } + + throw error; + } finally { + cleanupRequest(); + } + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e9b4919 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,20 @@ +export { createApiClient } from './client/create-api-client.js'; +export type { ApiOperation, ApiTree, BoundApi } from './client/create-api-client.js'; +export { ApiError, ContentType, HttpClient } from './client/http-client.js'; +export type { + ApiConfig, + ApiRequestClient, + CancelToken, + ErrorInterceptor, + FetchLike, + FullRequestParams, + HttpResponse, + ParamsSerializer, + QueryParamsType, + RequestContext, + RequestInterceptor, + RequestParams, + ResponseFormat, + ResponseInterceptor, + ResponseParser, +} from './client/http-client.js'; diff --git a/tests/unit/manual-client.test.ts b/tests/unit/manual-client.test.ts new file mode 100644 index 0000000..3aafdee --- /dev/null +++ b/tests/unit/manual-client.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test'; +import { + ContentType, + HttpClient, + createApiClient, + type ApiRequestClient, + type RequestParams, +} from '../../src/index.js'; + +describe('Manual runtime client', () => { + test('должен собирать ручные операции через createApiClient', async () => { + type User = { + id: string; + email: string; + }; + + type CreateUserQuery = { + invite?: boolean; + }; + + type CreateUserPayload = { + email: string; + }; + + const createUser = ( + http: ApiRequestClient, + query: CreateUserQuery, + body: CreateUserPayload, + requestParams: RequestParams = {}, + ) => + http.request({ + path: '/users', + method: 'POST', + query, + body, + type: ContentType.Json, + format: 'json', + secure: true, + ...requestParams, + }); + + let requestUrl = ''; + let requestInit: RequestInit | undefined; + + const http = new HttpClient({ + baseUrl: 'https://api.example.com', + customFetch: async (url, init) => { + requestUrl = String(url); + requestInit = init; + + return Response.json({ id: '1', email: 'user@example.com' }); + }, + onRequest: (params) => { + if (!params.secure) { + return params; + } + + const headers = new Headers(params.headers); + + if (!headers.has('Authorization')) { + headers.set('Authorization', 'Bearer manual-token'); + } + + return { + ...params, + headers, + }; + }, + }); + + const api = createApiClient(http, { + users: { + create: createUser, + }, + }); + + const user = await api.users.create( + { invite: true }, + { email: 'user@example.com' }, + { + headers: { + 'X-Request-Id': 'request-1', + }, + }, + ); + + const headers = new Headers(requestInit?.headers); + + expect(user).toEqual({ id: '1', email: 'user@example.com' }); + expect(requestUrl).toBe('https://api.example.com/users?invite=true'); + expect(requestInit?.method).toBe('POST'); + expect(requestInit?.body).toBe(JSON.stringify({ email: 'user@example.com' })); + expect(headers.get('Authorization')).toBe('Bearer manual-token'); + expect(headers.get('Content-Type')).toBe(ContentType.Json); + expect(headers.get('X-Request-Id')).toBe('request-1'); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3c46fde --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": false, + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/index.ts", "src/client/**/*.ts"] +}