From 5e6354ecf6b115c33f066e93811b5e0cb07efaa0 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 20 Nov 2024 22:12:01 +0100 Subject: [PATCH] Feat: Empty response support (#2191) The following HTTP status codes imply no content in response: - 204 - redirects, such as 302 --- CHANGELOG.md | 30 ++++++++++ README.md | 29 ++++++--- example/endpoints/delete-user.ts | 22 +++++++ example/example.client.ts | 17 +++++- example/example.documentation.yaml | 21 ++++++- example/factories.ts | 12 ++++ example/routing.ts | 3 + package.json | 2 +- src/api-response.ts | 9 ++- src/diagnostics.ts | 2 +- src/documentation-helpers.ts | 3 +- src/integration.ts | 60 ++++++++++++++----- src/result-helpers.ts | 4 +- src/zts.ts | 1 + tests/system/example.spec.ts | 23 ++++++- .../__snapshots__/documentation.spec.ts.snap | 40 +++++++++++++ .../__snapshots__/integration.spec.ts.snap | 57 ++++++++++++------ tests/unit/__snapshots__/zts.spec.ts.snap | 6 +- 18 files changed, 282 insertions(+), 59 deletions(-) create mode 100644 example/endpoints/delete-user.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb22902a..997ee0241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## Version 21 +### v21.1.0 + +- Featuring empty response support: + - For some REST APIs, empty responses are typical: with status code `204` (No Content) and redirects (302); + - Previously, the framework did not offer a straightforward way to describe such responses, but now there is one; + - The `mimeType` property can now be assigned with `null` in `ResultHandler` definition; + - Both `Documentation` and `Integration` generators ignore such entries so that the `schema` can be `z.never()`: + - The body of such response will not be depicted by `Documentation`; + - The type of such response will be described as `undefined` (configurable) by `Integration`. + +```ts +import { z } from "zod"; +import { + ResultHandler, + ensureHttpError, + EndpointsFactory, + Integration, +} from "express-zod-api"; + +const resultHandler = new ResultHandler({ + positive: { statusCode: 204, mimeType: null, schema: z.never() }, + negative: { statusCode: 404, mimeType: null, schema: z.never() }, + handler: ({ error, response }) => { + response.status(error ? ensureHttpError(error).statusCode : 204).end(); // no content + }, +}); + +new Integration({ noContent: z.undefined() }); // undefined is default +``` + ### v21.0.0 - Minimum supported versions of `express`: 4.21.1 and 5.0.1 (fixed vulnerabilities); diff --git a/README.md b/README.md index 426ea95f4..e4f329ef9 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,15 @@ Start your API server with I/O schema validation and custom middlewares in minut 3. [Route path params](#route-path-params) 4. [Multiple schemas for one route](#multiple-schemas-for-one-route) 5. [Response customization](#response-customization) - 6. [Error handling](#error-handling) - 7. [Production mode](#production-mode) - 8. [Non-object response](#non-object-response) including file downloads - 9. [File uploads](#file-uploads) - 10. [Serving static files](#serving-static-files) - 11. [Connect to your own express app](#connect-to-your-own-express-app) - 12. [Testing endpoints](#testing-endpoints) - 13. [Testing middlewares](#testing-middlewares) + 6. [Empty response](#empty-response) + 7. [Error handling](#error-handling) + 8. [Production mode](#production-mode) + 9. [Non-object response](#non-object-response) including file downloads + 10. [File uploads](#file-uploads) + 11. [Serving static files](#serving-static-files) + 12. [Connect to your own express app](#connect-to-your-own-express-app) + 13. [Testing endpoints](#testing-endpoints) + 14. [Testing middlewares](#testing-middlewares) 6. [Special needs](#special-needs) 1. [Different responses for different status codes](#different-responses-for-different-status-codes) 2. [Array response](#array-response) for migrating legacy APIs @@ -843,6 +844,18 @@ import { EndpointsFactory } from "express-zod-api"; const endpointsFactory = new EndpointsFactory(yourResultHandler); ``` +## Empty response + +For some REST APIs, empty responses are typical: with status code `204` (No Content) and redirects (302). In order to +describe it set the `mimeType` to `null` and `schema` to `z.never()`: + +```typescript +const resultHandler = new ResultHandler({ + positive: { statusCode: 204, mimeType: null, schema: z.never() }, + negative: { statusCode: 404, mimeType: null, schema: z.never() }, +}); +``` + ## Error handling `ResultHandler` is designed to be the entity responsible for centralized error handling. By default, that center is diff --git a/example/endpoints/delete-user.ts b/example/endpoints/delete-user.ts new file mode 100644 index 000000000..1ab792bc4 --- /dev/null +++ b/example/endpoints/delete-user.ts @@ -0,0 +1,22 @@ +import createHttpError from "http-errors"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import { noContentFactory } from "../factories"; + +/** @desc The endpoint demonstrates no content response established by its factory */ +export const deleteUserEndpoint = noContentFactory.build({ + method: "delete", + tag: "users", + input: z.object({ + id: z + .string() + .regex(/\d+/) + .transform((id) => parseInt(id, 10)) + .describe("numeric string"), + }), + output: z.object({}), + handler: async ({ input: { id } }) => { + assert(id <= 100, createHttpError(404, "User not found")); + return {}; + }, +}); diff --git a/example/example.client.ts b/example/example.client.ts index 05a493566..c6ca9b41a 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -27,6 +27,13 @@ type GetV1UserRetrieveResponse = }; }; +type DeleteV1UserIdRemoveInput = { + /** numeric string */ + id: string; +}; + +type DeleteV1UserIdRemoveResponse = undefined; + type PatchV1UserIdInput = { key: string; id: string; @@ -131,6 +138,7 @@ type PostV1AvatarRawResponse = export type Path = | "/v1/user/retrieve" + | "/v1/user/:id/remove" | "/v1/user/:id" | "/v1/user/create" | "/v1/user/list" @@ -145,6 +153,7 @@ export type MethodPath = `${Method} ${Path}`; export interface Input extends Record { "get /v1/user/retrieve": GetV1UserRetrieveInput; + "delete /v1/user/:id/remove": DeleteV1UserIdRemoveInput; "patch /v1/user/:id": PatchV1UserIdInput; "post /v1/user/create": PostV1UserCreateInput; "get /v1/user/list": GetV1UserListInput; @@ -156,6 +165,7 @@ export interface Input extends Record { export interface Response extends Record { "get /v1/user/retrieve": GetV1UserRetrieveResponse; + "delete /v1/user/:id/remove": DeleteV1UserIdRemoveResponse; "patch /v1/user/:id": PatchV1UserIdResponse; "post /v1/user/create": PostV1UserCreateResponse; "get /v1/user/list": GetV1UserListResponse; @@ -176,6 +186,7 @@ export const jsonEndpoints = { export const endpointTags = { "get /v1/user/retrieve": ["users"], + "delete /v1/user/:id/remove": ["users"], "patch /v1/user/:id": ["users"], "post /v1/user/create": ["users"], "get /v1/user/list": ["users"], @@ -228,9 +239,9 @@ export const exampleImplementation: Implementation = async ( headers: hasBody ? { "Content-Type": "application/json" } : undefined, body: hasBody ? JSON.stringify(params) : undefined, }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; const client = new ExpressZodAPIClient(exampleImplementation); diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 8d47c00b0..13e28edb5 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 21.0.0 + version: 21.1.0-beta.1 paths: /v1/user/retrieve: get: @@ -85,6 +85,25 @@ paths: status: error error: message: Sample error message + /v1/user/{id}/remove: + delete: + operationId: DeleteV1UserIdRemove + tags: + - users + parameters: + - name: id + in: path + required: true + description: numeric string + schema: + type: string + pattern: \d+ + description: numeric string + responses: + "204": + description: DELETE /v1/user/:id/remove Positive response + "404": + description: DELETE /v1/user/:id/remove Negative response /v1/user/{id}: patch: operationId: PatchV1UserId diff --git a/example/factories.ts b/example/factories.ts index cf0b617fa..a789bf1a5 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -98,3 +98,15 @@ export const statusDependingFactory = new EndpointsFactory({ }, }), }); + +/** @desc This factory demonstrates response without body, such as 204 No Content */ +export const noContentFactory = new EndpointsFactory({ + config, + resultHandler: new ResultHandler({ + positive: { statusCode: 204, mimeType: null, schema: z.never() }, + negative: { statusCode: 404, mimeType: null, schema: z.never() }, + handler: ({ error, response }) => { + response.status(error ? ensureHttpError(error).statusCode : 204).end(); // no content + }, + }), +}); diff --git a/example/routing.ts b/example/routing.ts index a6bc010a5..5e6b37284 100644 --- a/example/routing.ts +++ b/example/routing.ts @@ -1,6 +1,7 @@ import { DependsOnMethod, Routing, ServeStatic } from "../src"; import { rawAcceptingEndpoint } from "./endpoints/accept-raw"; import { createUserEndpoint } from "./endpoints/create-user"; +import { deleteUserEndpoint } from "./endpoints/delete-user"; import { listUsersEndpoint } from "./endpoints/list-users"; import { uploadAvatarEndpoint } from "./endpoints/upload-avatar"; import { retrieveUserEndpoint } from "./endpoints/retrieve-user"; @@ -17,6 +18,8 @@ export const routing: Routing = { // syntax 2: methods are defined within the route (id is the route path param by the way) ":id": new DependsOnMethod({ patch: updateUserEndpoint, // demonstrates authentication + }).nest({ + remove: deleteUserEndpoint, // nested path: /v1/user/:id/remove }), // demonstrates different response schemas depending on status code create: createUserEndpoint, diff --git a/package.json b/package.json index 14a0bdaa3..2c811f0d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "21.0.0", + "version": "21.1.0-beta.1", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { diff --git a/src/api-response.ts b/src/api-response.ts index 44abdebd3..31f5ecb9d 100644 --- a/src/api-response.ts +++ b/src/api-response.ts @@ -12,8 +12,11 @@ export interface ApiResponse { schema: S; /** @default 200 for a positive and 400 for a negative response */ statusCode?: number | [number, ...number[]]; - /** @default "application/json" */ - mimeType?: string | [string, ...string[]]; + /** + * @example null is for no content, such as 204 and 302 + * @default "application/json" + * */ + mimeType?: string | [string, ...string[]] | null; /** @deprecated use statusCode */ statusCodes?: never; /** @deprecated use mimeType */ @@ -27,5 +30,5 @@ export interface ApiResponse { export interface NormalizedResponse { schema: z.ZodTypeAny; statusCodes: [number, ...number[]]; - mimeTypes: [string, ...string[]]; + mimeTypes: [string, ...string[]] | null; } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 382c450de..725673cf9 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -22,7 +22,7 @@ export class Diagnostics { } for (const variant of ["positive", "negative"] as const) { for (const { mimeTypes, schema } of endpoint.getResponses(variant)) { - if (mimeTypes.includes(contentTypes.json)) { + if (mimeTypes?.includes(contentTypes.json)) { try { assertJsonCompatible(schema, "out"); } catch (reason) { diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 9c2aa6a2a..9bcb3f388 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -785,11 +785,12 @@ export const depictResponse = ({ hasMultipleStatusCodes ? statusCode : "" }`.trim(), }: ReqResHandlingProps & { - mimeTypes: ReadonlyArray; + mimeTypes: ReadonlyArray | null; variant: ResponseVariant; statusCode: number; hasMultipleStatusCodes: boolean; }): ResponseObject => { + if (!mimeTypes) return { description }; const depictedSchema = excludeExamplesFromDepiction( walkSchema(schema, { rules: { ...brandHandling, ...depicters }, diff --git a/src/integration.ts b/src/integration.ts index 41727e514..7b83ace9c 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -70,6 +70,11 @@ interface IntegrationParams { */ withUndefined?: boolean; }; + /** + * @desc The schema to use for responses without body such as 204 + * @default z.undefined() + * */ + noContent?: z.ZodTypeAny; /** * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). @@ -129,6 +134,7 @@ export class Integration { searchParamsConst: f.createIdentifier("searchParams"), exampleImplementationConst: f.createIdentifier("exampleImplementation"), clientConst: f.createIdentifier("client"), + contentTypeConst: f.createIdentifier("contentType"), isJsonConst: f.createIdentifier("isJSON"), } satisfies Record; protected interfaces: Array<{ @@ -157,6 +163,7 @@ export class Integration { variant = "client", splitResponse = false, optionalPropStyle = { withQuestionMark: true, withUndefined: true }, + noContent = z.undefined(), }: IntegrationParams) { walkRouting({ routing, @@ -175,7 +182,7 @@ export class Integration { : undefined; const positiveSchema = endpoint .getResponses("positive") - .map(({ schema }) => schema) + .map(({ schema, mimeTypes }) => (mimeTypes ? schema : noContent)) .reduce((agg, schema) => agg.or(schema)); const positiveResponse = splitResponse ? zodToTs(positiveSchema, { @@ -188,7 +195,7 @@ export class Integration { : undefined; const negativeSchema = endpoint .getResponses("negative") - .map(({ schema }) => schema) + .map(({ schema, mimeTypes }) => (mimeTypes ? schema : noContent)) .reduce((agg, schema) => agg.or(schema)); const negativeResponse = splitResponse ? zodToTs(negativeSchema, { @@ -230,7 +237,7 @@ export class Integration { isJson: endpoint .getResponses("positive") .some((response) => - response.mimeTypes.includes(contentTypes.json), + response.mimeTypes?.includes(contentTypes.json), ), tags: endpoint.getTags(), }, @@ -588,25 +595,44 @@ export class Integration { ), ); - // const isJSON = response.headers.get("content-type")?.startsWith("application/json"); + // const contentType = response.headers.get("content-type"); + const contentTypeStatement = f.createVariableStatement( + undefined, + makeConst( + this.ids.contentTypeConst, + f.createCallExpression( + f.createPropertyAccessExpression( + f.createPropertyAccessExpression( + this.ids.responseConst, + this.ids.headersProperty, + ), + f.createIdentifier("get" satisfies keyof Headers), + ), + undefined, + [f.createStringLiteral("content-type")], + ), + ), + ); + + // if (!contentType) return; + const noBodyStatement = f.createIfStatement( + f.createPrefixUnaryExpression( + ts.SyntaxKind.ExclamationToken, + this.ids.contentTypeConst, + ), + f.createReturnStatement(undefined), + undefined, + ); + + // const isJSON = contentType.startsWith("application/json"); const parserStatement = f.createVariableStatement( undefined, makeConst( this.ids.isJsonConst, f.createCallChain( f.createPropertyAccessChain( - f.createCallExpression( - f.createPropertyAccessExpression( - f.createPropertyAccessExpression( - this.ids.responseConst, - this.ids.headersProperty, - ), - f.createIdentifier("get" satisfies keyof Headers), - ), - undefined, - [f.createStringLiteral("content-type")], - ), - f.createToken(ts.SyntaxKind.QuestionDotToken), + this.ids.contentTypeConst, + undefined, f.createIdentifier("startsWith" satisfies keyof string), ), undefined, @@ -647,6 +673,8 @@ export class Integration { hasBodyStatement, searchParamsStatement, responseStatement, + contentTypeStatement, + noBodyStatement, parserStatement, returnStatement, ]), diff --git a/src/result-helpers.ts b/src/result-helpers.ts index 6bb88fb7c..5916b9848 100644 --- a/src/result-helpers.ts +++ b/src/result-helpers.ts @@ -42,7 +42,9 @@ export const normalize = ( mimeTypes: typeof mimeType === "string" ? [mimeType] - : mimeType || fallback.mimeTypes, + : mimeType === undefined + ? fallback.mimeTypes + : mimeType, }), ); }; diff --git a/src/zts.ts b/src/zts.ts index f96e5f131..656b3b4a7 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -234,6 +234,7 @@ const producers: HandlingRules< ZodBigInt: onPrimitive(ts.SyntaxKind.BigIntKeyword), ZodBoolean: onPrimitive(ts.SyntaxKind.BooleanKeyword), ZodAny: onPrimitive(ts.SyntaxKind.AnyKeyword), + ZodUndefined: onPrimitive(ts.SyntaxKind.UndefinedKeyword), [ezDateInBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), [ezDateOutBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), ZodNull: onNull, diff --git a/tests/system/example.spec.ts b/tests/system/example.spec.ts index 8da04ee51..d35a56625 100644 --- a/tests/system/example.spec.ts +++ b/tests/system/example.spec.ts @@ -241,6 +241,15 @@ describe("Example", async () => { expect(json).toMatchSnapshot(); }, ); + + test("Should handle no content", async () => { + const response = await fetch( + `http://localhost:${port}/v1/user/50/remove`, + { method: "DELETE", headers: { "Content-Type": "application/json" } }, + ); + expect(response.status).toBe(204); + expect(response.headers.get("content-type")).toBeNull(); + }); }); describe("Negative", () => { @@ -422,9 +431,9 @@ describe("Example", async () => { : { "Content-Type": "application/json", token: "456" }, body: method === "get" ? undefined : JSON.stringify(params), }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; @@ -457,5 +466,13 @@ describe("Example", async () => { | { status: "error"; error: { message: string } } >(); }); + + test("should handle no content (no response body)", async () => { + const response = await client.provide("delete", "/v1/user/:id/remove", { + id: "12", + }); + expect(response).toBeUndefined(); + expectTypeOf(response).toBeUndefined(); + }); }); }); diff --git a/tests/unit/__snapshots__/documentation.spec.ts.snap b/tests/unit/__snapshots__/documentation.spec.ts.snap index 99b8f0370..5d3a6498b 100644 --- a/tests/unit/__snapshots__/documentation.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation.spec.ts.snap @@ -1237,6 +1237,25 @@ paths: status: error error: message: Sample error message + /v1/user/{id}/remove: + delete: + operationId: DeleteV1UserIdRemove + tags: + - users + parameters: + - name: id + in: path + required: true + description: numeric string + schema: + type: string + pattern: \\d+ + description: numeric string + responses: + "204": + description: DELETE /v1/user/:id/remove Positive response + "404": + description: DELETE /v1/user/:id/remove Negative response /v1/user/{id}: patch: operationId: PatchV1UserId @@ -1780,6 +1799,23 @@ paths: status: error error: message: Sample error message + /v1/user/{id}/remove: + delete: + operationId: DeleteV1UserIdRemove + tags: + - users + parameters: + - name: id + in: path + required: true + description: numeric string + schema: + $ref: "#/components/schemas/DeleteV1UserIdRemoveParameterId" + responses: + "204": + description: DELETE /v1/user/:id/remove Positive response + "404": + description: DELETE /v1/user/:id/remove Negative response /v1/user/{id}: patch: operationId: PatchV1UserId @@ -2092,6 +2128,10 @@ components: required: - status - error + DeleteV1UserIdRemoveParameterId: + type: string + pattern: \\d+ + description: numeric string PatchV1UserIdParameterId: type: string PatchV1UserIdPositiveResponse: diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index 096a1f668..d7e6b354b 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -80,9 +80,9 @@ export const exampleImplementation: Implementation = async ( headers: hasBody ? { "Content-Type": "application/json" } : undefined, body: hasBody ? JSON.stringify(params) : undefined, }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; const client = new ExpressZodAPIClient(exampleImplementation); @@ -171,9 +171,9 @@ export const exampleImplementation: Implementation = async ( headers: hasBody ? { "Content-Type": "application/json" } : undefined, body: hasBody ? JSON.stringify(params) : undefined, }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; const client = new ExpressZodAPIClient(exampleImplementation); @@ -262,9 +262,9 @@ export const exampleImplementation: Implementation = async ( headers: hasBody ? { "Content-Type": "application/json" } : undefined, body: hasBody ? JSON.stringify(params) : undefined, }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; const client = new ExpressZodAPIClient(exampleImplementation); @@ -365,9 +365,9 @@ export const exampleImplementation: Implementation = async ( headers: hasBody ? { "Content-Type": "application/json" } : undefined, body: hasBody ? JSON.stringify(params) : undefined, }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; const client = new ExpressZodAPIClient(exampleImplementation); @@ -454,6 +454,13 @@ type GetV1UserRetrieveResponse = }; }; +type DeleteV1UserIdRemoveInput = { + /** numeric string */ + id: string; +}; + +type DeleteV1UserIdRemoveResponse = undefined; + type PatchV1UserIdInput = { key: string; id: string; @@ -558,6 +565,7 @@ type PostV1AvatarRawResponse = export type Path = | "/v1/user/retrieve" + | "/v1/user/:id/remove" | "/v1/user/:id" | "/v1/user/create" | "/v1/user/list" @@ -572,6 +580,7 @@ export type MethodPath = \`\${Method} \${Path}\`; export interface Input extends Record { "get /v1/user/retrieve": GetV1UserRetrieveInput; + "delete /v1/user/:id/remove": DeleteV1UserIdRemoveInput; "patch /v1/user/:id": PatchV1UserIdInput; "post /v1/user/create": PostV1UserCreateInput; "get /v1/user/list": GetV1UserListInput; @@ -583,6 +592,7 @@ export interface Input extends Record { export interface Response extends Record { "get /v1/user/retrieve": GetV1UserRetrieveResponse; + "delete /v1/user/:id/remove": DeleteV1UserIdRemoveResponse; "patch /v1/user/:id": PatchV1UserIdResponse; "post /v1/user/create": PostV1UserCreateResponse; "get /v1/user/list": GetV1UserListResponse; @@ -603,6 +613,7 @@ export const jsonEndpoints = { export const endpointTags = { "get /v1/user/retrieve": ["users"], + "delete /v1/user/:id/remove": ["users"], "patch /v1/user/:id": ["users"], "post /v1/user/create": ["users"], "get /v1/user/list": ["users"], @@ -655,9 +666,9 @@ export const exampleImplementation: Implementation = async ( headers: hasBody ? { "Content-Type": "application/json" } : undefined, body: hasBody ? JSON.stringify(params) : undefined, }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; const client = new ExpressZodAPIClient(exampleImplementation); @@ -696,6 +707,13 @@ type GetV1UserRetrieveResponse = }; }; +type DeleteV1UserIdRemoveInput = { + /** numeric string */ + id: string; +}; + +type DeleteV1UserIdRemoveResponse = undefined; + type PatchV1UserIdInput = { key: string; id: string; @@ -800,6 +818,7 @@ type PostV1AvatarRawResponse = export type Path = | "/v1/user/retrieve" + | "/v1/user/:id/remove" | "/v1/user/:id" | "/v1/user/create" | "/v1/user/list" @@ -814,6 +833,7 @@ export type MethodPath = \`\${Method} \${Path}\`; export interface Input extends Record { "get /v1/user/retrieve": GetV1UserRetrieveInput; + "delete /v1/user/:id/remove": DeleteV1UserIdRemoveInput; "patch /v1/user/:id": PatchV1UserIdInput; "post /v1/user/create": PostV1UserCreateInput; "get /v1/user/list": GetV1UserListInput; @@ -825,6 +845,7 @@ export interface Input extends Record { export interface Response extends Record { "get /v1/user/retrieve": GetV1UserRetrieveResponse; + "delete /v1/user/:id/remove": DeleteV1UserIdRemoveResponse; "patch /v1/user/:id": PatchV1UserIdResponse; "post /v1/user/create": PostV1UserCreateResponse; "get /v1/user/list": GetV1UserListResponse; @@ -965,9 +986,9 @@ export const exampleImplementation: Implementation = async ( headers: hasBody ? { "Content-Type": "application/json" } : undefined, body: hasBody ? JSON.stringify(params) : undefined, }); - const isJSON = response.headers - .get("content-type") - ?.startsWith("application/json"); + const contentType = response.headers.get("content-type"); + if (!contentType) return; + const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; const client = new ExpressZodAPIClient(exampleImplementation); diff --git a/tests/unit/__snapshots__/zts.spec.ts.snap b/tests/unit/__snapshots__/zts.spec.ts.snap index 944d59013..a71edee17 100644 --- a/tests/unit/__snapshots__/zts.spec.ts.snap +++ b/tests/unit/__snapshots__/zts.spec.ts.snap @@ -15,7 +15,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` enum: "hi" | "bye"; intersectionWithTransform: (number & bigint) & (number & string); date: any; - undefined?: any; + undefined?: undefined; null: null; void?: any; any?: any; @@ -85,7 +85,7 @@ exports[`zod-to-ts > PrimitiveSchema > outputs correct typescript 1`] = ` number: number; boolean: boolean; date: any; - undefined?: any; + undefined?: undefined; null: null; void?: any; any?: any; @@ -184,7 +184,7 @@ exports[`zod-to-ts > z.object() > escapes correctly 1`] = ` $e?: any; "4t"?: any; _r?: any; - "-r"?: any; + "-r"?: undefined; }" `;