Compare commits
1 Commits
b8bb267848
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 75bd97e8f9 |
60
README.md
60
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<User>({
|
||||||
|
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-Пакет
|
## API Как npm-Пакет
|
||||||
|
|
||||||
Типичный workflow:
|
Типичный workflow:
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -21,6 +21,7 @@
|
|||||||
"execa": "^8.0.0",
|
"execa": "^8.0.0",
|
||||||
"msw": "^2.0.0",
|
"msw": "^2.0.0",
|
||||||
"tmp": "^0.2.1",
|
"tmp": "^0.2.1",
|
||||||
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -1,8 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@gromlab/api-codegen",
|
"name": "@gromlab/api-codegen",
|
||||||
"version": "5.0.1",
|
"version": "5.1.0",
|
||||||
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
|
"description": "CLI tool to generate TypeScript API client from OpenAPI specification and runtime HTTP client for manual APIs",
|
||||||
"type": "module",
|
"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": {
|
"bin": {
|
||||||
"api-codegen": "dist/cli.js"
|
"api-codegen": "dist/cli.js"
|
||||||
},
|
},
|
||||||
@@ -17,7 +26,7 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"dev": "bun run src/cli.ts",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:unit": "bun test tests/unit",
|
"test:unit": "bun test tests/unit",
|
||||||
@@ -43,7 +52,8 @@
|
|||||||
"@types/tmp": "^0.2.6",
|
"@types/tmp": "^0.2.6",
|
||||||
"execa": "^8.0.0",
|
"execa": "^8.0.0",
|
||||||
"msw": "^2.0.0",
|
"msw": "^2.0.0",
|
||||||
"tmp": "^0.2.1"
|
"tmp": "^0.2.1",
|
||||||
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
44
src/client/create-api-client.ts
Normal file
44
src/client/create-api-client.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ApiRequestClient } from './http-client.js';
|
||||||
|
|
||||||
|
export type ApiOperation<TClient extends ApiRequestClient = ApiRequestClient> = (
|
||||||
|
client: TClient,
|
||||||
|
...args: any[]
|
||||||
|
) => any;
|
||||||
|
|
||||||
|
export type ApiTree<TClient extends ApiRequestClient = ApiRequestClient> = {
|
||||||
|
readonly [key: string]: ApiOperation<TClient> | ApiTree<TClient>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BoundApi<TTree, TClient extends ApiRequestClient> = {
|
||||||
|
readonly [K in keyof TTree]: TTree[K] extends (
|
||||||
|
client: TClient,
|
||||||
|
...args: infer Args
|
||||||
|
) => infer Result
|
||||||
|
? (...args: Args) => Result
|
||||||
|
: TTree[K] extends ApiTree<TClient>
|
||||||
|
? BoundApi<TTree[K], TClient>
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createApiClient = <
|
||||||
|
TClient extends ApiRequestClient,
|
||||||
|
const TTree extends ApiTree<TClient>,
|
||||||
|
>(
|
||||||
|
client: TClient,
|
||||||
|
tree: TTree,
|
||||||
|
): BoundApi<TTree, TClient> => {
|
||||||
|
const bindNode = (node: ApiOperation<TClient> | ApiTree<TClient>): 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<TClient> | ApiTree<TClient>),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return bindNode(tree) as BoundApi<TTree, TClient>;
|
||||||
|
};
|
||||||
448
src/client/http-client.ts
Normal file
448
src/client/http-client.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
export type QueryParamsType = Record<string | number, any>;
|
||||||
|
export type ResponseFormat = keyof Omit<Body, 'body' | 'bodyUsed'>;
|
||||||
|
|
||||||
|
export interface FullRequestParams extends Omit<RequestInit, 'body'> {
|
||||||
|
secure?: boolean;
|
||||||
|
path: string;
|
||||||
|
type?: ContentType;
|
||||||
|
query?: QueryParamsType;
|
||||||
|
format?: ResponseFormat;
|
||||||
|
body?: unknown;
|
||||||
|
baseUrl?: string;
|
||||||
|
cancelToken?: CancelToken;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestParams = Omit<FullRequestParams, 'body' | 'method' | 'query' | 'path'>;
|
||||||
|
|
||||||
|
export interface RequestContext<TResult = unknown> {
|
||||||
|
url: string;
|
||||||
|
request: FullRequestParams;
|
||||||
|
retryCount: number;
|
||||||
|
retry: () => Promise<TResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestInterceptor = (
|
||||||
|
params: FullRequestParams,
|
||||||
|
context: RequestContext,
|
||||||
|
) => FullRequestParams | Promise<FullRequestParams>;
|
||||||
|
|
||||||
|
export type ResponseInterceptor = <D = unknown, E = unknown>(
|
||||||
|
response: HttpResponse<D, E>,
|
||||||
|
context: RequestContext,
|
||||||
|
) => HttpResponse<D, E> | Promise<HttpResponse<D, E>>;
|
||||||
|
|
||||||
|
export type ErrorInterceptor = <TResult = unknown>(
|
||||||
|
error: unknown,
|
||||||
|
context: RequestContext<TResult>,
|
||||||
|
) => TResult | Promise<TResult>;
|
||||||
|
|
||||||
|
export type ParamsSerializer = (query: QueryParamsType) => string;
|
||||||
|
export type ResponseParser = (response: Response, format?: ResponseFormat) => unknown | Promise<unknown>;
|
||||||
|
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface ApiRequestClient {
|
||||||
|
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiConfig<SecurityDataType = unknown> extends Omit<RequestParams, 'baseUrl' | 'cancelToken' | 'signal'> {
|
||||||
|
baseUrl?: string;
|
||||||
|
customFetch?: FetchLike;
|
||||||
|
paramsSerializer?: ParamsSerializer;
|
||||||
|
responseParser?: ResponseParser;
|
||||||
|
onRequest?: RequestInterceptor;
|
||||||
|
onResponse?: ResponseInterceptor;
|
||||||
|
onError?: ErrorInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
|
||||||
|
data: D;
|
||||||
|
error: E;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError<E = unknown> 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<SecurityDataType = unknown> implements ApiRequestClient {
|
||||||
|
public baseUrl = '';
|
||||||
|
private abortControllers = new Map<CancelToken, AbortController>();
|
||||||
|
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<SecurityDataType> = {}) {
|
||||||
|
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<TResult>(
|
||||||
|
request: FullRequestParams,
|
||||||
|
retryCount: number,
|
||||||
|
retry: () => Promise<TResult>,
|
||||||
|
): RequestContext<TResult> {
|
||||||
|
return {
|
||||||
|
url: this.buildRequestUrl(request.baseUrl, request.path, request.query),
|
||||||
|
request,
|
||||||
|
retryCount,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateRequestContext<TResult>(context: RequestContext<TResult>, request: FullRequestParams) {
|
||||||
|
context.request = request;
|
||||||
|
context.url = this.buildRequestUrl(request.baseUrl, request.path, request.query);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected mergeHeaders(...headers: Array<HeadersInit | undefined>): 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<T extends Partial<FullRequestParams>>(
|
||||||
|
params1: T,
|
||||||
|
params2?: Partial<FullRequestParams>,
|
||||||
|
): 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<typeof setTimeout> | 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<ContentType, (input: any) => 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 <T = any, E = any>(
|
||||||
|
response: Response,
|
||||||
|
responseFormat?: ResponseFormat,
|
||||||
|
): Promise<HttpResponse<T, E>> => {
|
||||||
|
const parsedResponse = response as HttpResponse<T, E>;
|
||||||
|
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 <T = any, E = any>(requestParams: FullRequestParams): Promise<T> => {
|
||||||
|
return this.requestWithRetry<T, E>(requestParams, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
private requestWithRetry = async <T = any, E = any>(
|
||||||
|
requestParams: FullRequestParams,
|
||||||
|
retryCount: number,
|
||||||
|
): Promise<T> => {
|
||||||
|
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<T>(
|
||||||
|
request,
|
||||||
|
retryCount,
|
||||||
|
() => this.requestWithRetry<T, E>(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<T, E>(response, format);
|
||||||
|
|
||||||
|
if (!parsedResponse.ok) {
|
||||||
|
throw new ApiError<E>(parsedResponse, request, parsedResponse.error || parsedResponse.data, parsedResponse.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResponse = this.onResponse
|
||||||
|
? await this.onResponse<T, E>(parsedResponse, context)
|
||||||
|
: parsedResponse;
|
||||||
|
|
||||||
|
return finalResponse.data;
|
||||||
|
} catch (error) {
|
||||||
|
cleanupRequest();
|
||||||
|
|
||||||
|
if (this.onError) {
|
||||||
|
return this.onError(error, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
cleanupRequest();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/index.ts
Normal file
20
src/index.ts
Normal file
@@ -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';
|
||||||
97
tests/unit/manual-client.test.ts
Normal file
97
tests/unit/manual-client.test.ts
Normal file
@@ -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<User>({
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
14
tsconfig.build.json
Normal file
14
tsconfig.build.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user