typed-openapi 1.5.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-OVT6OLBK.js → chunk-4EZJSCLI.js} +341 -50
- package/dist/chunk-GD55PFNE.js +208 -0
- package/dist/cli.js +11 -5
- package/dist/index.d.ts +8 -3
- package/dist/index.js +1 -1
- package/dist/node.export.d.ts +10 -1
- package/dist/node.export.js +2 -2
- package/dist/{types-DLE5RaXi.d.ts → types-DsI2d-HE.d.ts} +2 -0
- package/package.json +6 -1
- package/src/cli.ts +13 -4
- package/src/default-fetcher.generator.ts +101 -0
- package/src/generate-client-files.ts +75 -15
- package/src/generator.ts +197 -26
- package/src/map-openapi-endpoints.ts +55 -4
- package/src/openapi-schema-to-ts.ts +3 -2
- package/src/tanstack-query.generator.ts +69 -25
- package/dist/chunk-O7DZWQK4.js +0 -68
package/src/generator.ts
CHANGED
|
@@ -8,10 +8,26 @@ import { type } from "arktype";
|
|
|
8
8
|
import { wrapWithQuotesIfNeeded } from "./string-utils.ts";
|
|
9
9
|
import type { NameTransformOptions } from "./types.ts";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Default success status codes (2xx and 3xx ranges)
|
|
12
|
+
export const DEFAULT_SUCCESS_STATUS_CODES = [
|
|
13
|
+
200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308,
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
// Default error status codes (4xx and 5xx ranges)
|
|
17
|
+
export const DEFAULT_ERROR_STATUS_CODES = [
|
|
18
|
+
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424,
|
|
19
|
+
425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type ErrorStatusCode = (typeof DEFAULT_ERROR_STATUS_CODES)[number];
|
|
23
|
+
|
|
24
|
+
export type GeneratorOptions = ReturnType<typeof mapOpenApiEndpoints> & {
|
|
12
25
|
runtime?: "none" | keyof typeof runtimeValidationGenerator;
|
|
13
26
|
schemasOnly?: boolean;
|
|
14
27
|
nameTransform?: NameTransformOptions | undefined;
|
|
28
|
+
successStatusCodes?: readonly number[];
|
|
29
|
+
errorStatusCodes?: readonly number[];
|
|
30
|
+
includeClient?: boolean;
|
|
15
31
|
};
|
|
16
32
|
type GeneratorContext = Required<GeneratorOptions>;
|
|
17
33
|
|
|
@@ -62,11 +78,18 @@ const replacerByRuntime = {
|
|
|
62
78
|
};
|
|
63
79
|
|
|
64
80
|
export const generateFile = (options: GeneratorOptions) => {
|
|
65
|
-
const ctx = {
|
|
81
|
+
const ctx = {
|
|
82
|
+
...options,
|
|
83
|
+
runtime: options.runtime ?? "none",
|
|
84
|
+
successStatusCodes: options.successStatusCodes ?? DEFAULT_SUCCESS_STATUS_CODES,
|
|
85
|
+
errorStatusCodes: options.errorStatusCodes ?? DEFAULT_ERROR_STATUS_CODES,
|
|
86
|
+
includeClient: options.includeClient ?? true,
|
|
87
|
+
} as GeneratorContext;
|
|
66
88
|
|
|
67
89
|
const schemaList = generateSchemaList(ctx);
|
|
68
90
|
const endpointSchemaList = options.schemasOnly ? "" : generateEndpointSchemaList(ctx);
|
|
69
|
-
const
|
|
91
|
+
const endpointByMethod = options.schemasOnly ? "" : generateEndpointByMethod(ctx);
|
|
92
|
+
const apiClient = options.schemasOnly || !ctx.includeClient ? "" : generateApiClient(ctx);
|
|
70
93
|
|
|
71
94
|
const transform =
|
|
72
95
|
ctx.runtime === "none"
|
|
@@ -98,6 +121,7 @@ export const generateFile = (options: GeneratorOptions) => {
|
|
|
98
121
|
|
|
99
122
|
const file = `
|
|
100
123
|
${transform(schemaList + endpointSchemaList)}
|
|
124
|
+
${endpointByMethod}
|
|
101
125
|
${apiClient}
|
|
102
126
|
`;
|
|
103
127
|
|
|
@@ -125,8 +149,19 @@ const generateSchemaList = ({ refs, runtime }: GeneratorContext) => {
|
|
|
125
149
|
);
|
|
126
150
|
};
|
|
127
151
|
|
|
128
|
-
const parameterObjectToString = (parameters: Box<AnyBoxDef> | Record<string, AnyBox
|
|
129
|
-
if (parameters instanceof Box)
|
|
152
|
+
const parameterObjectToString = (parameters: Box<AnyBoxDef> | Record<string, AnyBox>, ctx: GeneratorContext) => {
|
|
153
|
+
if (parameters instanceof Box) {
|
|
154
|
+
if (ctx.runtime === "none") {
|
|
155
|
+
return parameters.recompute((box) => {
|
|
156
|
+
if (Box.isReference(box) && !box.params.generics && box.value !== "null") {
|
|
157
|
+
box.value = `Schemas.${box.value}`;
|
|
158
|
+
}
|
|
159
|
+
return box;
|
|
160
|
+
}).value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return parameters.value;
|
|
164
|
+
}
|
|
130
165
|
|
|
131
166
|
let str = "{";
|
|
132
167
|
for (const [key, box] of Object.entries(parameters)) {
|
|
@@ -153,6 +188,24 @@ const responseHeadersObjectToString = (responseHeaders: Record<string, AnyBox>,
|
|
|
153
188
|
return str + "}";
|
|
154
189
|
};
|
|
155
190
|
|
|
191
|
+
const generateResponsesObject = (responses: Record<string, AnyBox>, ctx: GeneratorContext) => {
|
|
192
|
+
let str = "{";
|
|
193
|
+
for (const [statusCode, responseType] of Object.entries(responses)) {
|
|
194
|
+
const value =
|
|
195
|
+
ctx.runtime === "none"
|
|
196
|
+
? responseType.recompute((box) => {
|
|
197
|
+
if (Box.isReference(box) && !box.params.generics && box.value !== "null") {
|
|
198
|
+
box.value = `Schemas.${box.value}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return box;
|
|
202
|
+
}).value
|
|
203
|
+
: responseType.value;
|
|
204
|
+
str += `${wrapWithQuotesIfNeeded(statusCode)}: ${value},\n`;
|
|
205
|
+
}
|
|
206
|
+
return str + "}";
|
|
207
|
+
};
|
|
208
|
+
|
|
156
209
|
const generateEndpointSchemaList = (ctx: GeneratorContext) => {
|
|
157
210
|
let file = `
|
|
158
211
|
${ctx.runtime === "none" ? "export namespace Endpoints {" : ""}
|
|
@@ -168,9 +221,9 @@ const generateEndpointSchemaList = (ctx: GeneratorContext) => {
|
|
|
168
221
|
${
|
|
169
222
|
endpoint.meta.hasParameters
|
|
170
223
|
? `parameters: {
|
|
171
|
-
${parameters.query ? `query: ${parameterObjectToString(parameters.query)},` : ""}
|
|
172
|
-
${parameters.path ? `path: ${parameterObjectToString(parameters.path)},` : ""}
|
|
173
|
-
${parameters.header ? `header: ${parameterObjectToString(parameters.header)},` : ""}
|
|
224
|
+
${parameters.query ? `query: ${parameterObjectToString(parameters.query, ctx)},` : ""}
|
|
225
|
+
${parameters.path ? `path: ${parameterObjectToString(parameters.path, ctx)},` : ""}
|
|
226
|
+
${parameters.header ? `header: ${parameterObjectToString(parameters.header, ctx)},` : ""}
|
|
174
227
|
${
|
|
175
228
|
parameters.body
|
|
176
229
|
? `body: ${parameterObjectToString(
|
|
@@ -182,6 +235,7 @@ const generateEndpointSchemaList = (ctx: GeneratorContext) => {
|
|
|
182
235
|
return box;
|
|
183
236
|
})
|
|
184
237
|
: parameters.body,
|
|
238
|
+
ctx,
|
|
185
239
|
)},`
|
|
186
240
|
: ""
|
|
187
241
|
}
|
|
@@ -199,6 +253,7 @@ const generateEndpointSchemaList = (ctx: GeneratorContext) => {
|
|
|
199
253
|
}).value
|
|
200
254
|
: endpoint.response.value
|
|
201
255
|
},
|
|
256
|
+
${endpoint.responses ? `responses: ${generateResponsesObject(endpoint.responses, ctx)},` : ""}
|
|
202
257
|
${
|
|
203
258
|
endpoint.responseHeaders
|
|
204
259
|
? `responseHeaders: ${responseHeadersObjectToString(endpoint.responseHeaders, ctx)},`
|
|
@@ -227,9 +282,11 @@ const generateEndpointByMethod = (ctx: GeneratorContext) => {
|
|
|
227
282
|
${Object.entries(byMethods)
|
|
228
283
|
.map(([method, list]) => {
|
|
229
284
|
return `${method}: {
|
|
230
|
-
${list
|
|
231
|
-
(
|
|
232
|
-
|
|
285
|
+
${list
|
|
286
|
+
.map(
|
|
287
|
+
(endpoint) => `"${endpoint.path}": ${ctx.runtime === "none" ? "Endpoints." : ""}${endpoint.meta.alias}`,
|
|
288
|
+
)
|
|
289
|
+
.join(",\n")}
|
|
233
290
|
}`;
|
|
234
291
|
})
|
|
235
292
|
.join(",\n")}
|
|
@@ -251,9 +308,12 @@ const generateEndpointByMethod = (ctx: GeneratorContext) => {
|
|
|
251
308
|
};
|
|
252
309
|
|
|
253
310
|
const generateApiClient = (ctx: GeneratorContext) => {
|
|
311
|
+
if (!ctx.includeClient) {
|
|
312
|
+
return "";
|
|
313
|
+
}
|
|
314
|
+
|
|
254
315
|
const { endpointList } = ctx;
|
|
255
316
|
const byMethods = groupBy(endpointList, "method");
|
|
256
|
-
const endpointSchemaList = generateEndpointByMethod(ctx);
|
|
257
317
|
|
|
258
318
|
const apiClientTypes = `
|
|
259
319
|
// <ApiClientTypes>
|
|
@@ -272,6 +332,7 @@ type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text";
|
|
|
272
332
|
export type DefaultEndpoint = {
|
|
273
333
|
parameters?: EndpointParameters | undefined;
|
|
274
334
|
response: unknown;
|
|
335
|
+
responses?: Record<string, unknown>;
|
|
275
336
|
responseHeaders?: Record<string, unknown>;
|
|
276
337
|
};
|
|
277
338
|
|
|
@@ -287,11 +348,64 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
|
|
|
287
348
|
areParametersRequired: boolean;
|
|
288
349
|
};
|
|
289
350
|
response: TConfig["response"];
|
|
351
|
+
responses?: TConfig["responses"];
|
|
290
352
|
responseHeaders?: TConfig["responseHeaders"]
|
|
291
353
|
};
|
|
292
354
|
|
|
293
355
|
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;
|
|
294
356
|
|
|
357
|
+
export const successStatusCodes = [${ctx.successStatusCodes.join(",")}] as const;
|
|
358
|
+
export type SuccessStatusCode = typeof successStatusCodes[number];
|
|
359
|
+
|
|
360
|
+
export const errorStatusCodes = [${ctx.errorStatusCodes.join(",")}] as const;
|
|
361
|
+
export type ErrorStatusCode = typeof errorStatusCodes[number];
|
|
362
|
+
|
|
363
|
+
// Error handling types
|
|
364
|
+
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
|
|
365
|
+
interface SuccessResponse<TSuccess, TStatusCode> extends Omit<Response, "ok" | "status" | "json"> {
|
|
366
|
+
ok: true;
|
|
367
|
+
status: TStatusCode;
|
|
368
|
+
data: TSuccess;
|
|
369
|
+
/** [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) */
|
|
370
|
+
json: () => Promise<TSuccess>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
|
|
374
|
+
interface ErrorResponse<TData, TStatusCode> extends Omit<Response, "ok" | "status" | "json"> {
|
|
375
|
+
ok: false;
|
|
376
|
+
status: TStatusCode;
|
|
377
|
+
data: TData;
|
|
378
|
+
/** [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) */
|
|
379
|
+
json: () => Promise<TData>;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export type TypedApiResponse<TSuccess, TAllResponses extends Record<string | number, unknown> = {}> =
|
|
383
|
+
(keyof TAllResponses extends never
|
|
384
|
+
? SuccessResponse<TSuccess, number>
|
|
385
|
+
: {
|
|
386
|
+
[K in keyof TAllResponses]: K extends string
|
|
387
|
+
? K extends \`\${infer TStatusCode extends number}\`
|
|
388
|
+
? TStatusCode extends SuccessStatusCode
|
|
389
|
+
? SuccessResponse<TSuccess, TStatusCode>
|
|
390
|
+
: ErrorResponse<TAllResponses[K], TStatusCode>
|
|
391
|
+
: never
|
|
392
|
+
: K extends number
|
|
393
|
+
? K extends SuccessStatusCode
|
|
394
|
+
? SuccessResponse<TSuccess, K>
|
|
395
|
+
: ErrorResponse<TAllResponses[K], K>
|
|
396
|
+
: never;
|
|
397
|
+
}[keyof TAllResponses]);
|
|
398
|
+
|
|
399
|
+
export type SafeApiResponse<TEndpoint> = TEndpoint extends { response: infer TSuccess; responses: infer TResponses }
|
|
400
|
+
? TResponses extends Record<string, unknown>
|
|
401
|
+
? TypedApiResponse<TSuccess, TResponses>
|
|
402
|
+
: SuccessResponse<TSuccess, number>
|
|
403
|
+
: TEndpoint extends { response: infer TSuccess }
|
|
404
|
+
? SuccessResponse<TSuccess, number>
|
|
405
|
+
: never;
|
|
406
|
+
|
|
407
|
+
export type InferResponseByStatus<TEndpoint, TStatusCode> = Extract<SafeApiResponse<TEndpoint>, { status: TStatusCode }>
|
|
408
|
+
|
|
295
409
|
type RequiredKeys<T> = {
|
|
296
410
|
[P in keyof T]-?: undefined extends T[P] ? never : P;
|
|
297
411
|
}[keyof T];
|
|
@@ -302,9 +416,23 @@ type MaybeOptionalArg<T> = RequiredKeys<T> extends never ? [config?: T] : [confi
|
|
|
302
416
|
`;
|
|
303
417
|
|
|
304
418
|
const apiClient = `
|
|
419
|
+
// <TypedResponseError>
|
|
420
|
+
export class TypedResponseError extends Error {
|
|
421
|
+
response: ErrorResponse<unknown, ErrorStatusCode>;
|
|
422
|
+
status: number;
|
|
423
|
+
constructor(response: ErrorResponse<unknown, ErrorStatusCode>) {
|
|
424
|
+
super(\`HTTP \${response.status}: \${response.statusText}\`);
|
|
425
|
+
this.name = 'TypedResponseError';
|
|
426
|
+
this.response = response;
|
|
427
|
+
this.status = response.status;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// </TypedResponseError>
|
|
305
431
|
// <ApiClient>
|
|
306
432
|
export class ApiClient {
|
|
307
433
|
baseUrl: string = "";
|
|
434
|
+
successStatusCodes = successStatusCodes;
|
|
435
|
+
errorStatusCodes = errorStatusCodes;
|
|
308
436
|
|
|
309
437
|
constructor(public fetcher: Fetcher) {}
|
|
310
438
|
|
|
@@ -333,16 +461,47 @@ export class ApiClient {
|
|
|
333
461
|
...params: MaybeOptionalArg<${match(ctx.runtime)
|
|
334
462
|
.with("zod", "yup", () => infer(`TEndpoint["parameters"]`))
|
|
335
463
|
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`)
|
|
336
|
-
.otherwise(() => `TEndpoint["parameters"]`)}>
|
|
464
|
+
.otherwise(() => `TEndpoint["parameters"]`)} & { withResponse?: false; throwOnStatusError?: boolean }>
|
|
337
465
|
): Promise<${match(ctx.runtime)
|
|
338
466
|
.with("zod", "yup", () => infer(`TEndpoint["response"]`))
|
|
339
467
|
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["response"]`)
|
|
340
|
-
.otherwise(() => `TEndpoint["response"]`)}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
468
|
+
.otherwise(() => `TEndpoint["response"]`)}>;
|
|
469
|
+
|
|
470
|
+
${method}<Path extends keyof ${capitalizedMethod}Endpoints, TEndpoint extends ${capitalizedMethod}Endpoints[Path]>(
|
|
471
|
+
path: Path,
|
|
472
|
+
...params: MaybeOptionalArg<${match(ctx.runtime)
|
|
473
|
+
.with("zod", "yup", () => infer(`TEndpoint["parameters"]`))
|
|
474
|
+
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`)
|
|
475
|
+
.otherwise(() => `TEndpoint["parameters"]`)} & { withResponse: true; throwOnStatusError?: boolean }>
|
|
476
|
+
): Promise<SafeApiResponse<TEndpoint>>;
|
|
477
|
+
|
|
478
|
+
${method}<Path extends keyof ${capitalizedMethod}Endpoints, TEndpoint extends ${capitalizedMethod}Endpoints[Path]>(
|
|
479
|
+
path: Path,
|
|
480
|
+
...params: MaybeOptionalArg<any>
|
|
481
|
+
): Promise<any> {
|
|
482
|
+
const requestParams = params[0];
|
|
483
|
+
const withResponse = requestParams?.withResponse;
|
|
484
|
+
const { withResponse: _, throwOnStatusError = withResponse ? false : true, ...fetchParams } = requestParams || {};
|
|
485
|
+
|
|
486
|
+
const promise = this.fetcher("${method}", this.baseUrl + path, Object.keys(fetchParams).length ? requestParams : undefined)
|
|
487
|
+
.then(async (response) => {
|
|
488
|
+
const data = await this.parseResponse(response);
|
|
489
|
+
const typedResponse = Object.assign(response, {
|
|
490
|
+
data: data,
|
|
491
|
+
json: () => Promise.resolve(data)
|
|
492
|
+
}) as SafeApiResponse<TEndpoint>;
|
|
493
|
+
|
|
494
|
+
if (throwOnStatusError && errorStatusCodes.includes(response.status as never)) {
|
|
495
|
+
throw new TypedResponseError(typedResponse as never);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return withResponse ? typedResponse : data;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
return promise ${match(ctx.runtime)
|
|
502
|
+
.with("zod", "yup", () => `as Promise<${infer(`TEndpoint["response"]`)}>`)
|
|
503
|
+
.with("arktype", "io-ts", "typebox", "valibot", () => `as Promise<${infer(`TEndpoint`) + `["response"]`}>`)
|
|
504
|
+
.otherwise(() => `as Promise<TEndpoint["response"]>`)}
|
|
346
505
|
}
|
|
347
506
|
// </ApiClient.${method}>
|
|
348
507
|
`
|
|
@@ -373,11 +532,8 @@ export class ApiClient {
|
|
|
373
532
|
() => inferByRuntime[ctx.runtime](`TEndpoint`) + `["parameters"]`,
|
|
374
533
|
)
|
|
375
534
|
.otherwise(() => `TEndpoint extends { parameters: infer Params } ? Params : never`)}>)
|
|
376
|
-
: Promise<
|
|
377
|
-
|
|
378
|
-
json: () => Promise<TEndpoint extends { response: infer Res } ? Res : never>;
|
|
379
|
-
}> {
|
|
380
|
-
return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters);
|
|
535
|
+
: Promise<SafeApiResponse<TEndpoint>> {
|
|
536
|
+
return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters) as Promise<SafeApiResponse<TEndpoint>>;
|
|
381
537
|
}
|
|
382
538
|
// </ApiClient.request>
|
|
383
539
|
}
|
|
@@ -395,10 +551,25 @@ export function createApiClient(fetcher: Fetcher, baseUrl?: string) {
|
|
|
395
551
|
api.get("/users").then((users) => console.log(users));
|
|
396
552
|
api.post("/users", { body: { name: "John" } }).then((user) => console.log(user));
|
|
397
553
|
api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user));
|
|
554
|
+
|
|
555
|
+
// With error handling
|
|
556
|
+
const result = await api.get("/users/{id}", { path: { id: "123" }, withResponse: true });
|
|
557
|
+
if (result.ok) {
|
|
558
|
+
// Access data directly
|
|
559
|
+
const user = result.data;
|
|
560
|
+
console.log(user);
|
|
561
|
+
|
|
562
|
+
// Or use the json() method for compatibility
|
|
563
|
+
const userFromJson = await result.json();
|
|
564
|
+
console.log(userFromJson);
|
|
565
|
+
} else {
|
|
566
|
+
const error = result.data;
|
|
567
|
+
console.error(\`Error \${result.status}:\`, error);
|
|
568
|
+
}
|
|
398
569
|
*/
|
|
399
570
|
|
|
400
|
-
// </ApiClient
|
|
571
|
+
// </ApiClient>
|
|
401
572
|
`;
|
|
402
573
|
|
|
403
|
-
return
|
|
574
|
+
return apiClientTypes + apiClient;
|
|
404
575
|
};
|
|
@@ -105,13 +105,20 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject, options?: { nameTransfor
|
|
|
105
105
|
|
|
106
106
|
// Make parameters optional if all or some of them are not required
|
|
107
107
|
if (params) {
|
|
108
|
-
const t = createBoxFactory({}, ctx);
|
|
109
108
|
const filtered_params = ["query", "path", "header"] as Array<
|
|
110
109
|
keyof Pick<typeof params, "query" | "path" | "header">
|
|
111
110
|
>;
|
|
112
111
|
|
|
113
112
|
for (const k of filtered_params) {
|
|
114
113
|
if (params[k] && lists[k].length) {
|
|
114
|
+
const properties = Object.entries(params[k]!).reduce(
|
|
115
|
+
(acc, [key, value]) => {
|
|
116
|
+
if (value.schema) acc[key] = value.schema;
|
|
117
|
+
return acc;
|
|
118
|
+
},
|
|
119
|
+
{} as Record<string, NonNullable<AnyBox["schema"]>>,
|
|
120
|
+
);
|
|
121
|
+
const t = createBoxFactory({ type: "object", properties: properties }, ctx);
|
|
115
122
|
if (lists[k].every((param) => !param.required)) {
|
|
116
123
|
params[k] = t.reference("Partial", [t.object(params[k]!)]) as any;
|
|
117
124
|
} else {
|
|
@@ -130,14 +137,56 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject, options?: { nameTransfor
|
|
|
130
137
|
|
|
131
138
|
// Match the first 2xx-3xx response found, or fallback to default one otherwise
|
|
132
139
|
let responseObject: ResponseObject | undefined;
|
|
140
|
+
const allResponses: Record<string, AnyBox> = {};
|
|
141
|
+
|
|
133
142
|
Object.entries(operation.responses ?? {}).map(([status, responseOrRef]) => {
|
|
134
143
|
const statusCode = Number(status);
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
const responseObj = refs.unwrap<ResponseObject>(responseOrRef);
|
|
145
|
+
|
|
146
|
+
// Collect all responses for error handling
|
|
147
|
+
const content = responseObj?.content;
|
|
148
|
+
if (content) {
|
|
149
|
+
const matchingMediaType = Object.keys(content).find(isResponseMediaType);
|
|
150
|
+
if (matchingMediaType && content[matchingMediaType]) {
|
|
151
|
+
allResponses[status] = openApiSchemaToTs({
|
|
152
|
+
schema: content[matchingMediaType]?.schema ?? {},
|
|
153
|
+
ctx,
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
// If no JSON content, use unknown type
|
|
157
|
+
allResponses[status] = openApiSchemaToTs({ schema: {}, ctx });
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// If no content defined, use unknown type
|
|
161
|
+
allResponses[status] = openApiSchemaToTs({ schema: {}, ctx });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Keep the current logic for the main response (first 2xx-3xx)
|
|
165
|
+
if (statusCode >= 200 && statusCode < 300 && !responseObject) {
|
|
166
|
+
responseObject = responseObj;
|
|
137
167
|
}
|
|
138
168
|
});
|
|
169
|
+
|
|
139
170
|
if (!responseObject && operation.responses?.default) {
|
|
140
171
|
responseObject = refs.unwrap(operation.responses.default);
|
|
172
|
+
// Also add default to all responses if not already covered
|
|
173
|
+
if (!allResponses["default"]) {
|
|
174
|
+
const content = responseObject?.content;
|
|
175
|
+
if (content) {
|
|
176
|
+
const matchingMediaType = Object.keys(content).find(isResponseMediaType);
|
|
177
|
+
if (matchingMediaType && content[matchingMediaType]) {
|
|
178
|
+
allResponses["default"] = openApiSchemaToTs({
|
|
179
|
+
schema: content[matchingMediaType]?.schema ?? {},
|
|
180
|
+
ctx,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Set the responses collection
|
|
188
|
+
if (Object.keys(allResponses).length > 0) {
|
|
189
|
+
endpoint.responses = allResponses;
|
|
141
190
|
}
|
|
142
191
|
|
|
143
192
|
const content = responseObject?.content;
|
|
@@ -184,7 +233,7 @@ const isAllowedParamMediaTypes = (
|
|
|
184
233
|
allowedParamMediaTypes.includes(mediaType as any) ||
|
|
185
234
|
mediaType.includes("text/");
|
|
186
235
|
|
|
187
|
-
const isResponseMediaType = (mediaType: string) => mediaType === "application/json";
|
|
236
|
+
const isResponseMediaType = (mediaType: string) => mediaType === "application/json" || mediaType === "*/*";
|
|
188
237
|
const getAlias = ({ path, method, operation }: Endpoint) =>
|
|
189
238
|
sanitizeName(
|
|
190
239
|
(method + "_" + capitalize(operation.operationId ?? pathToVariableName(path))).replace(/-/g, "__"),
|
|
@@ -206,6 +255,7 @@ type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text";
|
|
|
206
255
|
type DefaultEndpoint = {
|
|
207
256
|
parameters?: EndpointParameters | undefined;
|
|
208
257
|
response: AnyBox;
|
|
258
|
+
responses?: Record<string, AnyBox>;
|
|
209
259
|
responseHeaders?: Record<string, AnyBox>;
|
|
210
260
|
};
|
|
211
261
|
|
|
@@ -221,5 +271,6 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
|
|
|
221
271
|
areParametersRequired: boolean;
|
|
222
272
|
};
|
|
223
273
|
response: TConfig["response"];
|
|
274
|
+
responses?: TConfig["responses"];
|
|
224
275
|
responseHeaders?: TConfig["responseHeaders"];
|
|
225
276
|
};
|
|
@@ -16,7 +16,6 @@ export const openApiSchemaToTs = ({ schema, meta: _inheritedMeta, ctx }: Openapi
|
|
|
16
16
|
const getTs = () => {
|
|
17
17
|
if (isReferenceObject(schema)) {
|
|
18
18
|
const refInfo = ctx.refs.getInfosByRef(schema.$ref);
|
|
19
|
-
|
|
20
19
|
return t.reference(refInfo.normalized);
|
|
21
20
|
}
|
|
22
21
|
|
|
@@ -153,7 +152,9 @@ export const openApiSchemaToTs = ({ schema, meta: _inheritedMeta, ctx }: Openapi
|
|
|
153
152
|
});
|
|
154
153
|
}
|
|
155
154
|
|
|
156
|
-
additionalProperties = t.
|
|
155
|
+
additionalProperties = t.literal(
|
|
156
|
+
`Record<string, ${additionalPropertiesType ? additionalPropertiesType.value : t.any().value}>`,
|
|
157
|
+
);
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
const hasRequiredArray = schema.required && schema.required.length > 0;
|
|
@@ -3,14 +3,17 @@ import { prettify } from "./format.ts";
|
|
|
3
3
|
import type { mapOpenApiEndpoints } from "./map-openapi-endpoints.ts";
|
|
4
4
|
|
|
5
5
|
type GeneratorOptions = ReturnType<typeof mapOpenApiEndpoints>;
|
|
6
|
-
type GeneratorContext = Required<GeneratorOptions
|
|
6
|
+
type GeneratorContext = Required<GeneratorOptions> & {
|
|
7
|
+
errorStatusCodes?: readonly number[];
|
|
8
|
+
};
|
|
7
9
|
|
|
8
10
|
export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relativeApiClientPath: string }) => {
|
|
9
11
|
const endpointMethods = new Set(ctx.endpointList.map((endpoint) => endpoint.method.toLowerCase()));
|
|
10
12
|
|
|
11
13
|
const file = `
|
|
12
14
|
import { queryOptions } from "@tanstack/react-query"
|
|
13
|
-
import type { EndpointByMethod, ApiClient } from "${ctx.relativeApiClientPath}"
|
|
15
|
+
import type { EndpointByMethod, ApiClient, SuccessStatusCode, ErrorStatusCode, InferResponseByStatus } from "${ctx.relativeApiClientPath}"
|
|
16
|
+
import { errorStatusCodes, TypedResponseError } from "${ctx.relativeApiClientPath}"
|
|
14
17
|
|
|
15
18
|
type EndpointQueryKey<TOptions extends EndpointParameters> = [
|
|
16
19
|
TOptions & {
|
|
@@ -77,30 +80,36 @@ export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relati
|
|
|
77
80
|
path: Path,
|
|
78
81
|
...params: MaybeOptionalArg<TEndpoint["parameters"]>
|
|
79
82
|
) {
|
|
80
|
-
const queryKey = createQueryKey(path, params[0]);
|
|
83
|
+
const queryKey = createQueryKey(path as string, params[0]);
|
|
81
84
|
const query = {
|
|
82
85
|
/** type-only property if you need easy access to the endpoint params */
|
|
83
86
|
"~endpoint": {} as TEndpoint,
|
|
84
87
|
queryKey,
|
|
88
|
+
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
|
|
85
89
|
queryOptions: queryOptions({
|
|
86
90
|
queryFn: async ({ queryKey, signal, }) => {
|
|
87
|
-
const
|
|
88
|
-
...params,
|
|
89
|
-
...queryKey[0],
|
|
91
|
+
const requestParams = {
|
|
92
|
+
...(params[0] || {}),
|
|
93
|
+
...(queryKey[0] || {}),
|
|
90
94
|
signal,
|
|
91
|
-
|
|
95
|
+
withResponse: false as const
|
|
96
|
+
};
|
|
97
|
+
const res = await this.client.${method}(path, requestParams);
|
|
92
98
|
return res as TEndpoint["response"];
|
|
93
99
|
},
|
|
94
100
|
queryKey: queryKey
|
|
95
101
|
}),
|
|
102
|
+
mutationFn: {} as "You need to pass .mutationOptions to the useMutation hook",
|
|
96
103
|
mutationOptions: {
|
|
97
104
|
mutationKey: queryKey,
|
|
98
105
|
mutationFn: async (localOptions: TEndpoint extends { parameters: infer Parameters} ? Parameters: never) => {
|
|
99
|
-
const
|
|
100
|
-
...params,
|
|
101
|
-
...queryKey[0],
|
|
102
|
-
...localOptions,
|
|
103
|
-
|
|
106
|
+
const requestParams = {
|
|
107
|
+
...(params[0] || {}),
|
|
108
|
+
...(queryKey[0] || {}),
|
|
109
|
+
...(localOptions || {}),
|
|
110
|
+
withResponse: false as const
|
|
111
|
+
};
|
|
112
|
+
const res = await this.client.${method}(path, requestParams);
|
|
104
113
|
return res as TEndpoint["response"];
|
|
105
114
|
}
|
|
106
115
|
}
|
|
@@ -115,32 +124,67 @@ export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relati
|
|
|
115
124
|
|
|
116
125
|
// <ApiClient.request>
|
|
117
126
|
/**
|
|
118
|
-
* Generic mutation method with full type-safety for any endpoint
|
|
127
|
+
* Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially
|
|
128
|
+
* but instead will require them to be passed when calling the mutation.mutate() method
|
|
119
129
|
*/
|
|
120
130
|
mutation<
|
|
121
131
|
TMethod extends keyof EndpointByMethod,
|
|
122
132
|
TPath extends keyof EndpointByMethod[TMethod],
|
|
123
133
|
TEndpoint extends EndpointByMethod[TMethod][TPath],
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
TWithResponse extends boolean = false,
|
|
135
|
+
TSelection = TWithResponse extends true
|
|
136
|
+
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
|
|
137
|
+
: TEndpoint extends { response: infer Res } ? Res : never,
|
|
138
|
+
TError = TEndpoint extends { responses: infer TResponses }
|
|
139
|
+
? TResponses extends Record<string | number, unknown>
|
|
140
|
+
? InferResponseByStatus<TEndpoint, ErrorStatusCode>
|
|
141
|
+
: Error
|
|
142
|
+
: Error
|
|
143
|
+
>(method: TMethod, path: TPath, options?: {
|
|
144
|
+
withResponse?: TWithResponse;
|
|
145
|
+
selectFn?: (res: TWithResponse extends true
|
|
146
|
+
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
|
|
147
|
+
: TEndpoint extends { response: infer Res } ? Res : never
|
|
148
|
+
) => TSelection;
|
|
149
|
+
throwOnStatusError?: boolean
|
|
150
|
+
}) {
|
|
129
151
|
const mutationKey = [{ method, path }] as const;
|
|
130
152
|
return {
|
|
131
153
|
/** type-only property if you need easy access to the endpoint params */
|
|
132
154
|
"~endpoint": {} as TEndpoint,
|
|
133
155
|
mutationKey: mutationKey,
|
|
156
|
+
mutationFn: {} as "You need to pass .mutationOptions to the useMutation hook",
|
|
134
157
|
mutationOptions: {
|
|
135
158
|
mutationKey: mutationKey,
|
|
136
|
-
mutationFn: async
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
mutationFn: async <TLocalWithResponse extends boolean = TWithResponse, TLocalSelection = TLocalWithResponse extends true
|
|
160
|
+
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
|
|
161
|
+
: TEndpoint extends { response: infer Res }
|
|
162
|
+
? Res
|
|
163
|
+
: never>
|
|
164
|
+
(params: (TEndpoint extends { parameters: infer Parameters } ? Parameters : {}) & {
|
|
165
|
+
withResponse?: TLocalWithResponse;
|
|
166
|
+
throwOnStatusError?: boolean;
|
|
167
|
+
}): Promise<TLocalSelection> => {
|
|
168
|
+
const withResponse = params.withResponse ??options?.withResponse ?? false;
|
|
169
|
+
const throwOnStatusError = params.throwOnStatusError ?? options?.throwOnStatusError ?? (withResponse ? false : true);
|
|
170
|
+
const selectFn = options?.selectFn;
|
|
171
|
+
const response = await (this.client as any)[method](path, { ...params as any, withResponse: true, throwOnStatusError: false });
|
|
172
|
+
|
|
173
|
+
if (throwOnStatusError && errorStatusCodes.includes(response.status as never)) {
|
|
174
|
+
throw new TypedResponseError(response as never);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Return just the data if withResponse is false, otherwise return the full response
|
|
178
|
+
const finalResponse = withResponse ? response : response.data;
|
|
179
|
+
const res = selectFn ? selectFn(finalResponse as any) : finalResponse;
|
|
180
|
+
return res as never;
|
|
181
|
+
}
|
|
182
|
+
} satisfies import("@tanstack/react-query").UseMutationOptions<TSelection, TError, (TEndpoint extends { parameters: infer Parameters } ? Parameters : {}) & {
|
|
183
|
+
withResponse?: boolean;
|
|
184
|
+
throwOnStatusError?: boolean;
|
|
185
|
+
}>,
|
|
143
186
|
}
|
|
187
|
+
}
|
|
144
188
|
// </ApiClient.request>
|
|
145
189
|
}
|
|
146
190
|
`;
|