Added openapi based api client
This commit is contained in:
394
apps/frontend/app/generated/api-client.ts
Normal file
394
apps/frontend/app/generated/api-client.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
export namespace Schemas {
|
||||
// <Schemas>
|
||||
export type HealthInfo = {
|
||||
errors?: (Array<string> | null) | undefined;
|
||||
status: string;
|
||||
up_since: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
// </Schemas>
|
||||
}
|
||||
|
||||
export namespace Endpoints {
|
||||
// <Endpoints>
|
||||
|
||||
export type get_Get_health_info = {
|
||||
method: 'GET';
|
||||
path: '/api/health/info';
|
||||
requestFormat: 'json';
|
||||
parameters: never;
|
||||
responses: { 200: Schemas.HealthInfo; 404: unknown };
|
||||
};
|
||||
|
||||
// </Endpoints>
|
||||
}
|
||||
|
||||
// <EndpointByMethod>
|
||||
export type EndpointByMethod = {
|
||||
get: {
|
||||
'/api/health/info': Endpoints.get_Get_health_info;
|
||||
};
|
||||
};
|
||||
|
||||
// </EndpointByMethod>
|
||||
|
||||
// <EndpointByMethod.Shorthands>
|
||||
export type GetEndpoints = EndpointByMethod['get'];
|
||||
// </EndpointByMethod.Shorthands>
|
||||
|
||||
// <ApiClientTypes>
|
||||
export type EndpointParameters = {
|
||||
body?: unknown;
|
||||
query?: Record<string, unknown>;
|
||||
header?: Record<string, unknown>;
|
||||
path?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MutationMethod = 'post' | 'put' | 'patch' | 'delete';
|
||||
export type Method = 'get' | 'head' | 'options' | MutationMethod;
|
||||
|
||||
type RequestFormat = 'json' | 'form-data' | 'form-url' | 'binary' | 'text';
|
||||
|
||||
export type DefaultEndpoint = {
|
||||
parameters?: EndpointParameters | undefined;
|
||||
responses?: Record<string, unknown>;
|
||||
responseHeaders?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
|
||||
operationId: string;
|
||||
method: Method;
|
||||
path: string;
|
||||
requestFormat: RequestFormat;
|
||||
parameters?: TConfig['parameters'];
|
||||
meta: {
|
||||
alias: string;
|
||||
hasParameters: boolean;
|
||||
areParametersRequired: boolean;
|
||||
};
|
||||
responses?: TConfig['responses'];
|
||||
responseHeaders?: TConfig['responseHeaders'];
|
||||
};
|
||||
|
||||
export interface Fetcher {
|
||||
decodePathParams?: (path: string, pathParams: Record<string, string>) => string;
|
||||
encodeSearchParams?: (searchParams: Record<string, unknown> | undefined) => URLSearchParams;
|
||||
//
|
||||
fetch: (input: {
|
||||
method: Method;
|
||||
url: URL;
|
||||
urlSearchParams?: URLSearchParams | undefined;
|
||||
parameters?: EndpointParameters | undefined;
|
||||
path: string;
|
||||
overrides?: RequestInit;
|
||||
throwOnStatusError?: boolean;
|
||||
}) => Promise<Response>;
|
||||
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 type SuccessStatusCode = (typeof successStatusCodes)[number];
|
||||
|
||||
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,
|
||||
504, 505, 506, 507, 508, 510, 511,
|
||||
] as const;
|
||||
export type ErrorStatusCode = (typeof errorStatusCodes)[number];
|
||||
|
||||
// Taken from https://github.com/unjs/fetchdts/blob/ec4eaeab5d287116171fc1efd61f4a1ad34e4609/src/fetch.ts#L3
|
||||
export interface TypedHeaders<TypedHeaderValues extends Record<string, string> | unknown>
|
||||
extends Omit<Headers, 'append' | 'delete' | 'get' | 'getSetCookie' | 'has' | 'set' | 'forEach'> {
|
||||
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) */
|
||||
append: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(
|
||||
name: Name,
|
||||
value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string
|
||||
) => void;
|
||||
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) */
|
||||
delete: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(name: Name) => void;
|
||||
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) */
|
||||
get: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(
|
||||
name: Name
|
||||
) => (Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string) | null;
|
||||
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) */
|
||||
getSetCookie: () => string[];
|
||||
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) */
|
||||
has: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(name: Name) => boolean;
|
||||
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) */
|
||||
set: <Name extends Extract<keyof TypedHeaderValues, string> | (string & {})>(
|
||||
name: Name,
|
||||
value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string
|
||||
) => void;
|
||||
forEach: (
|
||||
callbackfn: (
|
||||
value: TypedHeaderValues[keyof TypedHeaderValues] | (string & {}),
|
||||
key: Extract<keyof TypedHeaderValues, string> | (string & {}),
|
||||
parent: TypedHeaders<TypedHeaderValues>
|
||||
) => void,
|
||||
thisArg?: any
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
|
||||
export interface TypedSuccessResponse<TSuccess, TStatusCode, THeaders> extends Omit<Response, 'ok' | 'status' | 'json' | 'headers'> {
|
||||
ok: true;
|
||||
status: TStatusCode;
|
||||
headers: never extends THeaders ? Headers : TypedHeaders<THeaders>;
|
||||
data: TSuccess;
|
||||
/** [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) */
|
||||
json: () => Promise<TSuccess>;
|
||||
}
|
||||
|
||||
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
|
||||
export interface TypedErrorResponse<TData, TStatusCode, THeaders> extends Omit<Response, 'ok' | 'status' | 'json' | 'headers'> {
|
||||
ok: false;
|
||||
status: TStatusCode;
|
||||
headers: never extends THeaders ? Headers : TypedHeaders<THeaders>;
|
||||
data: TData;
|
||||
/** [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) */
|
||||
json: () => Promise<TData>;
|
||||
}
|
||||
|
||||
export type TypedApiResponse<TAllResponses extends Record<string | number, unknown> = {}, THeaders = {}> = {
|
||||
[K in keyof TAllResponses]: K extends string
|
||||
? K extends `${infer TStatusCode extends number}`
|
||||
? TStatusCode extends SuccessStatusCode
|
||||
? TypedSuccessResponse<TAllResponses[K], TStatusCode, K extends keyof THeaders ? THeaders[K] : never>
|
||||
: TypedErrorResponse<TAllResponses[K], TStatusCode, K extends keyof THeaders ? THeaders[K] : never>
|
||||
: never
|
||||
: K extends number
|
||||
? K extends SuccessStatusCode
|
||||
? TypedSuccessResponse<TAllResponses[K], K, K extends keyof THeaders ? THeaders[K] : never>
|
||||
: TypedErrorResponse<TAllResponses[K], K, K extends keyof THeaders ? THeaders[K] : never>
|
||||
: never;
|
||||
}[keyof TAllResponses];
|
||||
|
||||
export type SafeApiResponse<TEndpoint> = TEndpoint extends { responses: infer TResponses }
|
||||
? TResponses extends Record<string, unknown>
|
||||
? TypedApiResponse<TResponses, TEndpoint extends { responseHeaders: infer THeaders } ? THeaders : never>
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type InferResponseByStatus<TEndpoint, TStatusCode> = Extract<SafeApiResponse<TEndpoint>, { status: TStatusCode }>;
|
||||
|
||||
type RequiredKeys<T> = {
|
||||
[P in keyof T]-?: undefined extends T[P] ? never : P;
|
||||
}[keyof T];
|
||||
|
||||
type MaybeOptionalArg<T> = RequiredKeys<T> extends never ? [config?: T] : [config: T];
|
||||
type NotNever<T> = [T] extends [never] ? false : true;
|
||||
|
||||
// </ApiClientTypes>
|
||||
|
||||
// <TypedStatusError>
|
||||
export class TypedStatusError<TData = unknown> extends Error {
|
||||
response: TypedErrorResponse<TData, ErrorStatusCode, unknown>;
|
||||
status: number;
|
||||
constructor(response: TypedErrorResponse<TData, ErrorStatusCode, unknown>) {
|
||||
super(`HTTP ${response.status}: ${response.statusText}`);
|
||||
this.name = 'TypedStatusError';
|
||||
this.response = response;
|
||||
this.status = response.status;
|
||||
}
|
||||
}
|
||||
// </TypedStatusError>
|
||||
|
||||
// <ApiClient>
|
||||
export class ApiClient {
|
||||
baseUrl: string = '';
|
||||
successStatusCodes = successStatusCodes;
|
||||
errorStatusCodes = errorStatusCodes;
|
||||
|
||||
constructor(public fetcher: Fetcher) {}
|
||||
|
||||
setBaseUrl(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace path parameters in URL
|
||||
* Supports both OpenAPI format {param} and Express format :param
|
||||
*/
|
||||
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}`);
|
||||
};
|
||||
|
||||
/** Uses URLSearchParams, skips null/undefined values */
|
||||
defaultEncodeSearchParams = (queryParams: Record<string, unknown> | undefined): URLSearchParams | undefined => {
|
||||
if (!queryParams) return;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value != null) {
|
||||
// Skip null/undefined values
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => val != null && searchParams.append(key, String(val)));
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams;
|
||||
};
|
||||
|
||||
defaultParseResponseData = async (response: Response): Promise<unknown> => {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.startsWith('text/')) {
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
if (contentType === 'application/octet-stream') {
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json') || (contentType.includes('application/') && contentType.includes('json')) || contentType === '*/*') {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// <ApiClient.get>
|
||||
get<Path extends keyof GetEndpoints, TEndpoint extends GetEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { 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']>;
|
||||
|
||||
get<Path extends keyof GetEndpoints, TEndpoint extends GetEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
>
|
||||
): Promise<SafeApiResponse<TEndpoint>>;
|
||||
|
||||
get<Path extends keyof GetEndpoints, _TEndpoint extends GetEndpoints[Path]>(path: Path, ...params: MaybeOptionalArg<any>): Promise<any> {
|
||||
return this.request('get', path, ...params);
|
||||
}
|
||||
// </ApiClient.get>
|
||||
|
||||
// <ApiClient.request>
|
||||
/**
|
||||
* 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]>(
|
||||
method: TMethod,
|
||||
path: TPath,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { 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']>;
|
||||
|
||||
request<TMethod extends keyof EndpointByMethod, TPath extends keyof EndpointByMethod[TMethod], TEndpoint extends EndpointByMethod[TMethod][TPath]>(
|
||||
method: TMethod,
|
||||
path: TPath,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
>
|
||||
): Promise<SafeApiResponse<TEndpoint>>;
|
||||
|
||||
request<TMethod extends keyof EndpointByMethod, TPath extends keyof EndpointByMethod[TMethod], TEndpoint extends EndpointByMethod[TMethod][TPath]>(
|
||||
method: TMethod,
|
||||
path: TPath,
|
||||
...params: MaybeOptionalArg<any>
|
||||
): Promise<any> {
|
||||
const requestParams = params[0];
|
||||
const withResponse = requestParams?.withResponse;
|
||||
const { withResponse: _, throwOnStatusError = withResponse ? false : true, overrides, ...fetchParams } = requestParams || {};
|
||||
|
||||
const parametersToSend: EndpointParameters = {};
|
||||
if (requestParams?.body !== undefined) (parametersToSend as any).body = requestParams.body;
|
||||
if (requestParams?.query !== undefined) (parametersToSend as any).query = requestParams.query;
|
||||
if (requestParams?.header !== undefined) (parametersToSend as any).header = requestParams.header;
|
||||
if (requestParams?.path !== undefined) (parametersToSend as any).path = requestParams.path;
|
||||
|
||||
const resolvedPath = (this.fetcher.decodePathParams ?? this.defaultDecodePathParams)(
|
||||
this.baseUrl + (path as string),
|
||||
(parametersToSend.path ?? {}) as Record<string, string>
|
||||
);
|
||||
console.log('Resolved Path:', resolvedPath);
|
||||
const url = new URL(resolvedPath);
|
||||
const urlSearchParams = (this.fetcher.encodeSearchParams ?? this.defaultEncodeSearchParams)(parametersToSend.query);
|
||||
|
||||
const promise = this.fetcher
|
||||
.fetch({
|
||||
method: method,
|
||||
path: path as string,
|
||||
url,
|
||||
urlSearchParams,
|
||||
parameters: Object.keys(fetchParams).length ? fetchParams : undefined,
|
||||
overrides,
|
||||
throwOnStatusError,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const data = await (this.fetcher.parseResponseData ?? this.defaultParseResponseData)(response);
|
||||
const typedResponse = Object.assign(response, {
|
||||
data: data,
|
||||
json: () => Promise.resolve(data),
|
||||
}) as SafeApiResponse<TEndpoint>;
|
||||
|
||||
if (throwOnStatusError && errorStatusCodes.includes(response.status as never)) {
|
||||
throw new TypedStatusError(typedResponse as never);
|
||||
}
|
||||
|
||||
return withResponse ? typedResponse : data;
|
||||
});
|
||||
|
||||
return promise as Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>['data'];
|
||||
}
|
||||
// </ApiClient.request>
|
||||
}
|
||||
|
||||
export function createApiClient(fetcher: Fetcher, baseUrl?: string) {
|
||||
return new ApiClient(fetcher).setBaseUrl(baseUrl ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
Example usage:
|
||||
const api = createApiClient((method, url, params) =>
|
||||
fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()),
|
||||
);
|
||||
api.get("/users").then((users) => console.log(users));
|
||||
api.post("/users", { body: { name: "John" } }).then((user) => console.log(user));
|
||||
api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user));
|
||||
|
||||
// With error handling
|
||||
const result = await api.get("/users/{id}", { path: { id: "123" }, withResponse: true });
|
||||
if (result.ok) {
|
||||
// Access data directly
|
||||
const user = result.data;
|
||||
console.log(user);
|
||||
|
||||
// Or use the json() method for compatibility
|
||||
const userFromJson = await result.json();
|
||||
console.log(userFromJson);
|
||||
} else {
|
||||
const error = result.data;
|
||||
console.error(`Error ${result.status}:`, error);
|
||||
}
|
||||
*/
|
||||
|
||||
// </ApiClient>
|
||||
185
apps/frontend/app/generated/tanstack-client.ts
Normal file
185
apps/frontend/app/generated/tanstack-client.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import type {
|
||||
EndpointByMethod,
|
||||
ApiClient,
|
||||
SuccessStatusCode,
|
||||
ErrorStatusCode,
|
||||
InferResponseByStatus,
|
||||
TypedSuccessResponse,
|
||||
} from "./api-client.ts";
|
||||
import { errorStatusCodes, TypedStatusError } from "./api-client.ts";
|
||||
|
||||
type EndpointQueryKey<TOptions extends EndpointParameters> = [
|
||||
TOptions & {
|
||||
_id: string;
|
||||
_infinite?: boolean;
|
||||
},
|
||||
];
|
||||
|
||||
const createQueryKey = <TOptions extends EndpointParameters>(
|
||||
id: string,
|
||||
options?: TOptions,
|
||||
infinite?: boolean,
|
||||
): [EndpointQueryKey<TOptions>[0]] => {
|
||||
const params: EndpointQueryKey<TOptions>[0] = { _id: id } as EndpointQueryKey<TOptions>[0];
|
||||
if (infinite) {
|
||||
params._infinite = infinite;
|
||||
}
|
||||
if (options?.body) {
|
||||
params.body = options.body;
|
||||
}
|
||||
if (options?.header) {
|
||||
params.header = options.header;
|
||||
}
|
||||
if (options?.path) {
|
||||
params.path = options.path;
|
||||
}
|
||||
if (options?.query) {
|
||||
params.query = options.query;
|
||||
}
|
||||
return [params];
|
||||
};
|
||||
|
||||
// <EndpointByMethod.Shorthands>
|
||||
export type GetEndpoints = EndpointByMethod["get"];
|
||||
// </EndpointByMethod.Shorthands>
|
||||
|
||||
// <ApiClientTypes>
|
||||
export type EndpointParameters = {
|
||||
body?: unknown;
|
||||
query?: Record<string, unknown>;
|
||||
header?: Record<string, unknown>;
|
||||
path?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type RequiredKeys<T> = {
|
||||
[P in keyof T]-?: undefined extends T[P] ? never : P;
|
||||
}[keyof T];
|
||||
|
||||
type MaybeOptionalArg<T> = RequiredKeys<T> extends never ? [config?: T] : [config: T];
|
||||
|
||||
type InferResponseData<TEndpoint, TStatusCode> =
|
||||
TypedSuccessResponse<any, any, any> extends InferResponseByStatus<TEndpoint, TStatusCode>
|
||||
? Extract<InferResponseByStatus<TEndpoint, TStatusCode>, { data: {} }>["data"]
|
||||
: Extract<InferResponseByStatus<TEndpoint, TStatusCode>["data"], {}>;
|
||||
|
||||
// </ApiClientTypes>
|
||||
|
||||
// <ApiClient>
|
||||
export class TanstackQueryApiClient {
|
||||
constructor(public client: ApiClient) {}
|
||||
|
||||
// <ApiClient.get>
|
||||
get<Path extends keyof GetEndpoints, TEndpoint extends GetEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<TEndpoint["parameters"]>
|
||||
) {
|
||||
const queryKey = createQueryKey(path as string, params[0]);
|
||||
const query = {
|
||||
/** type-only property if you need easy access to the endpoint params */
|
||||
"~endpoint": {} as TEndpoint,
|
||||
queryKey,
|
||||
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
|
||||
queryOptions: queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const requestParams = {
|
||||
...(params[0] || {}),
|
||||
...(queryKey[0] || {}),
|
||||
overrides: { signal },
|
||||
withResponse: false as const,
|
||||
};
|
||||
const res = await this.client.get(path, requestParams as never);
|
||||
return res as InferResponseData<TEndpoint, SuccessStatusCode>;
|
||||
},
|
||||
queryKey: queryKey,
|
||||
}),
|
||||
};
|
||||
|
||||
return query;
|
||||
}
|
||||
// </ApiClient.get>
|
||||
|
||||
// <ApiClient.request>
|
||||
/**
|
||||
* Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially
|
||||
* but instead will require them to be passed when calling the mutation.mutate() method
|
||||
*/
|
||||
mutation<
|
||||
TMethod extends keyof EndpointByMethod,
|
||||
TPath extends keyof EndpointByMethod[TMethod],
|
||||
TEndpoint extends EndpointByMethod[TMethod][TPath],
|
||||
TWithResponse extends boolean = false,
|
||||
TSelection = TWithResponse extends true
|
||||
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
|
||||
: InferResponseData<TEndpoint, SuccessStatusCode>,
|
||||
TError = TEndpoint extends { responses: infer TResponses }
|
||||
? TResponses extends Record<string | number, unknown>
|
||||
? TypedStatusError<InferResponseData<TEndpoint, ErrorStatusCode>>
|
||||
: Error
|
||||
: Error,
|
||||
>(
|
||||
method: TMethod,
|
||||
path: TPath,
|
||||
options?: {
|
||||
withResponse?: TWithResponse;
|
||||
selectFn?: (
|
||||
res: TWithResponse extends true
|
||||
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
|
||||
: InferResponseData<TEndpoint, SuccessStatusCode>,
|
||||
) => TSelection;
|
||||
throwOnStatusError?: boolean;
|
||||
throwOnError?: boolean | ((error: TError) => boolean);
|
||||
},
|
||||
) {
|
||||
const mutationKey = [{ method, path }] as const;
|
||||
const mutationFn = async (
|
||||
params: (TEndpoint extends { parameters: infer Parameters } ? Parameters : {}) & {
|
||||
throwOnStatusError?: boolean;
|
||||
overrides?: RequestInit;
|
||||
},
|
||||
): Promise<TSelection> => {
|
||||
const withResponse = options?.withResponse ?? false;
|
||||
const throwOnStatusError =
|
||||
params.throwOnStatusError ?? options?.throwOnStatusError ?? (withResponse ? false : true);
|
||||
const selectFn = options?.selectFn;
|
||||
const response = await (this.client as any)[method](path, {
|
||||
...(params as any),
|
||||
withResponse: true,
|
||||
throwOnStatusError: false,
|
||||
});
|
||||
|
||||
if (throwOnStatusError && errorStatusCodes.includes(response.status as never)) {
|
||||
throw new TypedStatusError(response as never);
|
||||
}
|
||||
|
||||
// Return just the data if withResponse is false, otherwise return the full response
|
||||
const finalResponse = withResponse ? response : response.data;
|
||||
const res = selectFn ? selectFn(finalResponse as any) : finalResponse;
|
||||
return res as never;
|
||||
};
|
||||
return {
|
||||
/** type-only property if you need easy access to the endpoint params */
|
||||
"~endpoint": {} as TEndpoint,
|
||||
mutationKey: mutationKey,
|
||||
mutationFn: {} as "You need to pass .mutationOptions to the useMutation hook",
|
||||
mutationOptions: {
|
||||
throwOnError: options?.throwOnError as boolean | ((error: TError) => boolean),
|
||||
mutationKey: mutationKey,
|
||||
mutationFn: mutationFn,
|
||||
} as Omit<
|
||||
import("@tanstack/react-query").UseMutationOptions<
|
||||
TSelection,
|
||||
TError,
|
||||
(TEndpoint extends { parameters: infer Parameters } ? Parameters : {}) & {
|
||||
withResponse?: boolean;
|
||||
throwOnStatusError?: boolean;
|
||||
}
|
||||
>,
|
||||
"mutationFn"
|
||||
> & {
|
||||
mutationFn: typeof mutationFn;
|
||||
},
|
||||
};
|
||||
}
|
||||
// </ApiClient.request>
|
||||
}
|
||||
94
apps/frontend/app/lib/api.ts
Normal file
94
apps/frontend/app/lib/api.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { type Fetcher, type Method, createApiClient } from '../generated/api-client';
|
||||
import { TanstackQueryApiClient } from '../generated/tanstack-client';
|
||||
|
||||
const API_BASE_URL: string | undefined = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const get_fetch: (axios: AxiosInstance) => Fetcher['fetch'] =
|
||||
(axios) =>
|
||||
async ({ method, url: incomingUrl, parameters: params }) => {
|
||||
// Use a plain object for Axios headers
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Replace path parameters (supports both {param} and :param formats)
|
||||
const actualUrl = replacePathParams(incomingUrl.toString(), (params?.path ?? {}) as Record<string, string>);
|
||||
const url = new URL(actualUrl);
|
||||
|
||||
// Handle query parameters
|
||||
if (params?.query) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params.query).forEach(([key, value]) => {
|
||||
if (value != null) {
|
||||
// Skip null/undefined values
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => val != null && searchParams.append(key, String(val)));
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
url.search = searchParams.toString();
|
||||
}
|
||||
|
||||
// Handle request body for mutation methods (use Axios `data`)
|
||||
const data = (['post', 'put', 'patch', 'delete'] satisfies Method[] as string[]).includes(method.toLowerCase()) ? params?.body : undefined;
|
||||
|
||||
if (data != null) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
if (params?.header) {
|
||||
Object.entries(params.header).forEach(([key, value]) => {
|
||||
if (value != null) {
|
||||
headers[key] = String(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios(url.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
...(data !== undefined && { data }),
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
return axiosResponseToFetchResponse(response);
|
||||
};
|
||||
|
||||
function axiosResponseToFetchResponse(response: AxiosResponse): Response {
|
||||
const headers = new Headers();
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => headers.append(key, val));
|
||||
} else {
|
||||
headers.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(response.data, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace path parameters in URL
|
||||
* Supports both OpenAPI format {param} and Express format :param
|
||||
*/
|
||||
function replacePathParams(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}`);
|
||||
}
|
||||
|
||||
export function createApi(axios: AxiosInstance) {
|
||||
return createApiClient(
|
||||
{
|
||||
fetch: get_fetch(axios),
|
||||
},
|
||||
API_BASE_URL ?? window.location.origin
|
||||
);
|
||||
}
|
||||
|
||||
export function createTanstackApi(axios: AxiosInstance) {
|
||||
return new TanstackQueryApiClient(createApi(axios));
|
||||
}
|
||||
59
apps/frontend/app/providers/ApiProvider.tsx
Normal file
59
apps/frontend/app/providers/ApiProvider.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createContext, use, useContext, type PropsWithChildren } from 'react';
|
||||
import { createTanstackApi, createApi } from '../lib/api';
|
||||
import axios from 'axios';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
type ApiProviderProps = PropsWithChildren<{}>;
|
||||
type ApiContextType = {
|
||||
apiClient: ReturnType<typeof createApi>;
|
||||
tanstackApiClient: ReturnType<typeof createTanstackApi>;
|
||||
};
|
||||
|
||||
const ApiContext = createContext<ApiContextType | null>(null);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* Example usage:
|
||||
* ```ts
|
||||
* const { tanstackApiClient } = useApi();
|
||||
* const { queryOptions } = tanstackApiClient.get('/api/health/info');
|
||||
* useQuery({
|
||||
* ...queryOptions,
|
||||
* queryFn: async (...args) => {
|
||||
* console.log('Fetching health info...');
|
||||
* const data = await queryOptions.queryFn!(...args);
|
||||
* console.log('Health Info:', data);
|
||||
* return data;
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
export const ApiProvider: React.FC<ApiProviderProps> = ({ children }) => {
|
||||
const axiosInstance = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
const apiClient = createApi(axiosInstance);
|
||||
const tanstackApiClient = createTanstackApi(axiosInstance);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiContext
|
||||
value={{
|
||||
apiClient,
|
||||
tanstackApiClient,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ApiContext>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useApi() {
|
||||
const context = use(ApiContext);
|
||||
if (!context) {
|
||||
throw new Error('useApi must be used within an ApiProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import './app.css';
|
||||
import { Theme } from '@radix-ui/themes';
|
||||
import { ApiProvider } from './providers/ApiProvider';
|
||||
|
||||
export const links: Route.LinksFunction = () => [];
|
||||
|
||||
@@ -27,7 +27,9 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
export default function App() {
|
||||
return (
|
||||
<Theme>
|
||||
<Outlet />
|
||||
<ApiProvider>
|
||||
<Outlet />
|
||||
</ApiProvider>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Text } from '@radix-ui/themes';
|
||||
import type { Route } from './+types/home';
|
||||
import { useContext } from 'react';
|
||||
import { useApi } from '../providers/ApiProvider';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'YANPM' }, { name: 'description', content: 'Welcome to Yet Another Nginx Proxy Manager!' }];
|
||||
|
||||
13
apps/frontend/app/vite-env.d.ts
vendored
Normal file
13
apps/frontend/app/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
interface ViteTypeOptions {
|
||||
// By adding this line, you can make the type of ImportMetaEnv strict
|
||||
// to disallow unknown keys.
|
||||
// strictImportMetaEnv: unknown
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string | undefined;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user