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/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
- type GeneratorOptions = ReturnType<typeof mapOpenApiEndpoints> & {
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 = { ...options, runtime: options.runtime ?? "none" } as GeneratorContext;
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 apiClient = options.schemasOnly ? "" : generateApiClient(ctx);
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) return parameters.value;
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.map(
231
- (endpoint) => `"${endpoint.path}": ${ctx.runtime === "none" ? "Endpoints." : ""}${endpoint.meta.alias}`,
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
- return this.fetcher("${method}", this.baseUrl + path, params[0])
342
- .then(response => this.parseResponse(response))${match(ctx.runtime)
343
- .with("zod", "yup", () => `as Promise<${infer(`TEndpoint["response"]`)}>`)
344
- .with("arktype", "io-ts", "typebox", "valibot", () => `as Promise<${infer(`TEndpoint`) + `["response"]`}>`)
345
- .otherwise(() => `as Promise<TEndpoint["response"]>`)};
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<Omit<Response, "json"> & {
377
- /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */
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 endpointSchemaList + apiClientTypes + apiClient;
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
- if (statusCode >= 200 && statusCode < 300) {
136
- responseObject = refs.unwrap<ResponseObject>(responseOrRef);
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.object({ [t.string().value]: additionalPropertiesType! });
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 res = await this.client.${method}(path, {
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 res = await this.client.${method}(path, {
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 that doesnt require parameters to be passed initially
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
- TSelection,
125
- >(method: TMethod, path: TPath, selectFn?: (res: Omit<Response, "json"> & {
126
- /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */
127
- json: () => Promise<TEndpoint extends { response: infer Res } ? Res : never>;
128
- }) => TSelection) {
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 (params: TEndpoint extends { parameters: infer Parameters } ? Parameters : never) => {
137
- const response = await this.client.request(method, path, params);
138
- const res = selectFn ? selectFn(response) : response
139
- return res as unknown extends TSelection ? typeof response : Awaited<TSelection>
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
  `;