feature/openapi #7

Merged
GW_MC merged 10 commits from feature/openapi into master 2025-12-05 20:50:37 +08:00
4 changed files with 80 additions and 52 deletions
Showing only changes of commit d33f4f103f - Show all commits

View File

@@ -14,9 +14,9 @@ export namespace Endpoints {
// <Endpoints> // <Endpoints>
export type get_Get_health_info = { export type get_Get_health_info = {
method: 'GET'; method: "GET";
path: '/api/health/info'; path: "/api/health/info";
requestFormat: 'json'; requestFormat: "json";
parameters: never; parameters: never;
responses: { 200: Schemas.HealthInfo; 404: unknown }; responses: { 200: Schemas.HealthInfo; 404: unknown };
}; };
@@ -27,14 +27,14 @@ export namespace Endpoints {
// <EndpointByMethod> // <EndpointByMethod>
export type EndpointByMethod = { export type EndpointByMethod = {
get: { get: {
'/api/health/info': Endpoints.get_Get_health_info; "/api/health/info": Endpoints.get_Get_health_info;
}; };
}; };
// </EndpointByMethod> // </EndpointByMethod>
// <EndpointByMethod.Shorthands> // <EndpointByMethod.Shorthands>
export type GetEndpoints = EndpointByMethod['get']; export type GetEndpoints = EndpointByMethod["get"];
// </EndpointByMethod.Shorthands> // </EndpointByMethod.Shorthands>
// <ApiClientTypes> // <ApiClientTypes>
@@ -45,10 +45,10 @@ export type EndpointParameters = {
path?: Record<string, unknown>; path?: Record<string, unknown>;
}; };
export type MutationMethod = 'post' | 'put' | 'patch' | 'delete'; export type MutationMethod = "post" | "put" | "patch" | "delete";
export type Method = 'get' | 'head' | 'options' | MutationMethod; export type Method = "get" | "head" | "options" | MutationMethod;
type RequestFormat = 'json' | 'form-data' | 'form-url' | 'binary' | 'text'; type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text";
export type DefaultEndpoint = { export type DefaultEndpoint = {
parameters?: EndpointParameters | undefined; parameters?: EndpointParameters | undefined;
@@ -61,14 +61,14 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
method: Method; method: Method;
path: string; path: string;
requestFormat: RequestFormat; requestFormat: RequestFormat;
parameters?: TConfig['parameters']; parameters?: TConfig["parameters"];
meta: { meta: {
alias: string; alias: string;
hasParameters: boolean; hasParameters: boolean;
areParametersRequired: boolean; areParametersRequired: boolean;
}; };
responses?: TConfig['responses']; responses?: TConfig["responses"];
responseHeaders?: TConfig['responseHeaders']; responseHeaders?: TConfig["responseHeaders"];
}; };
export interface Fetcher { export interface Fetcher {
@@ -87,28 +87,30 @@ export interface Fetcher {
parseResponseData?: (response: Response) => Promise<unknown>; parseResponseData?: (response: Response) => Promise<unknown>;
} }
export const successStatusCodes = [200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308] as const; export const successStatusCodes = [
200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308,
] as const;
export type SuccessStatusCode = (typeof successStatusCodes)[number]; export type SuccessStatusCode = (typeof successStatusCodes)[number];
export const errorStatusCodes = [ export const errorStatusCodes = [
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424,
504, 505, 506, 507, 508, 510, 511, 425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
] as const; ] as const;
export type ErrorStatusCode = (typeof errorStatusCodes)[number]; export type ErrorStatusCode = (typeof errorStatusCodes)[number];
// Taken from https://github.com/unjs/fetchdts/blob/ec4eaeab5d287116171fc1efd61f4a1ad34e4609/src/fetch.ts#L3 // Taken from https://github.com/unjs/fetchdts/blob/ec4eaeab5d287116171fc1efd61f4a1ad34e4609/src/fetch.ts#L3
export interface TypedHeaders<TypedHeaderValues extends Record<string, string> | unknown> export interface TypedHeaders<TypedHeaderValues extends Record<string, string> | unknown>
extends Omit<Headers, 'append' | 'delete' | 'get' | 'getSetCookie' | 'has' | 'set' | 'forEach'> { extends Omit<Headers, "append" | "delete" | "get" | "getSetCookie" | "has" | "set" | "forEach"> {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) */ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) */
append: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>( append: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(
name: Name, name: Name,
value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string,
) => void; ) => void;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) */ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) */
delete: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(name: Name) => void; delete: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(name: Name) => void;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) */ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) */
get: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>( get: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(
name: Name name: Name,
) => (Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string) | null; ) => (Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string) | null;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) */ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) */
getSetCookie: () => string[]; getSetCookie: () => string[];
@@ -117,20 +119,21 @@ export interface TypedHeaders<TypedHeaderValues extends Record<string, string> |
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) */ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) */
set: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>( set: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(
name: Name, name: Name,
value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string,
) => void; ) => void;
forEach: ( forEach: (
callbackfn: ( callbackfn: (
value: TypedHeaderValues[keyof TypedHeaderValues] | (string & {}), value: TypedHeaderValues[keyof TypedHeaderValues] | (string & {}),
key: Extract<keyof TypedHeaderValues, string> | (string & {}), key: Extract<keyof TypedHeaderValues, string> | (string & {}),
parent: TypedHeaders<TypedHeaderValues> parent: TypedHeaders<TypedHeaderValues>,
) => void, ) => void,
thisArg?: any thisArg?: any,
) => void; ) => void;
} }
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */ /** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
export interface TypedSuccessResponse<TSuccess, TStatusCode, THeaders> extends Omit<Response, 'ok' | 'status' | 'json' | 'headers'> { export interface TypedSuccessResponse<TSuccess, TStatusCode, THeaders>
extends Omit<Response, "ok" | "status" | "json" | "headers"> {
ok: true; ok: true;
status: TStatusCode; status: TStatusCode;
headers: never extends THeaders ? Headers : TypedHeaders<THeaders>; headers: never extends THeaders ? Headers : TypedHeaders<THeaders>;
@@ -140,7 +143,8 @@ export interface TypedSuccessResponse<TSuccess, TStatusCode, THeaders> extends O
} }
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */ /** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
export interface TypedErrorResponse<TData, TStatusCode, THeaders> extends Omit<Response, 'ok' | 'status' | 'json' | 'headers'> { export interface TypedErrorResponse<TData, TStatusCode, THeaders>
extends Omit<Response, "ok" | "status" | "json" | "headers"> {
ok: false; ok: false;
status: TStatusCode; status: TStatusCode;
headers: never extends THeaders ? Headers : TypedHeaders<THeaders>; headers: never extends THeaders ? Headers : TypedHeaders<THeaders>;
@@ -169,7 +173,10 @@ export type SafeApiResponse<TEndpoint> = TEndpoint extends { responses: infer TR
: never : never
: never; : never;
export type InferResponseByStatus<TEndpoint, TStatusCode> = Extract<SafeApiResponse<TEndpoint>, { status: TStatusCode }>; export type InferResponseByStatus<TEndpoint, TStatusCode> = Extract<
SafeApiResponse<TEndpoint>,
{ status: TStatusCode }
>;
type RequiredKeys<T> = { type RequiredKeys<T> = {
[P in keyof T]-?: undefined extends T[P] ? never : P; [P in keyof T]-?: undefined extends T[P] ? never : P;
@@ -186,7 +193,7 @@ export class TypedStatusError<TData = unknown> extends Error {
status: number; status: number;
constructor(response: TypedErrorResponse<TData, ErrorStatusCode, unknown>) { constructor(response: TypedErrorResponse<TData, ErrorStatusCode, unknown>) {
super(`HTTP ${response.status}: ${response.statusText}`); super(`HTTP ${response.status}: ${response.statusText}`);
this.name = 'TypedStatusError'; this.name = "TypedStatusError";
this.response = response; this.response = response;
this.status = response.status; this.status = response.status;
} }
@@ -195,7 +202,7 @@ export class TypedStatusError<TData = unknown> extends Error {
// <ApiClient> // <ApiClient>
export class ApiClient { export class ApiClient {
baseUrl: string = ''; baseUrl: string = "";
successStatusCodes = successStatusCodes; successStatusCodes = successStatusCodes;
errorStatusCodes = errorStatusCodes; errorStatusCodes = errorStatusCodes;
@@ -211,7 +218,9 @@ export class ApiClient {
* Supports both OpenAPI format {param} and Express format :param * Supports both OpenAPI format {param} and Express format :param
*/ */
defaultDecodePathParams = (url: string, params: Record<string, string>): string => { defaultDecodePathParams = (url: string, params: Record<string, string>): string => {
return url.replace(/{(\w+)}/g, (_, key: string) => params[key] || `{${key}}`).replace(/:([a-zA-Z0-9_]+)/g, (_, key: string) => params[key] || `:${key}`); return url
.replace(/{(\w+)}/g, (_, key: string) => params[key] || `{${key}}`)
.replace(/:([a-zA-Z0-9_]+)/g, (_, key: string) => params[key] || `:${key}`);
}; };
/** Uses URLSearchParams, skips null/undefined values */ /** Uses URLSearchParams, skips null/undefined values */
@@ -234,16 +243,20 @@ export class ApiClient {
}; };
defaultParseResponseData = async (response: Response): Promise<unknown> => { defaultParseResponseData = async (response: Response): Promise<unknown> => {
const contentType = response.headers.get('content-type') ?? ''; const contentType = response.headers.get("content-type") ?? "";
if (contentType.startsWith('text/')) { if (contentType.startsWith("text/")) {
return await response.text(); return await response.text();
} }
if (contentType === 'application/octet-stream') { if (contentType === "application/octet-stream") {
return await response.arrayBuffer(); return await response.arrayBuffer();
} }
if (contentType.includes('application/json') || (contentType.includes('application/') && contentType.includes('json')) || contentType === '*/*') { if (
contentType.includes("application/json") ||
(contentType.includes("application/") && contentType.includes("json")) ||
contentType === "*/*"
) {
try { try {
return await response.json(); return await response.json();
} catch { } catch {
@@ -264,7 +277,7 @@ export class ApiClient {
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
> >
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>['data']>; ): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
get<Path extends keyof GetEndpoints, TEndpoint extends GetEndpoints[Path]>( get<Path extends keyof GetEndpoints, TEndpoint extends GetEndpoints[Path]>(
path: Path, path: Path,
@@ -277,8 +290,11 @@ export class ApiClient {
> >
): Promise<SafeApiResponse<TEndpoint>>; ): Promise<SafeApiResponse<TEndpoint>>;
get<Path extends keyof GetEndpoints, _TEndpoint extends GetEndpoints[Path]>(path: Path, ...params: MaybeOptionalArg<any>): Promise<any> { get<Path extends keyof GetEndpoints, _TEndpoint extends GetEndpoints[Path]>(
return this.request('get', path, ...params); path: Path,
...params: MaybeOptionalArg<any>
): Promise<any> {
return this.request("get", path, ...params);
} }
// </ApiClient.get> // </ApiClient.get>
@@ -286,7 +302,11 @@ export class ApiClient {
/** /**
* Generic request method with full type-safety for any endpoint * Generic request method with full type-safety for any endpoint
*/ */
request<TMethod extends keyof EndpointByMethod, TPath extends keyof EndpointByMethod[TMethod], TEndpoint extends EndpointByMethod[TMethod][TPath]>( request<
TMethod extends keyof EndpointByMethod,
TPath extends keyof EndpointByMethod[TMethod],
TEndpoint extends EndpointByMethod[TMethod][TPath],
>(
method: TMethod, method: TMethod,
path: TPath, path: TPath,
...params: MaybeOptionalArg< ...params: MaybeOptionalArg<
@@ -296,9 +316,13 @@ export class ApiClient {
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
> >
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>['data']>; ): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
request<TMethod extends keyof EndpointByMethod, TPath extends keyof EndpointByMethod[TMethod], TEndpoint extends EndpointByMethod[TMethod][TPath]>( request<
TMethod extends keyof EndpointByMethod,
TPath extends keyof EndpointByMethod[TMethod],
TEndpoint extends EndpointByMethod[TMethod][TPath],
>(
method: TMethod, method: TMethod,
path: TPath, path: TPath,
...params: MaybeOptionalArg< ...params: MaybeOptionalArg<
@@ -310,14 +334,19 @@ export class ApiClient {
> >
): Promise<SafeApiResponse<TEndpoint>>; ): Promise<SafeApiResponse<TEndpoint>>;
request<TMethod extends keyof EndpointByMethod, TPath extends keyof EndpointByMethod[TMethod], TEndpoint extends EndpointByMethod[TMethod][TPath]>( request<
method: TMethod, TMethod extends keyof EndpointByMethod,
path: TPath, TPath extends keyof EndpointByMethod[TMethod],
...params: MaybeOptionalArg<any> TEndpoint extends EndpointByMethod[TMethod][TPath],
): Promise<any> { >(method: TMethod, path: TPath, ...params: MaybeOptionalArg<any>): Promise<any> {
const requestParams = params[0]; const requestParams = params[0];
const withResponse = requestParams?.withResponse; const withResponse = requestParams?.withResponse;
const { withResponse: _, throwOnStatusError = withResponse ? false : true, overrides, ...fetchParams } = requestParams || {}; const {
withResponse: _,
throwOnStatusError = withResponse ? false : true,
overrides,
...fetchParams
} = requestParams || {};
const parametersToSend: EndpointParameters = {}; const parametersToSend: EndpointParameters = {};
if (requestParams?.body !== undefined) (parametersToSend as any).body = requestParams.body; if (requestParams?.body !== undefined) (parametersToSend as any).body = requestParams.body;
@@ -327,9 +356,8 @@ export class ApiClient {
const resolvedPath = (this.fetcher.decodePathParams ?? this.defaultDecodePathParams)( const resolvedPath = (this.fetcher.decodePathParams ?? this.defaultDecodePathParams)(
this.baseUrl + (path as string), this.baseUrl + (path as string),
(parametersToSend.path ?? {}) as Record<string, string> (parametersToSend.path ?? {}) as Record<string, string>,
); );
console.log('Resolved Path:', resolvedPath);
const url = new URL(resolvedPath); const url = new URL(resolvedPath);
const urlSearchParams = (this.fetcher.encodeSearchParams ?? this.defaultEncodeSearchParams)(parametersToSend.query); const urlSearchParams = (this.fetcher.encodeSearchParams ?? this.defaultEncodeSearchParams)(parametersToSend.query);
@@ -357,13 +385,13 @@ export class ApiClient {
return withResponse ? typedResponse : data; return withResponse ? typedResponse : data;
}); });
return promise as Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>['data']; return promise as Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"];
} }
// </ApiClient.request> // </ApiClient.request>
} }
export function createApiClient(fetcher: Fetcher, baseUrl?: string) { export function createApiClient(fetcher: Fetcher, baseUrl?: string) {
return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ''); return new ApiClient(fetcher).setBaseUrl(baseUrl ?? "");
} }
/** /**

View File

@@ -1,5 +1,5 @@
import type { AxiosInstance, AxiosResponse } from 'axios'; import type { AxiosInstance, AxiosResponse } from 'axios';
import { type Fetcher, type Method, createApiClient } from '../generated/api-client'; import { type Fetcher, type Method, createApiClient } from '../generated/api-client/api-client';
import { TanstackQueryApiClient } from '../generated/tanstack-client'; import { TanstackQueryApiClient } from '../generated/tanstack-client';
const API_BASE_URL: string | undefined = import.meta.env.VITE_API_BASE_URL; const API_BASE_URL: string | undefined = import.meta.env.VITE_API_BASE_URL;

View File

@@ -8,7 +8,7 @@
"start": "react-router-serve ./build/server/index.js", "start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc", "typecheck": "react-router typegen && tsc",
"test": "echo \"No tests specified\" && exit 0", "test": "echo \"No tests specified\" && exit 0",
"generate:openapi": "typed-openapi ../api/swagger.json --tanstack tanstack-client.ts -o ./app/generated/api-client.ts" "generate:openapi": "typed-openapi ../api/swagger.json --tanstack tanstack-client.ts -o ./app/generated/api-client/api-client.ts"
}, },
"dependencies": { "dependencies": {
"@radix-ui/themes": "^3.2.1", "@radix-ui/themes": "^3.2.1",