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-Пакет
|
||||
|
||||
Типичный workflow:
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -21,6 +21,7 @@
|
||||
"execa": "^8.0.0",
|
||||
"msw": "^2.0.0",
|
||||
"tmp": "^0.2.1",
|
||||
"typescript": "^5",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
|
||||
18
package.json
18
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"
|
||||
|
||||
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