From 5552d965ad2c748cf68ce0bd2b1d0ade88b67603 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 13 Jul 2025 22:09:11 +0200 Subject: [PATCH 01/27] Add HEAD method to methods. --- example/example.client.ts | 2 +- express-zod-api/src/common-helpers.ts | 1 + express-zod-api/src/method.ts | 1 + .../tests/__snapshots__/common-helpers.spec.ts.snap | 4 ++++ .../tests/__snapshots__/integration.spec.ts.snap | 10 +++++----- express-zod-api/tests/common-helpers.spec.ts | 7 +++++++ express-zod-api/tests/method.spec.ts | 10 +++++++++- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/example/example.client.ts b/example/example.client.ts index 7dcb515a3..85ee5926d 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -338,7 +338,7 @@ export type Path = | "/v1/events/stream" | "/v1/forms/feedback"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 114f3a62f..e0c45c9da 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -37,6 +37,7 @@ const areFilesAvailable = (request: Request): boolean => { export const defaultInputSources: InputSources = { get: ["query", "params"], + head: ["query", "params"], post: ["body", "params", "files"], put: ["body", "params"], patch: ["body", "params"], diff --git a/express-zod-api/src/method.ts b/express-zod-api/src/method.ts index c581d87f0..e37ee95ab 100644 --- a/express-zod-api/src/method.ts +++ b/express-zod-api/src/method.ts @@ -6,6 +6,7 @@ export const methods = [ "put", "delete", "patch", + "head", ] satisfies Array; export type Method = (typeof methods)[number]; diff --git a/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap index 30ee82c2c..f96ccfbd7 100644 --- a/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap @@ -10,6 +10,10 @@ exports[`Common Helpers > defaultInputSources > should be declared in a certain "query", "params", ], + "head": [ + "query", + "params", + ], "patch": [ "body", "params", diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 38e79e133..e1b189ffa 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -37,7 +37,7 @@ interface PostV1CustomNegativeResponseVariants { export type Path = "/v1/custom"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "post /v1/custom": PostV1CustomInput; @@ -110,7 +110,7 @@ interface PostV1MtplNegativeResponseVariants { export type Path = "/v1/mtpl"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "post /v1/mtpl": PostV1MtplInput; @@ -178,7 +178,7 @@ interface PostV1TestNegativeResponseVariants { export type Path = "/v1/test"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { /** @deprecated */ @@ -247,7 +247,7 @@ interface PostV1TestNegativeResponseVariants { export type Path = "/v1/test"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { /** @deprecated */ @@ -313,7 +313,7 @@ interface PostV1TestWithDashesNegativeResponseVariants { export type Path = "/v1/test-with-dashes"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "post /v1/test-with-dashes": PostV1TestWithDashesInput; diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 04d0f8679..a2e55af7c 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -69,6 +69,13 @@ describe("Common Helpers", () => { param: 123, }); }); + test("should return query for HEAD requests by default", () => { + expect( + getInput(makeRequestMock({ method: "HEAD", query: { param: 123 } })), + ).toEqual({ + param: 123, + }); + }); test("should return only query for DELETE requests by default", () => { expect( getInput( diff --git a/express-zod-api/tests/method.spec.ts b/express-zod-api/tests/method.spec.ts index 2d356aeb4..1f46ffe3e 100644 --- a/express-zod-api/tests/method.spec.ts +++ b/express-zod-api/tests/method.spec.ts @@ -4,7 +4,14 @@ import { isMethod, methods, Method, AuxMethod } from "../src/method"; describe("Method", () => { describe("methods array", () => { test("should be the list of selected keys of express router", () => { - expect(methods).toEqual(["get", "post", "put", "delete", "patch"]); + expect(methods).toEqual([ + "get", + "post", + "put", + "delete", + "patch", + "head", + ]); }); }); @@ -15,6 +22,7 @@ describe("Method", () => { expectTypeOf<"put">().toExtend(); expectTypeOf<"delete">().toExtend(); expectTypeOf<"patch">().toExtend(); + expectTypeOf<"head">().toExtend(); expectTypeOf<"wrong">().not.toExtend(); }); }); From 5e90d9f626b7fea77eb4bd6ca39d7f6a61a861d4 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 13 Jul 2025 22:51:21 +0200 Subject: [PATCH 02/27] Force no content for HEAD response by Documentation and Integration. --- example/endpoints/send-avatar.ts | 1 + example/endpoints/stream-avatar.ts | 1 + example/example.client.ts | 65 ++++++++++++++++++++++++++++ example/example.documentation.yaml | 47 ++++++++++++++++++++ express-zod-api/src/documentation.ts | 3 +- express-zod-api/src/integration.ts | 9 +++- 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/example/endpoints/send-avatar.ts b/example/endpoints/send-avatar.ts index 6f6890b13..84d7380d8 100644 --- a/example/endpoints/send-avatar.ts +++ b/example/endpoints/send-avatar.ts @@ -3,6 +3,7 @@ import { fileSendingEndpointsFactory } from "../factories"; import { readFile } from "node:fs/promises"; export const sendAvatarEndpoint = fileSendingEndpointsFactory.build({ + method: ["get", "head"], shortDescription: "Sends a file content.", tag: ["files", "users"], input: z.object({ diff --git a/example/endpoints/stream-avatar.ts b/example/endpoints/stream-avatar.ts index 2444741d5..6d3da015d 100644 --- a/example/endpoints/stream-avatar.ts +++ b/example/endpoints/stream-avatar.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { fileStreamingEndpointsFactory } from "../factories"; export const streamAvatarEndpoint = fileStreamingEndpointsFactory.build({ + method: ["get", "head"], shortDescription: "Streams a file content.", tag: ["users", "files"], input: z.object({ diff --git a/example/example.client.ts b/example/example.client.ts index 85ee5926d..b6bc4aa15 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -180,6 +180,27 @@ interface GetV1AvatarSendNegativeResponseVariants { 400: GetV1AvatarSendNegativeVariant1; } +/** head /v1/avatar/send */ +type HeadV1AvatarSendInput = { + userId: string; +}; + +/** head /v1/avatar/send */ +type HeadV1AvatarSendPositiveVariant1 = undefined; + +/** head /v1/avatar/send */ +interface HeadV1AvatarSendPositiveResponseVariants { + 200: HeadV1AvatarSendPositiveVariant1; +} + +/** head /v1/avatar/send */ +type HeadV1AvatarSendNegativeVariant1 = string; + +/** head /v1/avatar/send */ +interface HeadV1AvatarSendNegativeResponseVariants { + 400: HeadV1AvatarSendNegativeVariant1; +} + /** get /v1/avatar/stream */ type GetV1AvatarStreamInput = { userId: string; @@ -201,6 +222,27 @@ interface GetV1AvatarStreamNegativeResponseVariants { 400: GetV1AvatarStreamNegativeVariant1; } +/** head /v1/avatar/stream */ +type HeadV1AvatarStreamInput = { + userId: string; +}; + +/** head /v1/avatar/stream */ +type HeadV1AvatarStreamPositiveVariant1 = undefined; + +/** head /v1/avatar/stream */ +interface HeadV1AvatarStreamPositiveResponseVariants { + 200: HeadV1AvatarStreamPositiveVariant1; +} + +/** head /v1/avatar/stream */ +type HeadV1AvatarStreamNegativeVariant1 = string; + +/** head /v1/avatar/stream */ +interface HeadV1AvatarStreamNegativeResponseVariants { + 400: HeadV1AvatarStreamNegativeVariant1; +} + /** post /v1/avatar/upload */ type PostV1AvatarUploadInput = { avatar: any; @@ -348,7 +390,10 @@ export interface Input { "get /v1/user/list": GetV1UserListInput; /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendInput; + /** @deprecated */ + "head /v1/avatar/send": HeadV1AvatarSendInput; "get /v1/avatar/stream": GetV1AvatarStreamInput; + "head /v1/avatar/stream": HeadV1AvatarStreamInput; "post /v1/avatar/upload": PostV1AvatarUploadInput; "post /v1/avatar/raw": PostV1AvatarRawInput; "get /v1/events/stream": GetV1EventsStreamInput; @@ -363,7 +408,10 @@ export interface PositiveResponse { "get /v1/user/list": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; + /** @deprecated */ + "head /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; + "head /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; "get /v1/events/stream": SomeOf; @@ -378,7 +426,10 @@ export interface NegativeResponse { "get /v1/user/list": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; + /** @deprecated */ + "head /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; + "head /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; "get /v1/events/stream": SomeOf; @@ -399,8 +450,13 @@ export interface EncodedResponse { /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendPositiveResponseVariants & GetV1AvatarSendNegativeResponseVariants; + /** @deprecated */ + "head /v1/avatar/send": HeadV1AvatarSendPositiveResponseVariants & + HeadV1AvatarSendNegativeResponseVariants; "get /v1/avatar/stream": GetV1AvatarStreamPositiveResponseVariants & GetV1AvatarStreamNegativeResponseVariants; + "head /v1/avatar/stream": HeadV1AvatarStreamPositiveResponseVariants & + HeadV1AvatarStreamNegativeResponseVariants; "post /v1/avatar/upload": PostV1AvatarUploadPositiveResponseVariants & PostV1AvatarUploadNegativeResponseVariants; "post /v1/avatar/raw": PostV1AvatarRawPositiveResponseVariants & @@ -431,9 +487,16 @@ export interface Response { "get /v1/avatar/send": | PositiveResponse["get /v1/avatar/send"] | NegativeResponse["get /v1/avatar/send"]; + /** @deprecated */ + "head /v1/avatar/send": + | PositiveResponse["head /v1/avatar/send"] + | NegativeResponse["head /v1/avatar/send"]; "get /v1/avatar/stream": | PositiveResponse["get /v1/avatar/stream"] | NegativeResponse["get /v1/avatar/stream"]; + "head /v1/avatar/stream": + | PositiveResponse["head /v1/avatar/stream"] + | NegativeResponse["head /v1/avatar/stream"]; "post /v1/avatar/upload": | PositiveResponse["post /v1/avatar/upload"] | NegativeResponse["post /v1/avatar/upload"]; @@ -457,7 +520,9 @@ export const endpointTags = { "post /v1/user/create": ["users"], "get /v1/user/list": ["users"], "get /v1/avatar/send": ["files", "users"], + "head /v1/avatar/send": ["files", "users"], "get /v1/avatar/stream": ["users", "files"], + "head /v1/avatar/stream": ["users", "files"], "post /v1/avatar/upload": ["files"], "post /v1/avatar/raw": ["files"], "get /v1/events/stream": ["subscriptions"], diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index b491f1950..b6dddd2c5 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -430,6 +430,30 @@ paths: text/plain: schema: type: string + head: + operationId: HeadV1AvatarSend + summary: Sends a file content. + deprecated: true + tags: + - files + - users + parameters: + - name: userId + in: query + required: true + description: HEAD /v1/avatar/send Parameter + schema: + type: string + pattern: \d+ + responses: + "200": + description: HEAD /v1/avatar/send Positive response + "400": + description: HEAD /v1/avatar/send Negative response + content: + text/plain: + schema: + type: string /v1/avatar/stream: get: operationId: GetV1AvatarStream @@ -460,6 +484,29 @@ paths: text/plain: schema: type: string + head: + operationId: HeadV1AvatarStream + summary: Streams a file content. + tags: + - users + - files + parameters: + - name: userId + in: query + required: true + description: HEAD /v1/avatar/stream Parameter + schema: + type: string + pattern: \d+ + responses: + "200": + description: HEAD /v1/avatar/stream Positive response + "400": + description: HEAD /v1/avatar/stream Negative response + content: + text/plain: + schema: + type: string /v1/avatar/upload: post: operationId: PostV1AvatarUpload diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 822e285c8..e411405c8 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -198,7 +198,8 @@ export class Documentation extends OpenApiBuilder { ...commons, variant, schema, - mimeTypes, + mimeTypes: + method === "head" && variant === "positive" ? null : mimeTypes, statusCode, hasMultipleStatusCodes: apiResponses.length > 1 || statusCodes.length > 1, diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 311c6c639..00e5b3b4b 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -108,7 +108,14 @@ export class Integration extends IntegrationBase { const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { const variantType = makeType( entitle(responseVariant, "variant", `${idx + 1}`), - zodToTs(mimeTypes ? schema : noContent, ctxOut), + zodToTs( + mimeTypes // @todo simplify this + ? method === "head" && responseVariant === "positive" + ? noContent + : schema + : noContent, + ctxOut, + ), { comment: request }, ); this.#program.push(variantType); From 8a316b2e0c98cbc926157ac95382c517435607c1 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 13 Jul 2025 22:55:46 +0200 Subject: [PATCH 03/27] ref: simpler condition for Integration. --- express-zod-api/src/integration.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 00e5b3b4b..4b9e39ac0 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -106,16 +106,12 @@ export class Integration extends IntegrationBase { (agg, responseVariant) => { const responses = endpoint.getResponses(responseVariant); const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { + const hasContent = + mimeTypes && + !(method === "head" && responseVariant === "positive"); const variantType = makeType( entitle(responseVariant, "variant", `${idx + 1}`), - zodToTs( - mimeTypes // @todo simplify this - ? method === "head" && responseVariant === "positive" - ? noContent - : schema - : noContent, - ctxOut, - ), + zodToTs(hasContent ? schema : noContent, ctxOut), { comment: request }, ); this.#program.push(variantType); From bfccb454dc6e2fac05b6119ba1afabeb80ede5a1 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 14 Jul 2025 12:04:10 +0200 Subject: [PATCH 04/27] REV: moving HEAD method to AuxMethod type. --- example/endpoints/send-avatar.ts | 1 - example/endpoints/stream-avatar.ts | 1 - example/example.client.ts | 67 +------------------ example/example.documentation.yaml | 47 ------------- express-zod-api/src/common-helpers.ts | 3 +- express-zod-api/src/documentation.ts | 3 +- express-zod-api/src/integration.ts | 5 +- express-zod-api/src/method.ts | 3 +- .../__snapshots__/common-helpers.spec.ts.snap | 4 -- .../__snapshots__/integration.spec.ts.snap | 10 +-- express-zod-api/tests/method.spec.ts | 16 ++--- 11 files changed, 16 insertions(+), 144 deletions(-) diff --git a/example/endpoints/send-avatar.ts b/example/endpoints/send-avatar.ts index 84d7380d8..6f6890b13 100644 --- a/example/endpoints/send-avatar.ts +++ b/example/endpoints/send-avatar.ts @@ -3,7 +3,6 @@ import { fileSendingEndpointsFactory } from "../factories"; import { readFile } from "node:fs/promises"; export const sendAvatarEndpoint = fileSendingEndpointsFactory.build({ - method: ["get", "head"], shortDescription: "Sends a file content.", tag: ["files", "users"], input: z.object({ diff --git a/example/endpoints/stream-avatar.ts b/example/endpoints/stream-avatar.ts index 6d3da015d..2444741d5 100644 --- a/example/endpoints/stream-avatar.ts +++ b/example/endpoints/stream-avatar.ts @@ -2,7 +2,6 @@ import { z } from "zod/v4"; import { fileStreamingEndpointsFactory } from "../factories"; export const streamAvatarEndpoint = fileStreamingEndpointsFactory.build({ - method: ["get", "head"], shortDescription: "Streams a file content.", tag: ["users", "files"], input: z.object({ diff --git a/example/example.client.ts b/example/example.client.ts index b6bc4aa15..7dcb515a3 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -180,27 +180,6 @@ interface GetV1AvatarSendNegativeResponseVariants { 400: GetV1AvatarSendNegativeVariant1; } -/** head /v1/avatar/send */ -type HeadV1AvatarSendInput = { - userId: string; -}; - -/** head /v1/avatar/send */ -type HeadV1AvatarSendPositiveVariant1 = undefined; - -/** head /v1/avatar/send */ -interface HeadV1AvatarSendPositiveResponseVariants { - 200: HeadV1AvatarSendPositiveVariant1; -} - -/** head /v1/avatar/send */ -type HeadV1AvatarSendNegativeVariant1 = string; - -/** head /v1/avatar/send */ -interface HeadV1AvatarSendNegativeResponseVariants { - 400: HeadV1AvatarSendNegativeVariant1; -} - /** get /v1/avatar/stream */ type GetV1AvatarStreamInput = { userId: string; @@ -222,27 +201,6 @@ interface GetV1AvatarStreamNegativeResponseVariants { 400: GetV1AvatarStreamNegativeVariant1; } -/** head /v1/avatar/stream */ -type HeadV1AvatarStreamInput = { - userId: string; -}; - -/** head /v1/avatar/stream */ -type HeadV1AvatarStreamPositiveVariant1 = undefined; - -/** head /v1/avatar/stream */ -interface HeadV1AvatarStreamPositiveResponseVariants { - 200: HeadV1AvatarStreamPositiveVariant1; -} - -/** head /v1/avatar/stream */ -type HeadV1AvatarStreamNegativeVariant1 = string; - -/** head /v1/avatar/stream */ -interface HeadV1AvatarStreamNegativeResponseVariants { - 400: HeadV1AvatarStreamNegativeVariant1; -} - /** post /v1/avatar/upload */ type PostV1AvatarUploadInput = { avatar: any; @@ -380,7 +338,7 @@ export type Path = | "/v1/events/stream" | "/v1/forms/feedback"; -export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; +export type Method = "get" | "post" | "put" | "delete" | "patch"; export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; @@ -390,10 +348,7 @@ export interface Input { "get /v1/user/list": GetV1UserListInput; /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendInput; - /** @deprecated */ - "head /v1/avatar/send": HeadV1AvatarSendInput; "get /v1/avatar/stream": GetV1AvatarStreamInput; - "head /v1/avatar/stream": HeadV1AvatarStreamInput; "post /v1/avatar/upload": PostV1AvatarUploadInput; "post /v1/avatar/raw": PostV1AvatarRawInput; "get /v1/events/stream": GetV1EventsStreamInput; @@ -408,10 +363,7 @@ export interface PositiveResponse { "get /v1/user/list": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; - /** @deprecated */ - "head /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; - "head /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; "get /v1/events/stream": SomeOf; @@ -426,10 +378,7 @@ export interface NegativeResponse { "get /v1/user/list": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; - /** @deprecated */ - "head /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; - "head /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; "get /v1/events/stream": SomeOf; @@ -450,13 +399,8 @@ export interface EncodedResponse { /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendPositiveResponseVariants & GetV1AvatarSendNegativeResponseVariants; - /** @deprecated */ - "head /v1/avatar/send": HeadV1AvatarSendPositiveResponseVariants & - HeadV1AvatarSendNegativeResponseVariants; "get /v1/avatar/stream": GetV1AvatarStreamPositiveResponseVariants & GetV1AvatarStreamNegativeResponseVariants; - "head /v1/avatar/stream": HeadV1AvatarStreamPositiveResponseVariants & - HeadV1AvatarStreamNegativeResponseVariants; "post /v1/avatar/upload": PostV1AvatarUploadPositiveResponseVariants & PostV1AvatarUploadNegativeResponseVariants; "post /v1/avatar/raw": PostV1AvatarRawPositiveResponseVariants & @@ -487,16 +431,9 @@ export interface Response { "get /v1/avatar/send": | PositiveResponse["get /v1/avatar/send"] | NegativeResponse["get /v1/avatar/send"]; - /** @deprecated */ - "head /v1/avatar/send": - | PositiveResponse["head /v1/avatar/send"] - | NegativeResponse["head /v1/avatar/send"]; "get /v1/avatar/stream": | PositiveResponse["get /v1/avatar/stream"] | NegativeResponse["get /v1/avatar/stream"]; - "head /v1/avatar/stream": - | PositiveResponse["head /v1/avatar/stream"] - | NegativeResponse["head /v1/avatar/stream"]; "post /v1/avatar/upload": | PositiveResponse["post /v1/avatar/upload"] | NegativeResponse["post /v1/avatar/upload"]; @@ -520,9 +457,7 @@ export const endpointTags = { "post /v1/user/create": ["users"], "get /v1/user/list": ["users"], "get /v1/avatar/send": ["files", "users"], - "head /v1/avatar/send": ["files", "users"], "get /v1/avatar/stream": ["users", "files"], - "head /v1/avatar/stream": ["users", "files"], "post /v1/avatar/upload": ["files"], "post /v1/avatar/raw": ["files"], "get /v1/events/stream": ["subscriptions"], diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index b6dddd2c5..b491f1950 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -430,30 +430,6 @@ paths: text/plain: schema: type: string - head: - operationId: HeadV1AvatarSend - summary: Sends a file content. - deprecated: true - tags: - - files - - users - parameters: - - name: userId - in: query - required: true - description: HEAD /v1/avatar/send Parameter - schema: - type: string - pattern: \d+ - responses: - "200": - description: HEAD /v1/avatar/send Positive response - "400": - description: HEAD /v1/avatar/send Negative response - content: - text/plain: - schema: - type: string /v1/avatar/stream: get: operationId: GetV1AvatarStream @@ -484,29 +460,6 @@ paths: text/plain: schema: type: string - head: - operationId: HeadV1AvatarStream - summary: Streams a file content. - tags: - - users - - files - parameters: - - name: userId - in: query - required: true - description: HEAD /v1/avatar/stream Parameter - schema: - type: string - pattern: \d+ - responses: - "200": - description: HEAD /v1/avatar/stream Positive response - "400": - description: HEAD /v1/avatar/stream Negative response - content: - text/plain: - schema: - type: string /v1/avatar/upload: post: operationId: PostV1AvatarUpload diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index e0c45c9da..541aedca4 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -37,7 +37,6 @@ const areFilesAvailable = (request: Request): boolean => { export const defaultInputSources: InputSources = { get: ["query", "params"], - head: ["query", "params"], post: ["body", "params", "files"], put: ["body", "params"], patch: ["body", "params"], @@ -54,6 +53,8 @@ export const getInput = ( ): FlatObject => { const method = getActualMethod(req); if (method === "options") return {}; + if (method === "head") + return getInput(R.assoc("method", "GET", R.clone(req)), userDefined); return ( userDefined[method] || defaultInputSources[method] || diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index e411405c8..822e285c8 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -198,8 +198,7 @@ export class Documentation extends OpenApiBuilder { ...commons, variant, schema, - mimeTypes: - method === "head" && variant === "positive" ? null : mimeTypes, + mimeTypes, statusCode, hasMultipleStatusCodes: apiResponses.length > 1 || statusCodes.length > 1, diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 4b9e39ac0..311c6c639 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -106,12 +106,9 @@ export class Integration extends IntegrationBase { (agg, responseVariant) => { const responses = endpoint.getResponses(responseVariant); const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { - const hasContent = - mimeTypes && - !(method === "head" && responseVariant === "positive"); const variantType = makeType( entitle(responseVariant, "variant", `${idx + 1}`), - zodToTs(hasContent ? schema : noContent, ctxOut), + zodToTs(mimeTypes ? schema : noContent, ctxOut), { comment: request }, ); this.#program.push(variantType); diff --git a/express-zod-api/src/method.ts b/express-zod-api/src/method.ts index e37ee95ab..4b978f559 100644 --- a/express-zod-api/src/method.ts +++ b/express-zod-api/src/method.ts @@ -6,12 +6,11 @@ export const methods = [ "put", "delete", "patch", - "head", ] satisfies Array; export type Method = (typeof methods)[number]; -export type AuxMethod = Extract; +export type AuxMethod = Extract; export const isMethod = (subject: string): subject is Method => (methods as string[]).includes(subject); diff --git a/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap index f96ccfbd7..30ee82c2c 100644 --- a/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap @@ -10,10 +10,6 @@ exports[`Common Helpers > defaultInputSources > should be declared in a certain "query", "params", ], - "head": [ - "query", - "params", - ], "patch": [ "body", "params", diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index e1b189ffa..38e79e133 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -37,7 +37,7 @@ interface PostV1CustomNegativeResponseVariants { export type Path = "/v1/custom"; -export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; +export type Method = "get" | "post" | "put" | "delete" | "patch"; export interface Input { "post /v1/custom": PostV1CustomInput; @@ -110,7 +110,7 @@ interface PostV1MtplNegativeResponseVariants { export type Path = "/v1/mtpl"; -export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; +export type Method = "get" | "post" | "put" | "delete" | "patch"; export interface Input { "post /v1/mtpl": PostV1MtplInput; @@ -178,7 +178,7 @@ interface PostV1TestNegativeResponseVariants { export type Path = "/v1/test"; -export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; +export type Method = "get" | "post" | "put" | "delete" | "patch"; export interface Input { /** @deprecated */ @@ -247,7 +247,7 @@ interface PostV1TestNegativeResponseVariants { export type Path = "/v1/test"; -export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; +export type Method = "get" | "post" | "put" | "delete" | "patch"; export interface Input { /** @deprecated */ @@ -313,7 +313,7 @@ interface PostV1TestWithDashesNegativeResponseVariants { export type Path = "/v1/test-with-dashes"; -export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; +export type Method = "get" | "post" | "put" | "delete" | "patch"; export interface Input { "post /v1/test-with-dashes": PostV1TestWithDashesInput; diff --git a/express-zod-api/tests/method.spec.ts b/express-zod-api/tests/method.spec.ts index 1f46ffe3e..ce117b5b1 100644 --- a/express-zod-api/tests/method.spec.ts +++ b/express-zod-api/tests/method.spec.ts @@ -4,14 +4,7 @@ import { isMethod, methods, Method, AuxMethod } from "../src/method"; describe("Method", () => { describe("methods array", () => { test("should be the list of selected keys of express router", () => { - expect(methods).toEqual([ - "get", - "post", - "put", - "delete", - "patch", - "head", - ]); + expect(methods).toEqual(["get", "post", "put", "delete", "patch"]); }); }); @@ -22,14 +15,15 @@ describe("Method", () => { expectTypeOf<"put">().toExtend(); expectTypeOf<"delete">().toExtend(); expectTypeOf<"patch">().toExtend(); - expectTypeOf<"head">().toExtend(); expectTypeOf<"wrong">().not.toExtend(); }); }); describe("AuxMethod", () => { - test("should be options", () => { - expectTypeOf().toEqualTypeOf("options" as const); + test("should be options or head", () => { + expectTypeOf<"options">().toExtend(); + expectTypeOf<"head">().toExtend(); + expectTypeOf<"other">().not.toExtend(); }); }); From 86b253b738ae2f1fe2ed76109f815b99b5a682a5 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 14 Jul 2025 12:55:47 +0200 Subject: [PATCH 05/27] Add HEAD method to CORS when GET is there. --- express-zod-api/src/routing.ts | 4 +++- express-zod-api/tests/routing.spec.ts | 4 ++-- express-zod-api/tests/system.spec.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/routing.ts b/express-zod-api/src/routing.ts index 9f90800f6..997370b68 100644 --- a/express-zod-api/src/routing.ts +++ b/express-zod-api/src/routing.ts @@ -25,7 +25,7 @@ export interface Routing { export type Parsers = Partial>; const lineUp = (methods: Array) => - methods // options is last, fine to sort in-place + methods // options is last, fine to sort in-place // @todo sort HEAD too .sort((a, b) => +(a === "options") - +(b === "options")) .join(", ") .toUpperCase(); @@ -81,6 +81,8 @@ export const initRouting = ({ const deprioritized = new Map(); for (const [path, methods] of familiar) { const accessMethods = Array.from(methods.keys()); + /** @link https://github.com/RobinTail/express-zod-api/discussions/2791#discussioncomment-13745912 */ + if (accessMethods.includes("get")) accessMethods.push("head"); for (const [method, [matchingParsers, endpoint]] of methods) { const handlers = matchingParsers .slice() // must be immutable diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 5cf985069..2bb6489c3 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -232,7 +232,7 @@ describe("Routing", () => { expect(responseMock._getStatusCode()).toBe(200); expect(responseMock._getHeaders()).toEqual({ "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, PUT, PATCH, OPTIONS", + "access-control-allow-methods": "GET, POST, PUT, PATCH, HEAD, OPTIONS", "access-control-allow-headers": "content-type", "x-custom-header": "Testing", }); @@ -362,7 +362,7 @@ describe("Routing", () => { expect(responseMock._getStatusCode()).toBe(200); expect(responseMock._getHeaders()).toEqual({ "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-methods": "GET, POST, HEAD, OPTIONS", "access-control-allow-headers": "content-type", }); }); diff --git a/express-zod-api/tests/system.spec.ts b/express-zod-api/tests/system.spec.ts index 57a7dcf31..32eed48c1 100644 --- a/express-zod-api/tests/system.spec.ts +++ b/express-zod-api/tests/system.spec.ts @@ -437,7 +437,7 @@ describe("App in production mode", async () => { }), }); expect(response.status).toBe(405); - expect(response.headers.get("Allow")).toBe("GET, POST"); + expect(response.headers.get("Allow")).toBe("GET, POST, HEAD"); const json = await response.json(); expect(json).toMatchSnapshot(); }); From ec05db7a371ce174c896725768b411b78551e0d3 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 14 Jul 2025 13:55:38 +0200 Subject: [PATCH 06/27] Fix runtime error on accessing eTag caused by R.clone(). --- express-zod-api/src/common-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 541aedca4..a178ea1d7 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -54,7 +54,7 @@ export const getInput = ( const method = getActualMethod(req); if (method === "options") return {}; if (method === "head") - return getInput(R.assoc("method", "GET", R.clone(req)), userDefined); + return getInput(Object.assign({}, req, { method: "GET" }), userDefined); return ( userDefined[method] || defaultInputSources[method] || From 1fc8cd371d3e92fa71cd41f1502049af59f24dab Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 14 Jul 2025 18:36:25 +0200 Subject: [PATCH 07/27] REF: avoid cloning request. --- express-zod-api/src/common-helpers.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index a178ea1d7..240fb1de9 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -51,10 +51,9 @@ export const getInput = ( req: Request, userDefined: CommonConfig["inputSources"] = {}, ): FlatObject => { - const method = getActualMethod(req); - if (method === "options") return {}; - if (method === "head") - return getInput(Object.assign({}, req, { method: "GET" }), userDefined); + const actualMethod = getActualMethod(req); + if (actualMethod === "options") return {}; + const method = actualMethod === "head" ? "get" : actualMethod; return ( userDefined[method] || defaultInputSources[method] || From b04c0df5335e464e5ed519935d3fdc518e6281d7 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 14 Jul 2025 19:25:28 +0200 Subject: [PATCH 08/27] Test for HEAD and res.send(). --- example/index.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/example/index.spec.ts b/example/index.spec.ts index 75edfb340..ef2fa0eda 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -170,6 +170,20 @@ describe("Example", async () => { expect(hash).toMatchSnapshot(); }); + test("Should inform on content length for sendable image", async () => { + const response = await fetch( + `http://localhost:${port}/v1/avatar/send?userId=123`, + { method: "HEAD" }, + ); + expect(response.status).toBe(200); + expect(response.headers.has("Content-type")).toBeTruthy(); + expect(response.headers.get("Content-type")).toBe( + "image/svg+xml; charset=utf-8", + ); + expect(response.headers.has("Content-Length")).toBeTruthy(); + expect(response.headers.get("Content-Length")).toBe("48687"); + }); + test("Should stream an image with a correct header", async () => { const response = await fetch( `http://localhost:${port}/v1/avatar/stream?userId=123`, From fc8a2aa360e7f6c8b2c1a3946fb209000bf47f2b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 14 Jul 2025 22:43:12 +0200 Subject: [PATCH 09/27] Adjusting example to ensure content-length for HEAD of streaming. --- example/factories.ts | 13 ++++++++----- example/index.spec.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/example/factories.ts b/example/factories.ts index 5f7eb2e43..3846f82c4 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -8,7 +8,7 @@ import { defaultEndpointsFactory, } from "express-zod-api"; import { authMiddleware } from "./middlewares"; -import { createReadStream } from "node:fs"; +import { createReadStream, statSync } from "node:fs"; import { z } from "zod/v4"; /** @desc This factory extends the default one by enforcing the authentication using the specified middleware */ @@ -34,12 +34,15 @@ export const fileStreamingEndpointsFactory = new EndpointsFactory( new ResultHandler({ positive: { schema: ez.buffer(), mimeType: "image/*" }, negative: { schema: z.string(), mimeType: "text/plain" }, - handler: ({ response, error, output }) => { + handler: ({ response, error, output, request: { method } }) => { if (error) return void response.status(400).send(error.message); if ("filename" in output && typeof output.filename === "string") { - createReadStream(output.filename).pipe( - response.attachment(output.filename), - ); + const target = response.attachment(output.filename); + if (method === "HEAD") { + const { size } = statSync(output.filename); + return void target.set({ "Content-Length": `${size}` }).end(); + } + createReadStream(output.filename).pipe(target); } else { response.status(400).send("Filename is missing"); } diff --git a/example/index.spec.ts b/example/index.spec.ts index ef2fa0eda..746ce26bb 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -208,6 +208,21 @@ describe("Example", async () => { expect(hash).toMatchSnapshot(); }); + test("Should inform on content length for streaming image", async () => { + const response = await fetch( + `http://localhost:${port}/v1/avatar/stream?userId=123`, + { method: "HEAD" }, + ); + expect(response.status).toBe(200); + expect(response.headers.has("Content-type")).toBeTruthy(); + expect(response.headers.get("Content-type")).toBe("image/svg+xml"); + expect(response.headers.get("Content-Disposition")).toBe( + `attachment; filename="logo.svg"`, + ); + expect(response.headers.has("Content-Length")).toBeTruthy(); + expect(response.headers.get("Content-Length")).toBe("48687"); + }); + test("Should serve static files", async () => { const response = await fetch(`http://localhost:${port}/public/logo.svg`); expect(response.status).toBe(200); From b205b7d4f01b89bda6d9618f2aff731ce6f1dd92 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 14 Jul 2025 22:45:54 +0200 Subject: [PATCH 10/27] minor: shortening. --- example/factories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/factories.ts b/example/factories.ts index 3846f82c4..264e4a477 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -40,7 +40,7 @@ export const fileStreamingEndpointsFactory = new EndpointsFactory( const target = response.attachment(output.filename); if (method === "HEAD") { const { size } = statSync(output.filename); - return void target.set({ "Content-Length": `${size}` }).end(); + return void target.set("Content-Length", `${size}`).end(); } createReadStream(output.filename).pipe(target); } else { From b0551caaf2613f49cd392c58a68db22cca80a2ee Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 14 Jul 2025 23:12:14 +0200 Subject: [PATCH 11/27] Ref: improving CORS method sorting. --- express-zod-api/src/routing.ts | 6 +++--- express-zod-api/tests/routing.spec.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/routing.ts b/express-zod-api/src/routing.ts index 997370b68..dbea8ae59 100644 --- a/express-zod-api/src/routing.ts +++ b/express-zod-api/src/routing.ts @@ -6,7 +6,7 @@ import { ContentType } from "./content-type"; import { DependsOnMethod } from "./depends-on-method"; import { Diagnostics } from "./diagnostics"; import { AbstractEndpoint } from "./endpoint"; -import { AuxMethod, Method } from "./method"; +import { AuxMethod, isMethod, Method } from "./method"; import { OnEndpoint, walkRouting } from "./routing-walker"; import { ServeStatic } from "./serve-static"; import { GetLogger } from "./server-helpers"; @@ -25,8 +25,8 @@ export interface Routing { export type Parsers = Partial>; const lineUp = (methods: Array) => - methods // options is last, fine to sort in-place // @todo sort HEAD too - .sort((a, b) => +(a === "options") - +(b === "options")) + methods // auxiliary methods go last + .sort((a, b) => +isMethod(b) - +isMethod(a) || a.localeCompare(b)) .join(", ") .toUpperCase(); diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 2bb6489c3..c6f41c91f 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -232,7 +232,7 @@ describe("Routing", () => { expect(responseMock._getStatusCode()).toBe(200); expect(responseMock._getHeaders()).toEqual({ "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, PUT, PATCH, HEAD, OPTIONS", + "access-control-allow-methods": "GET, PATCH, POST, PUT, HEAD, OPTIONS", "access-control-allow-headers": "content-type", "x-custom-header": "Testing", }); From e74a0bfd4e00e37a9702a327ed20cd172c560dd3 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 14 Jul 2025 23:17:10 +0200 Subject: [PATCH 12/27] REF: async async stats with async RH for streaming. --- example/factories.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/example/factories.ts b/example/factories.ts index 264e4a477..4999cbe8e 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -8,7 +8,8 @@ import { defaultEndpointsFactory, } from "express-zod-api"; import { authMiddleware } from "./middlewares"; -import { createReadStream, statSync } from "node:fs"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; import { z } from "zod/v4"; /** @desc This factory extends the default one by enforcing the authentication using the specified middleware */ @@ -34,12 +35,12 @@ export const fileStreamingEndpointsFactory = new EndpointsFactory( new ResultHandler({ positive: { schema: ez.buffer(), mimeType: "image/*" }, negative: { schema: z.string(), mimeType: "text/plain" }, - handler: ({ response, error, output, request: { method } }) => { + handler: async ({ response, error, output, request: { method } }) => { if (error) return void response.status(400).send(error.message); if ("filename" in output && typeof output.filename === "string") { const target = response.attachment(output.filename); if (method === "HEAD") { - const { size } = statSync(output.filename); + const { size } = await stat(output.filename); return void target.set("Content-Length", `${size}`).end(); } createReadStream(output.filename).pipe(target); From 00ca5c0e5e4705c0888958dc45cedb89faa504df Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 14:06:35 +0200 Subject: [PATCH 13/27] FEAT: supporting HEAD method by Integration and Documentation. --- example/example.client.ts | 164 ++- example/example.documentation.yaml | 132 +++ express-zod-api/src/common-helpers.ts | 19 +- express-zod-api/src/documentation-helpers.ts | 6 +- express-zod-api/src/documentation.ts | 33 +- express-zod-api/src/endpoint.ts | 8 +- express-zod-api/src/endpoints-factory.ts | 9 +- express-zod-api/src/integration-base.ts | 15 +- express-zod-api/src/integration.ts | 23 +- express-zod-api/src/method.ts | 6 + express-zod-api/src/routing-walker.ts | 1 + .../__snapshots__/documentation.spec.ts.snap | 1006 ++++++++++++++++- .../__snapshots__/integration.spec.ts.snap | 12 +- 13 files changed, 1351 insertions(+), 83 deletions(-) diff --git a/example/example.client.ts b/example/example.client.ts index 7dcb515a3..819cd65a0 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -39,6 +39,33 @@ interface GetV1UserRetrieveNegativeResponseVariants { 400: GetV1UserRetrieveNegativeVariant1; } +/** head /v1/user/retrieve */ +type HeadV1UserRetrieveInput = { + /** a numeric string containing the id of the user */ + id: string; +}; + +/** head /v1/user/retrieve */ +type HeadV1UserRetrievePositiveVariant1 = undefined; + +/** head /v1/user/retrieve */ +interface HeadV1UserRetrievePositiveResponseVariants { + 200: HeadV1UserRetrievePositiveVariant1; +} + +/** head /v1/user/retrieve */ +type HeadV1UserRetrieveNegativeVariant1 = { + status: "error"; + error: { + message: string; + }; +}; + +/** head /v1/user/retrieve */ +interface HeadV1UserRetrieveNegativeResponseVariants { + 400: HeadV1UserRetrieveNegativeVariant1; +} + /** delete /v1/user/:id/remove */ type DeleteV1UserIdRemoveInput = { /** numeric string */ @@ -159,6 +186,25 @@ interface GetV1UserListNegativeResponseVariants { 400: GetV1UserListNegativeVariant1; } +/** head /v1/user/list */ +type HeadV1UserListInput = {}; + +/** head /v1/user/list */ +type HeadV1UserListPositiveVariant1 = undefined; + +/** head /v1/user/list */ +interface HeadV1UserListPositiveResponseVariants { + 200: HeadV1UserListPositiveVariant1; +} + +/** head /v1/user/list */ +type HeadV1UserListNegativeVariant1 = string; + +/** head /v1/user/list */ +interface HeadV1UserListNegativeResponseVariants { + 400: HeadV1UserListNegativeVariant1; +} + /** get /v1/avatar/send */ type GetV1AvatarSendInput = { userId: string; @@ -180,6 +226,27 @@ interface GetV1AvatarSendNegativeResponseVariants { 400: GetV1AvatarSendNegativeVariant1; } +/** head /v1/avatar/send */ +type HeadV1AvatarSendInput = { + userId: string; +}; + +/** head /v1/avatar/send */ +type HeadV1AvatarSendPositiveVariant1 = undefined; + +/** head /v1/avatar/send */ +interface HeadV1AvatarSendPositiveResponseVariants { + 200: HeadV1AvatarSendPositiveVariant1; +} + +/** head /v1/avatar/send */ +type HeadV1AvatarSendNegativeVariant1 = string; + +/** head /v1/avatar/send */ +interface HeadV1AvatarSendNegativeResponseVariants { + 400: HeadV1AvatarSendNegativeVariant1; +} + /** get /v1/avatar/stream */ type GetV1AvatarStreamInput = { userId: string; @@ -201,6 +268,27 @@ interface GetV1AvatarStreamNegativeResponseVariants { 400: GetV1AvatarStreamNegativeVariant1; } +/** head /v1/avatar/stream */ +type HeadV1AvatarStreamInput = { + userId: string; +}; + +/** head /v1/avatar/stream */ +type HeadV1AvatarStreamPositiveVariant1 = undefined; + +/** head /v1/avatar/stream */ +interface HeadV1AvatarStreamPositiveResponseVariants { + 200: HeadV1AvatarStreamPositiveVariant1; +} + +/** head /v1/avatar/stream */ +type HeadV1AvatarStreamNegativeVariant1 = string; + +/** head /v1/avatar/stream */ +interface HeadV1AvatarStreamNegativeResponseVariants { + 400: HeadV1AvatarStreamNegativeVariant1; +} + /** post /v1/avatar/upload */ type PostV1AvatarUploadInput = { avatar: any; @@ -292,6 +380,28 @@ interface GetV1EventsStreamNegativeResponseVariants { 400: GetV1EventsStreamNegativeVariant1; } +/** head /v1/events/stream */ +type HeadV1EventsStreamInput = { + /** @deprecated for testing error response */ + trigger?: string | undefined; +}; + +/** head /v1/events/stream */ +type HeadV1EventsStreamPositiveVariant1 = undefined; + +/** head /v1/events/stream */ +interface HeadV1EventsStreamPositiveResponseVariants { + 200: HeadV1EventsStreamPositiveVariant1; +} + +/** head /v1/events/stream */ +type HeadV1EventsStreamNegativeVariant1 = string; + +/** head /v1/events/stream */ +interface HeadV1EventsStreamNegativeResponseVariants { + 400: HeadV1EventsStreamNegativeVariant1; +} + /** post /v1/forms/feedback */ type PostV1FormsFeedbackInput = { name: string; @@ -338,56 +448,76 @@ export type Path = | "/v1/events/stream" | "/v1/forms/feedback"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; + "head /v1/user/retrieve": HeadV1UserRetrieveInput; "delete /v1/user/:id/remove": DeleteV1UserIdRemoveInput; "patch /v1/user/:id": PatchV1UserIdInput; "post /v1/user/create": PostV1UserCreateInput; "get /v1/user/list": GetV1UserListInput; + "head /v1/user/list": HeadV1UserListInput; /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendInput; + /** @deprecated */ + "head /v1/avatar/send": HeadV1AvatarSendInput; "get /v1/avatar/stream": GetV1AvatarStreamInput; + "head /v1/avatar/stream": HeadV1AvatarStreamInput; "post /v1/avatar/upload": PostV1AvatarUploadInput; "post /v1/avatar/raw": PostV1AvatarRawInput; "get /v1/events/stream": GetV1EventsStreamInput; + "head /v1/events/stream": HeadV1EventsStreamInput; "post /v1/forms/feedback": PostV1FormsFeedbackInput; } export interface PositiveResponse { "get /v1/user/retrieve": SomeOf; + "head /v1/user/retrieve": SomeOf; "delete /v1/user/:id/remove": SomeOf; "patch /v1/user/:id": SomeOf; "post /v1/user/create": SomeOf; "get /v1/user/list": SomeOf; + "head /v1/user/list": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; + /** @deprecated */ + "head /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; + "head /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; "get /v1/events/stream": SomeOf; + "head /v1/events/stream": SomeOf; "post /v1/forms/feedback": SomeOf; } export interface NegativeResponse { "get /v1/user/retrieve": SomeOf; + "head /v1/user/retrieve": SomeOf; "delete /v1/user/:id/remove": SomeOf; "patch /v1/user/:id": SomeOf; "post /v1/user/create": SomeOf; "get /v1/user/list": SomeOf; + "head /v1/user/list": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; + /** @deprecated */ + "head /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; + "head /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; "get /v1/events/stream": SomeOf; + "head /v1/events/stream": SomeOf; "post /v1/forms/feedback": SomeOf; } export interface EncodedResponse { "get /v1/user/retrieve": GetV1UserRetrievePositiveResponseVariants & GetV1UserRetrieveNegativeResponseVariants; + "head /v1/user/retrieve": HeadV1UserRetrievePositiveResponseVariants & + HeadV1UserRetrieveNegativeResponseVariants; "delete /v1/user/:id/remove": DeleteV1UserIdRemovePositiveResponseVariants & DeleteV1UserIdRemoveNegativeResponseVariants; "patch /v1/user/:id": PatchV1UserIdPositiveResponseVariants & @@ -396,17 +526,26 @@ export interface EncodedResponse { PostV1UserCreateNegativeResponseVariants; "get /v1/user/list": GetV1UserListPositiveResponseVariants & GetV1UserListNegativeResponseVariants; + "head /v1/user/list": HeadV1UserListPositiveResponseVariants & + HeadV1UserListNegativeResponseVariants; /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendPositiveResponseVariants & GetV1AvatarSendNegativeResponseVariants; + /** @deprecated */ + "head /v1/avatar/send": HeadV1AvatarSendPositiveResponseVariants & + HeadV1AvatarSendNegativeResponseVariants; "get /v1/avatar/stream": GetV1AvatarStreamPositiveResponseVariants & GetV1AvatarStreamNegativeResponseVariants; + "head /v1/avatar/stream": HeadV1AvatarStreamPositiveResponseVariants & + HeadV1AvatarStreamNegativeResponseVariants; "post /v1/avatar/upload": PostV1AvatarUploadPositiveResponseVariants & PostV1AvatarUploadNegativeResponseVariants; "post /v1/avatar/raw": PostV1AvatarRawPositiveResponseVariants & PostV1AvatarRawNegativeResponseVariants; "get /v1/events/stream": GetV1EventsStreamPositiveResponseVariants & GetV1EventsStreamNegativeResponseVariants; + "head /v1/events/stream": HeadV1EventsStreamPositiveResponseVariants & + HeadV1EventsStreamNegativeResponseVariants; "post /v1/forms/feedback": PostV1FormsFeedbackPositiveResponseVariants & PostV1FormsFeedbackNegativeResponseVariants; } @@ -415,6 +554,9 @@ export interface Response { "get /v1/user/retrieve": | PositiveResponse["get /v1/user/retrieve"] | NegativeResponse["get /v1/user/retrieve"]; + "head /v1/user/retrieve": + | PositiveResponse["head /v1/user/retrieve"] + | NegativeResponse["head /v1/user/retrieve"]; "delete /v1/user/:id/remove": | PositiveResponse["delete /v1/user/:id/remove"] | NegativeResponse["delete /v1/user/:id/remove"]; @@ -427,13 +569,23 @@ export interface Response { "get /v1/user/list": | PositiveResponse["get /v1/user/list"] | NegativeResponse["get /v1/user/list"]; + "head /v1/user/list": + | PositiveResponse["head /v1/user/list"] + | NegativeResponse["head /v1/user/list"]; /** @deprecated */ "get /v1/avatar/send": | PositiveResponse["get /v1/avatar/send"] | NegativeResponse["get /v1/avatar/send"]; + /** @deprecated */ + "head /v1/avatar/send": + | PositiveResponse["head /v1/avatar/send"] + | NegativeResponse["head /v1/avatar/send"]; "get /v1/avatar/stream": | PositiveResponse["get /v1/avatar/stream"] | NegativeResponse["get /v1/avatar/stream"]; + "head /v1/avatar/stream": + | PositiveResponse["head /v1/avatar/stream"] + | NegativeResponse["head /v1/avatar/stream"]; "post /v1/avatar/upload": | PositiveResponse["post /v1/avatar/upload"] | NegativeResponse["post /v1/avatar/upload"]; @@ -443,6 +595,9 @@ export interface Response { "get /v1/events/stream": | PositiveResponse["get /v1/events/stream"] | NegativeResponse["get /v1/events/stream"]; + "head /v1/events/stream": + | PositiveResponse["head /v1/events/stream"] + | NegativeResponse["head /v1/events/stream"]; "post /v1/forms/feedback": | PositiveResponse["post /v1/forms/feedback"] | NegativeResponse["post /v1/forms/feedback"]; @@ -452,15 +607,20 @@ export type Request = keyof Input; export const endpointTags = { "get /v1/user/retrieve": ["users"], + "head /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"], + "head /v1/user/list": ["users"], "get /v1/avatar/send": ["files", "users"], + "head /v1/avatar/send": ["files", "users"], "get /v1/avatar/stream": ["users", "files"], + "head /v1/avatar/stream": ["users", "files"], "post /v1/avatar/upload": ["files"], "post /v1/avatar/raw": ["files"], "get /v1/events/stream": ["subscriptions"], + "head /v1/events/stream": ["subscriptions"], "post /v1/forms/feedback": ["forms"], }; @@ -486,7 +646,7 @@ export type Implementation = ( ) => Promise; const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); + const hasBody = !["get", "head", "delete"].includes(method); const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`; const response = await fetch( new URL(`${path}${searchParams}`, "http://localhost:8090"), diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index b491f1950..10b724341 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -80,6 +80,52 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1UserRetrieve + summary: Retrieves the user. + description: Example user retrieval endpoint. + tags: + - users + parameters: + - name: id + in: query + required: true + description: a numeric string containing the id of the user + schema: + description: a numeric string containing the id of the user + type: string + pattern: \d+ + responses: + "200": + description: HEAD /v1/user/retrieve Positive response + "400": + description: HEAD /v1/user/retrieve Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message /v1/user/{id}/remove: delete: operationId: DeleteV1UserIdRemove @@ -401,6 +447,22 @@ paths: examples: example1: value: Sample error message + head: + operationId: HeadV1UserList + tags: + - users + responses: + "200": + description: HEAD /v1/user/list Positive response + "400": + description: HEAD /v1/user/list Negative response + content: + application/json: + schema: + type: string + examples: + example1: + value: Sample error message /v1/avatar/send: get: operationId: GetV1AvatarSend @@ -430,6 +492,30 @@ paths: text/plain: schema: type: string + head: + operationId: HeadV1AvatarSend + summary: Sends a file content. + deprecated: true + tags: + - files + - users + parameters: + - name: userId + in: query + required: true + description: HEAD /v1/avatar/send Parameter + schema: + type: string + pattern: \d+ + responses: + "200": + description: HEAD /v1/avatar/send Positive response + "400": + description: HEAD /v1/avatar/send Negative response + content: + text/plain: + schema: + type: string /v1/avatar/stream: get: operationId: GetV1AvatarStream @@ -460,6 +546,29 @@ paths: text/plain: schema: type: string + head: + operationId: HeadV1AvatarStream + summary: Streams a file content. + tags: + - users + - files + parameters: + - name: userId + in: query + required: true + description: HEAD /v1/avatar/stream Parameter + schema: + type: string + pattern: \d+ + responses: + "200": + description: HEAD /v1/avatar/stream Positive response + "400": + description: HEAD /v1/avatar/stream Negative response + content: + text/plain: + schema: + type: string /v1/avatar/upload: post: operationId: PostV1AvatarUpload @@ -662,6 +771,29 @@ paths: text/plain: schema: type: string + head: + operationId: HeadV1EventsStream + tags: + - subscriptions + parameters: + - name: trigger + in: query + deprecated: true + required: false + description: for testing error response + schema: + deprecated: true + description: for testing error response + type: string + responses: + "200": + description: HEAD /v1/events/stream Positive response + "400": + description: HEAD /v1/events/stream Negative response + content: + text/plain: + schema: + type: string /v1/forms/feedback: post: operationId: PostV1FormsFeedback diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 240fb1de9..039126dd9 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -47,18 +47,23 @@ const fallbackInputSource: InputSource[] = ["body", "query", "params"]; export const getActualMethod = (request: Request) => request.method.toLowerCase() as Method | AuxMethod; +export const getInputSources = ( + actualMethod: ReturnType, + userDefined: CommonConfig["inputSources"] = {}, +) => { + if (actualMethod === "options") return []; + const method = actualMethod === "head" ? "get" : actualMethod; + return ( + userDefined[method] || defaultInputSources[method] || fallbackInputSource + ); +}; + export const getInput = ( req: Request, userDefined: CommonConfig["inputSources"] = {}, ): FlatObject => { const actualMethod = getActualMethod(req); - if (actualMethod === "options") return {}; - const method = actualMethod === "head" ? "get" : actualMethod; - return ( - userDefined[method] || - defaultInputSources[method] || - fallbackInputSource - ) + return getInputSources(actualMethod, userDefined) .filter((src) => (src === "files" ? areFilesAvailable(req) : true)) .reduce((agg, src) => Object.assign(agg, req[src]), {}); }; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 506058491..f3ce4cf06 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -47,7 +47,7 @@ import { IOSchema } from "./io-schema"; import { flattenIO } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; import { getBrand } from "./metadata"; -import { Method } from "./method"; +import { ClientMethod } from "./method"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand } from "./raw-schema"; import { FirstPartyKind } from "./schema-walker"; @@ -62,7 +62,7 @@ interface ReqResCommons { name?: string, ) => ReferenceObject; path: string; - method: Method; + method: ClientMethod; } export interface OpenAPIContext extends ReqResCommons { @@ -77,7 +77,7 @@ export type Depicter = ( /** @desc Using defaultIsHeader when returns null or undefined */ export type IsHeader = ( name: string, - method: Method, + method: ClientMethod, path: string, ) => boolean | null | undefined; diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 822e285c8..10708372b 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -11,10 +11,10 @@ import * as R from "ramda"; import { responseVariants } from "./api-response"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; -import { defaultInputSources, makeCleanId } from "./common-helpers"; +import { getInputSources, makeCleanId } from "./common-helpers"; import { CommonConfig } from "./config-type"; import { processContainers } from "./logical-container"; -import { Method } from "./method"; +import { ClientMethod } from "./method"; import { depictBody, depictRequestParams, @@ -30,7 +30,8 @@ import { depictRequest, } from "./documentation-helpers"; import { Routing } from "./routing"; -import { OnEndpoint, walkRouting } from "./routing-walker"; +import { walkRouting } from "./routing-walker"; +import { AbstractEndpoint } from "./endpoint"; type Component = | "positiveResponse" @@ -101,7 +102,11 @@ export class Documentation extends OpenApiBuilder { return { $ref: `#/components/schemas/${name}` }; } - #ensureUniqOperationId(path: string, method: Method, userDefined?: string) { + #ensureUniqOperationId( + path: string, + method: ClientMethod, + userDefined?: string, + ) { const operationId = userDefined || makeCleanId(method, path); let lastSuffix = this.#lastOperationIdSuffixes.get(operationId); if (lastSuffix === undefined) { @@ -151,7 +156,11 @@ export class Documentation extends OpenApiBuilder { this.addInfo({ title, version }); for (const url of typeof serverUrl === "string" ? [serverUrl] : serverUrl) this.addServer({ url }); - const onEndpoint: OnEndpoint = (endpoint, path, method) => { + const onEndpoint = ( + endpoint: AbstractEndpoint, + path: string, + method: ClientMethod, + ) => { const commons = { path, method, @@ -166,8 +175,7 @@ export class Documentation extends OpenApiBuilder { : hasSummaryFromDescription && description ? ensureShortDescription(description) : undefined; - const inputSources = - config.inputSources?.[method] || defaultInputSources[method]; + const inputSources = getInputSources(method, config.inputSources); const operationId = this.#ensureUniqOperationId( path, method, @@ -198,7 +206,8 @@ export class Documentation extends OpenApiBuilder { ...commons, variant, schema, - mimeTypes, + mimeTypes: + method === "head" && variant === "positive" ? null : mimeTypes, statusCode, hasMultipleStatusCodes: apiResponses.length > 1 || statusCodes.length > 1, @@ -251,7 +260,13 @@ export class Documentation extends OpenApiBuilder { }; this.addPath(reformatParamsInPath(path), { [method]: operation }); }; - walkRouting({ routing, onEndpoint }); + walkRouting({ + routing, + onEndpoint: (endpoint, path, method) => { + onEndpoint(endpoint, path, method); + if (method === "get") onEndpoint(endpoint, path, "head"); + }, + }); if (tags) this.rootDoc.tags = depictTags(tags); } } diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index 5504e3449..8e0475590 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -23,7 +23,7 @@ import { lastResortHandler } from "./last-resort"; import { ActualLogger } from "./logger-helpers"; import { LogicalContainer } from "./logical-container"; import { getBrand, getExamples } from "./metadata"; -import { AuxMethod, Method } from "./method"; +import { AuxMethod, ClientMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import { ContentType } from "./content-type"; import { ezRawBrand } from "./raw-schema"; @@ -54,7 +54,7 @@ export abstract class AbstractEndpoint extends Routable { variant: ResponseVariant, ): ReadonlyArray; /** @internal */ - public abstract getOperationId(method: Method): string | undefined; + public abstract getOperationId(method: ClientMethod): string | undefined; /** @internal */ public abstract get description(): string | undefined; /** @internal */ @@ -105,7 +105,7 @@ export class Endpoint< resultHandler: AbstractResultHandler; description?: string; shortDescription?: string; - getOperationId?: (method: Method) => string | undefined; + getOperationId?: (method: ClientMethod) => string | undefined; methods?: Method[]; scopes?: string[]; tags?: string[]; @@ -194,7 +194,7 @@ export class Endpoint< } /** @internal */ - public override getOperationId(method: Method): string | undefined { + public override getOperationId(method: ClientMethod): string | undefined { return this.#def.getOperationId?.(method); } diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index 00751505b..7cfbfc82b 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -7,7 +7,7 @@ import { getFinalEndpointInputSchema, ConditionalIntersection, } from "./io-schema"; -import { Method } from "./method"; +import { ClientMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware, @@ -45,7 +45,7 @@ interface BuildProps< /** @desc The operation summary for the generated Documentation (50 symbols max) */ shortDescription?: string; /** @desc The operation ID for the generated Documentation (must be unique) */ - operationId?: string | ((method: Method) => string); + operationId?: string | ((method: ClientMethod) => string); /** * @desc HTTP method(s) this endpoint can handle * @default "get" unless the Endpoint is assigned within DependsOnMethod @@ -128,7 +128,10 @@ export class EndpointsFactory< const { middlewares, resultHandler } = this; const methods = typeof method === "string" ? [method] : method; const getOperationId = - typeof operationId === "function" ? operationId : () => operationId; + typeof operationId === "function" + ? operationId + : (mtd: ClientMethod) => + operationId && `${operationId}${mtd === "head" ? "__HEAD" : ""}`; // ensure non-breaking change const scopes = typeof scope === "string" ? [scope] : scope || []; const tags = typeof tag === "string" ? [tag] : tag || []; return new Endpoint({ diff --git a/express-zod-api/src/integration-base.ts b/express-zod-api/src/integration-base.ts index 711ab7f12..c5ae09c38 100644 --- a/express-zod-api/src/integration-base.ts +++ b/express-zod-api/src/integration-base.ts @@ -2,7 +2,7 @@ import * as R from "ramda"; import ts from "typescript"; import { ResponseVariant } from "./api-response"; import { contentTypes } from "./content-type"; -import { Method, methods } from "./method"; +import { ClientMethod, clientMethods } from "./method"; import type { makeEventSchema } from "./sse"; import { accessModifiers, @@ -96,10 +96,10 @@ export abstract class IntegrationBase { }; /** - * @example export type Method = "get" | "post" | "put" | "delete" | "patch"; + * @example export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; * @internal * */ - protected methodType = makePublicLiteralType("Method", methods); + protected methodType = makePublicLiteralType("Method", clientMethods); /** * @example type SomeOf = T[keyof T]; @@ -416,8 +416,9 @@ export abstract class IntegrationBase { f.createLogicalNot( makeCall( f.createArrayLiteralExpression([ - literally("get" satisfies Method), - literally("delete" satisfies Method), + literally("get" satisfies ClientMethod), + literally("head" satisfies ClientMethod), + literally("delete" satisfies ClientMethod), ]), propOf("includes"), )(this.#ids.methodParameter), @@ -625,7 +626,7 @@ export abstract class IntegrationBase { makeConst(this.#ids.clientConst, makeNew(clientClassName)), // const client = new Client(); // client.provide("get /v1/user/retrieve", { id: "10" }); makeCall(this.#ids.clientConst, this.#ids.provideMethod)( - literally(`${"get" satisfies Method} /v1/user/retrieve`), + literally(`${"get" satisfies ClientMethod} /v1/user/retrieve`), f.createObjectLiteralExpression([ f.createPropertyAssignment("id", literally("10")), ]), @@ -634,7 +635,7 @@ export abstract class IntegrationBase { makeCall( makeNew( subscriptionClassName, - literally(`${"get" satisfies Method} /v1/events/stream`), + literally(`${"get" satisfies ClientMethod} /v1/events/stream`), f.createObjectLiteralExpression(), ), this.#ids.onMethod, diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 311c6c639..2b7708af5 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -17,11 +17,13 @@ import { import { makeCleanId } from "./common-helpers"; import { loadPeer } from "./peer-helpers"; import { Routing } from "./routing"; -import { OnEndpoint, walkRouting } from "./routing-walker"; +import { walkRouting } from "./routing-walker"; import { HandlingRules } from "./schema-walker"; import { zodToTs } from "./zts"; import { ZTSContext } from "./zts-helpers"; import type Prettier from "prettier"; +import { AbstractEndpoint } from "./endpoint"; +import { ClientMethod } from "./method"; interface IntegrationParams { routing: Routing; @@ -94,7 +96,11 @@ export class Integration extends IntegrationBase { const commons = { makeAlias: this.#makeAlias.bind(this) }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; - const onEndpoint: OnEndpoint = (endpoint, path, method) => { + const onEndpoint = ( + endpoint: AbstractEndpoint, + path: string, + method: ClientMethod, + ) => { const entitle = makeCleanId.bind(null, method, path); // clean id with method+path prefix const { isDeprecated, inputSchema, tags } = endpoint; const request = `${method} ${path}`; @@ -106,9 +112,12 @@ export class Integration extends IntegrationBase { (agg, responseVariant) => { const responses = endpoint.getResponses(responseVariant); const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { + const hasContent = + mimeTypes && + !(method === "head" && responseVariant === "positive"); const variantType = makeType( entitle(responseVariant, "variant", `${idx + 1}`), - zodToTs(mimeTypes ? schema : noContent, ctxOut), + zodToTs(hasContent ? schema : noContent, ctxOut), { comment: request }, ); this.#program.push(variantType); @@ -144,7 +153,13 @@ export class Integration extends IntegrationBase { this.registry.set(request, { isDeprecated, store }); this.tags.set(request, tags); }; - walkRouting({ routing, onEndpoint }); + walkRouting({ + routing, + onEndpoint: (endpoint, path, method) => { + onEndpoint(endpoint, path, method); + if (method === "get") onEndpoint(endpoint, path, "head"); + }, + }); this.#program.unshift(...this.#aliases.values()); this.#program.push( this.makePathType(), diff --git a/express-zod-api/src/method.ts b/express-zod-api/src/method.ts index 4b978f559..9218f4b79 100644 --- a/express-zod-api/src/method.ts +++ b/express-zod-api/src/method.ts @@ -12,5 +12,11 @@ export type Method = (typeof methods)[number]; export type AuxMethod = Extract; +export const clientMethods = [...methods, "head"] satisfies Array< + Method | Extract +>; + +export type ClientMethod = (typeof clientMethods)[number]; + export const isMethod = (subject: string): subject is Method => (methods as string[]).includes(subject); diff --git a/express-zod-api/src/routing-walker.ts b/express-zod-api/src/routing-walker.ts index 2693c3911..c81ee132a 100644 --- a/express-zod-api/src/routing-walker.ts +++ b/express-zod-api/src/routing-walker.ts @@ -5,6 +5,7 @@ import { isMethod, Method } from "./method"; import { Routing } from "./routing"; import { ServeStatic, StaticHandler } from "./serve-static"; +/** @todo check if still need to export */ export type OnEndpoint = ( endpoint: AbstractEndpoint, path: string, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index e98ea4c24..f76155996 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -58,6 +58,41 @@ paths: status: error error: message: Sample error message + head: + operationId: coolOperationId__HEAD + summary: thing is the path segment + description: thing is the path segment + responses: + "200": + description: HEAD /v1/getSome/thing Positive response + "400": + description: HEAD /v1/getSome/thing Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -132,6 +167,41 @@ paths: status: error error: message: Sample error message + head: + operationId: headCoolOperationId + summary: thing is the path segment + description: thing is the path segment + responses: + "200": + description: HEAD /v1/getSome/thing Positive response + "400": + description: HEAD /v1/getSome/thing Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message post: operationId: postCoolOperationId summary: thing is the path segment @@ -264,6 +334,41 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomeThing + summary: operationIdEndpoint + description: thing is the path segment + responses: + "200": + description: HEAD /v1/getSome/thing Positive response + "400": + description: HEAD /v1/getSome/thing Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message /v1/getSome/{thing}: get: operationId: GetV1GetSomeThing2 @@ -316,6 +421,41 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomeThing2 + summary: thing is the path parameter + description: thing is the path parameter + responses: + "200": + description: HEAD /v1/getSome/:thing Positive response + "400": + description: HEAD /v1/getSome/:thing Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -408,6 +548,55 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: key + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + type: string + - name: str + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + type: string + security: + - APIKEY_1: [] + - HTTP_1: [] + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message /v1/setSomething: post: operationId: PostV1SetSomething @@ -730,6 +919,66 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: array + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + minItems: 1 + maxItems: 3 + type: array + items: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + - name: unlimited + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + type: array + items: + type: boolean + - name: transformer + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + type: string + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -1105,42 +1354,117 @@ paths: status: error error: message: Sample error message -components: - schemas: {} - responses: {} - parameters: {} - examples: {} - requestBodies: {} - headers: {} - securitySchemes: {} - links: {} - callbacks: {} -tags: [] -servers: - - url: https://example.com -" -`; - -exports[`Documentation > Basic cases > should generate the correct schema for union type 1`] = ` -"openapi: 3.1.0 -info: - title: Testing Union and Or Types - version: 3.4.5 -paths: - /v1/getSomething: - post: - operationId: PostV1GetSomething - requestBody: - description: POST /v1/getSomething Request body - content: - application/json: - schema: - type: object - properties: - union: - anyOf: - - type: object - properties: + head: + operationId: HeadV1GetSomething + parameters: + - name: optional + in: query + required: false + description: HEAD /v1/getSomething Parameter + schema: + type: string + - name: optDefault + in: query + required: false + description: HEAD /v1/getSomething Parameter + schema: + default: test + type: string + - name: nullish + in: query + required: false + description: HEAD /v1/getSomething Parameter + schema: + type: + - boolean + - "null" + - name: nuDefault + in: query + required: false + description: HEAD /v1/getSomething Parameter + schema: + default: 123 + type: + - integer + - "null" + exclusiveMinimum: 0 + maximum: 9007199254740991 + - name: labeledDate + in: query + required: false + description: HEAD /v1/getSomething Parameter + schema: + default: Today + type: string + format: date-time + pattern: ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$ + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message +components: + schemas: {} + responses: {} + parameters: {} + examples: {} + requestBodies: {} + headers: {} + securitySchemes: {} + links: {} + callbacks: {} +tags: [] +servers: + - url: https://example.com +" +`; + +exports[`Documentation > Basic cases > should generate the correct schema for union type 1`] = ` +"openapi: 3.1.0 +info: + title: Testing Union and Or Types + version: 3.4.5 +paths: + /v1/getSomething: + post: + operationId: PostV1GetSomething + requestBody: + description: POST /v1/getSomething Request body + content: + application/json: + schema: + type: object + properties: + union: + anyOf: + - type: object + properties: one: type: string two: @@ -1504,6 +1828,24 @@ paths: required: - status additionalProperties: false + head: + operationId: HeadV1GetSomething + responses: + "201": + description: HEAD /v1/getSomething Positive response + "403": + description: HEAD /v1/getSomething Negative response + content: + text/vnd.yaml: + schema: + type: object + properties: + status: + type: string + const: NOT OK + required: + - status + additionalProperties: false components: schemas: {} responses: {} @@ -2306,6 +2648,45 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: any + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: {} + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -2397,6 +2778,54 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: string + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + format: string (preprocessed) + - name: number + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + minimum: 0 + maximum: 9007199254740991 + format: integer (preprocessed) + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -2588,6 +3017,58 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1Name + parameters: + - name: name + in: path + required: true + description: HEAD /v1/:name Parameter + schema: + summary: My custom schema + - name: other + in: query + required: true + description: HEAD /v1/:name Parameter + schema: + summary: My custom schema + - name: regular + in: query + required: true + description: HEAD /v1/:name Parameter + schema: + type: boolean + responses: + "200": + description: HEAD /v1/:name Positive response + "400": + description: HEAD /v1/:name Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -2682,6 +3163,57 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1Test + parameters: + - name: user_id + in: query + required: true + description: HEAD /v1/test Parameter + schema: + type: string + - name: at + in: query + required: true + description: YYYY-MM-DDTHH:mm:ss.sssZ + schema: + description: YYYY-MM-DDTHH:mm:ss.sssZ + type: string + format: date-time + pattern: ^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?)?Z?$ + externalDocs: + url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString + responses: + "200": + description: HEAD /v1/test Positive response + "400": + description: HEAD /v1/test Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -2765,6 +3297,46 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1Test + parameters: + - name: user_id + in: query + required: true + description: HEAD /v1/test Parameter + schema: + type: string + responses: + "200": + description: HEAD /v1/test Positive response + "400": + description: HEAD /v1/test Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -2850,6 +3422,52 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1Test + parameters: + - name: id + in: query + required: true + description: HEAD /v1/test Parameter + schema: + type: string + - name: x-request-id + in: header + required: true + description: HEAD /v1/test Parameter + schema: + type: string + responses: + "200": + description: HEAD /v1/test Positive response + "400": + description: HEAD /v1/test Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -3093,10 +3711,53 @@ paths: additionalProperties: false required: - status - - data + - data + additionalProperties: false + "400": + description: GET /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: arr + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + minItems: 1 + type: array + items: + type: string + responses: + "200": + description: HEAD /v1/getSomething Positive response "400": - description: GET /v1/getSomething Negative response + description: HEAD /v1/getSomething Negative response content: application/json: schema: @@ -3391,6 +4052,49 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + deprecated: true + parameters: + - name: str + in: query + deprecated: true + required: true + description: HEAD /v1/getSomething Parameter + schema: + deprecated: true + type: string + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -3556,6 +4260,31 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadHrisEmployees + parameters: + - name: cursor + in: query + required: false + description: An optional cursor string used for pagination. This can be + retrieved from the \`next\` property of the previous page response. + schema: + $ref: "#/components/schemas/HeadHrisEmployeesParameterCursor" + responses: + "200": + description: HEAD /hris/employees Positive response + "400": + description: HEAD /hris/employees Negative response + content: + application/json: + schema: + $ref: "#/components/schemas/HeadHrisEmployeesNegativeResponse" + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: GetHrisEmployeesParameterCursor: @@ -3594,6 +4323,28 @@ components: - status - error additionalProperties: false + HeadHrisEmployeesParameterCursor: + description: An optional cursor string used for pagination. This can be + retrieved from the \`next\` property of the previous page response. + type: string + HeadHrisEmployeesNegativeResponse: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false responses: {} parameters: {} examples: {} @@ -3900,6 +4651,49 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: strNum + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + type: string + examples: + example1: + value: "123" + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -4098,6 +4892,51 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: strNum + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + examples: + - "123" + type: string + examples: + example1: + value: "123" + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -4289,6 +5128,47 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: str + in: query + required: true + description: here is the test + schema: + description: here is the test + type: string + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} @@ -4576,6 +5456,56 @@ paths: status: error error: message: Sample error message + head: + operationId: HeadV1Name + parameters: + - name: name + in: path + required: true + description: HEAD /v1/:name Parameter + schema: + anyOf: + - type: string + const: John + - type: string + const: Jane + - name: other + in: query + required: true + description: HEAD /v1/:name Parameter + schema: + type: boolean + responses: + "200": + description: HEAD /v1/:name Positive response + "400": + description: HEAD /v1/:name Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message components: schemas: {} responses: {} diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 38e79e133..51918b3da 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -37,7 +37,7 @@ interface PostV1CustomNegativeResponseVariants { export type Path = "/v1/custom"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "post /v1/custom": PostV1CustomInput; @@ -110,7 +110,7 @@ interface PostV1MtplNegativeResponseVariants { export type Path = "/v1/mtpl"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "post /v1/mtpl": PostV1MtplInput; @@ -178,7 +178,7 @@ interface PostV1TestNegativeResponseVariants { export type Path = "/v1/test"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { /** @deprecated */ @@ -247,7 +247,7 @@ interface PostV1TestNegativeResponseVariants { export type Path = "/v1/test"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { /** @deprecated */ @@ -313,7 +313,7 @@ interface PostV1TestWithDashesNegativeResponseVariants { export type Path = "/v1/test-with-dashes"; -export type Method = "get" | "post" | "put" | "delete" | "patch"; +export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; export interface Input { "post /v1/test-with-dashes": PostV1TestWithDashesInput; @@ -364,7 +364,7 @@ export type Implementation = ( ) => Promise; const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); + const hasBody = !["get", "head", "delete"].includes(method); const searchParams = hasBody ? "" : \`?\${new URLSearchParams(params)}\`; const response = await fetch( new URL(\`\${path}\${searchParams}\`, "https://example.com"), From 17c96aacb953f8c2d7f9bb9f4bcff0953a27c874 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 18:08:45 +0200 Subject: [PATCH 14/27] todo for getActualMethod. --- express-zod-api/src/common-helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 039126dd9..f2c19da21 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -44,6 +44,7 @@ export const defaultInputSources: InputSources = { }; const fallbackInputSource: InputSource[] = ["body", "query", "params"]; +/** @todo consider removing "as" to ensure more constraints and realistic handling */ export const getActualMethod = (request: Request) => request.method.toLowerCase() as Method | AuxMethod; From 99eb536d5db702b29edde4c0faf9327396917793 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 20:28:26 +0200 Subject: [PATCH 15/27] Ref: extracting doesImplyContent() common helper. --- express-zod-api/src/common-helpers.ts | 8 +++++++- express-zod-api/src/documentation.ts | 9 ++++++--- express-zod-api/src/integration.ts | 5 ++--- express-zod-api/tests/common-helpers.spec.ts | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index f2c19da21..584dbf883 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -4,7 +4,8 @@ import { z } from "zod/v4"; import type { $ZodTransform, $ZodType } from "zod/v4/core"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { contentTypes } from "./content-type"; -import { AuxMethod, Method } from "./method"; +import { AuxMethod, ClientMethod, Method } from "./method"; +import { ResponseVariant } from "./api-response"; /** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */ export type EmptyObject = z.output; @@ -130,3 +131,8 @@ export const isProduction = R.memoizeWith( () => process.env.TSUP_STATIC as string, // eslint-disable-line no-restricted-syntax -- substituted by TSUP () => process.env.NODE_ENV === "production", // eslint-disable-line no-restricted-syntax -- memoized ); + +export const doesImplyContent = ( + method: ClientMethod, + responseVariant: ResponseVariant, +) => !(method === "head" && responseVariant === "positive"); diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 10708372b..b65e50df0 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -11,7 +11,11 @@ import * as R from "ramda"; import { responseVariants } from "./api-response"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; -import { getInputSources, makeCleanId } from "./common-helpers"; +import { + doesImplyContent, + getInputSources, + makeCleanId, +} from "./common-helpers"; import { CommonConfig } from "./config-type"; import { processContainers } from "./logical-container"; import { ClientMethod } from "./method"; @@ -206,8 +210,7 @@ export class Documentation extends OpenApiBuilder { ...commons, variant, schema, - mimeTypes: - method === "head" && variant === "positive" ? null : mimeTypes, + mimeTypes: doesImplyContent(method, variant) ? mimeTypes : null, statusCode, hasMultipleStatusCodes: apiResponses.length > 1 || statusCodes.length > 1, diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 2b7708af5..f81ed07b4 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -14,7 +14,7 @@ import { makeLiteralType, makeUnion, } from "./typescript-api"; -import { makeCleanId } from "./common-helpers"; +import { doesImplyContent, makeCleanId } from "./common-helpers"; import { loadPeer } from "./peer-helpers"; import { Routing } from "./routing"; import { walkRouting } from "./routing-walker"; @@ -113,8 +113,7 @@ export class Integration extends IntegrationBase { const responses = endpoint.getResponses(responseVariant); const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { const hasContent = - mimeTypes && - !(method === "head" && responseVariant === "positive"); + mimeTypes && doesImplyContent(method, responseVariant); const variantType = makeType( entitle(responseVariant, "variant", `${idx + 1}`), zodToTs(hasContent ? schema : noContent, ctxOut), diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index a2e55af7c..a1db72357 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -7,9 +7,11 @@ import { makeCleanId, ensureError, getRoutePathParams, + doesImplyContent, } from "../src/common-helpers"; import { z } from "zod/v4"; import { makeRequestMock } from "../src/testing"; +import { methods } from "../src/method"; describe("Common Helpers", () => { describe("defaultInputSources", () => { @@ -287,4 +289,16 @@ describe("Common Helpers", () => { }, ); }); + + describe("doesImplyContent()", () => { + test.each(methods)("should return true for %s", (method) => { + expect(doesImplyContent(method, "positive")).toBe(true); + expect(doesImplyContent(method, "negative")).toBe(true); + }); + + test("should return false for positive response to HEAD request", () => { + expect(doesImplyContent("head", "positive")).toBe(false); + expect(doesImplyContent("head", "negative")).toBe(true); + }); + }); }); From a2c5d65523f2ed0abdcd956468690db682bd7204 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 20:40:19 +0200 Subject: [PATCH 16/27] Extracting withHead() helper. --- express-zod-api/src/documentation.ts | 14 +++----------- express-zod-api/src/integration.ts | 14 +++----------- express-zod-api/src/routing-walker.ts | 15 +++++++++++---- 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index b65e50df0..67ca24554 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -34,8 +34,7 @@ import { depictRequest, } from "./documentation-helpers"; import { Routing } from "./routing"; -import { walkRouting } from "./routing-walker"; -import { AbstractEndpoint } from "./endpoint"; +import { OnEndpoint, walkRouting, withHead } from "./routing-walker"; type Component = | "positiveResponse" @@ -160,11 +159,7 @@ export class Documentation extends OpenApiBuilder { this.addInfo({ title, version }); for (const url of typeof serverUrl === "string" ? [serverUrl] : serverUrl) this.addServer({ url }); - const onEndpoint = ( - endpoint: AbstractEndpoint, - path: string, - method: ClientMethod, - ) => { + const onEndpoint: OnEndpoint = (endpoint, path, method) => { const commons = { path, method, @@ -265,10 +260,7 @@ export class Documentation extends OpenApiBuilder { }; walkRouting({ routing, - onEndpoint: (endpoint, path, method) => { - onEndpoint(endpoint, path, method); - if (method === "get") onEndpoint(endpoint, path, "head"); - }, + onEndpoint: withHead(onEndpoint), }); if (tags) this.rootDoc.tags = depictTags(tags); } diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index f81ed07b4..22d4684fb 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -17,12 +17,11 @@ import { import { doesImplyContent, makeCleanId } from "./common-helpers"; import { loadPeer } from "./peer-helpers"; import { Routing } from "./routing"; -import { walkRouting } from "./routing-walker"; +import { OnEndpoint, walkRouting, withHead } from "./routing-walker"; import { HandlingRules } from "./schema-walker"; import { zodToTs } from "./zts"; import { ZTSContext } from "./zts-helpers"; import type Prettier from "prettier"; -import { AbstractEndpoint } from "./endpoint"; import { ClientMethod } from "./method"; interface IntegrationParams { @@ -96,11 +95,7 @@ export class Integration extends IntegrationBase { const commons = { makeAlias: this.#makeAlias.bind(this) }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; - const onEndpoint = ( - endpoint: AbstractEndpoint, - path: string, - method: ClientMethod, - ) => { + const onEndpoint: OnEndpoint = (endpoint, path, method) => { const entitle = makeCleanId.bind(null, method, path); // clean id with method+path prefix const { isDeprecated, inputSchema, tags } = endpoint; const request = `${method} ${path}`; @@ -154,10 +149,7 @@ export class Integration extends IntegrationBase { }; walkRouting({ routing, - onEndpoint: (endpoint, path, method) => { - onEndpoint(endpoint, path, method); - if (method === "get") onEndpoint(endpoint, path, "head"); - }, + onEndpoint: withHead(onEndpoint), }); this.#program.unshift(...this.#aliases.values()); this.#program.push( diff --git a/express-zod-api/src/routing-walker.ts b/express-zod-api/src/routing-walker.ts index c81ee132a..ff62b0605 100644 --- a/express-zod-api/src/routing-walker.ts +++ b/express-zod-api/src/routing-walker.ts @@ -1,17 +1,24 @@ import { DependsOnMethod } from "./depends-on-method"; import { AbstractEndpoint } from "./endpoint"; import { RoutingError } from "./errors"; -import { isMethod, Method } from "./method"; +import { ClientMethod, isMethod, Method } from "./method"; import { Routing } from "./routing"; import { ServeStatic, StaticHandler } from "./serve-static"; -/** @todo check if still need to export */ -export type OnEndpoint = ( +export type OnEndpoint = ( endpoint: AbstractEndpoint, path: string, - method: Method, + method: M, ) => void; +/** Calls the given hook with HEAD each time it's called with GET method */ +export const withHead = + (onEndpoint: OnEndpoint): OnEndpoint => + (endpoint, path, method) => { + onEndpoint(endpoint, path, method); + if (method === "get") onEndpoint(endpoint, path, "head"); + }; + interface RoutingWalkerParams { routing: Routing; onEndpoint: OnEndpoint; From fda6b94dd71175fc51a8ec8205b3beb7293af9a1 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 21:04:54 +0200 Subject: [PATCH 17/27] Tests for getInputSources(). --- express-zod-api/tests/common-helpers.spec.ts | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index a1db72357..8a4216c0c 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -8,10 +8,12 @@ import { ensureError, getRoutePathParams, doesImplyContent, + getInputSources, } from "../src/common-helpers"; import { z } from "zod/v4"; import { makeRequestMock } from "../src/testing"; import { methods } from "../src/method"; +import { InputSources } from "../src/config-type"; describe("Common Helpers", () => { describe("defaultInputSources", () => { @@ -48,6 +50,44 @@ describe("Common Helpers", () => { }); }); + describe("getInputSources()", () => { + test.each([undefined, {}])( + "should return empty array for options %#", + (userDefined) => { + expect(getInputSources("options", userDefined)).toEqual([]); + }, + ); + + test.each(methods)("should return user defined ones for %s", (method) => { + const userDefined: InputSources = { + get: ["headers"], + put: ["files"], + post: ["query"], + delete: ["params"], + patch: ["body"], + }; + expect(getInputSources(method, userDefined)).toEqual(userDefined[method]); + }); + + test.each([undefined, {}])( + "should return default ones when missing user defined for %s", + (userDefined) => { + expect(getInputSources("get", userDefined)).toEqual( + defaultInputSources.get, + ); + }, + ); + + test.each([undefined, {}, { get: ["body" as const] }])( + "for HEAD should return the same as for GET", + (userDefined) => { + expect(getInputSources("head", userDefined)).toEqual( + getInputSources("get", userDefined), + ); + }, + ); + }); + describe("getInput()", () => { test("should return body for POST, PUT and PATCH requests by default", () => { expect( From e11f85662cd203f6005257c29e68ad2ba0140bca Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 21:34:16 +0200 Subject: [PATCH 18/27] Changelog: 24.7.0. --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f8d9dac8..ee0ee36ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Version 24 +### v24.7.0 + +- Supporting `HEAD` method: + - The purpose of the `HEAD` method is to retrieve the headers without performing `GET` request; + - It is the built-in feature of Express to support `HEAD` method by request handlers for `GET` requests; + - Therefore, each `Endpoint` supporting `get` method also handles `head` requests (no changes required); + - Added `HEAD` method to CORS response headers, along with `OPTIONS`, for such endpoints; + - Positive response to `HEAD` request is the same as for `GET`, but without the body: + - Added `head` request depiction to the generated `Documentation`; + - Added `head` request types to the generated `Integration` client; + - The following customizable functions can now receive `head` as an argument: + - `operationId` supplied to `EndpointsFactory::build()`; + - `isHeader` supplied to `Documentation::constructor()`; +- Caveat: + - If the `operationId` assigned with a string then it is appended with `__HEAD` for `head` method to avoid conflicts; + ### v24.6.2 - Correcting recommendations given in [v24.6.0](#v2460) regarding using with `zod@^4.0.0`: From c0962fac0f0ae169e3800f5eac69d17d7eb19fae Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 21:35:39 +0200 Subject: [PATCH 19/27] Changelog: contributor credits. --- CHANGELOG.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0ee36ca..de7e612cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - The following customizable functions can now receive `head` as an argument: - `operationId` supplied to `EndpointsFactory::build()`; - `isHeader` supplied to `Documentation::constructor()`; + - This feature was suggested by [@pepegc](https://github.com/pepegc); - Caveat: - If the `operationId` assigned with a string then it is appended with `__HEAD` for `head` method to avoid conflicts; diff --git a/README.md b/README.md index feef31cb3..79da6c8a2 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Therefore, many basic tasks can be accomplished faster and easier, in particular These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas: +[@pepegc](https://github.com/pepegc) [@MichaelHindley](https://github.com/MichaelHindley) [@zoton2](https://github.com/zoton2) [@ThomasKientz](https://github.com/ThomasKientz) From 70fb79d48655bb62827b1d8fcc4cae5c09c824c5 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 21:43:47 +0200 Subject: [PATCH 20/27] Changelog: note on ResultHandler approach. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de7e612cb..37dba963d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - Positive response to `HEAD` request is the same as for `GET`, but without the body: - Added `head` request depiction to the generated `Documentation`; - Added `head` request types to the generated `Integration` client; + - Regarding the expected `Content-Length` header in response to `HEAD` request: + - `ResultHandler`s using `response.send()` (as well as its shorthands such as `.json()`) automatically do that + instead of sending the response body (no changes needed); + - Other approaches, such as stream piping, might require to implement `Content-Length` header for `HEAD` requests; - The following customizable functions can now receive `head` as an argument: - `operationId` supplied to `EndpointsFactory::build()`; - `isHeader` supplied to `Documentation::constructor()`; From e73f6ccdc505ee505cf45a12a89098d61f8aea2a Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 23:05:09 +0200 Subject: [PATCH 21/27] Minor: type constraints in test. --- express-zod-api/tests/common-helpers.spec.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 8a4216c0c..819a7a042 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -78,14 +78,15 @@ describe("Common Helpers", () => { }, ); - test.each([undefined, {}, { get: ["body" as const] }])( - "for HEAD should return the same as for GET", - (userDefined) => { - expect(getInputSources("head", userDefined)).toEqual( - getInputSources("get", userDefined), - ); - }, - ); + test.each | undefined>([ + undefined, + {}, + { get: ["body"] }, + ])("for HEAD should return the same as for GET", (userDefined) => { + expect(getInputSources("head", userDefined)).toEqual( + getInputSources("get", userDefined), + ); + }); }); describe("getInput()", () => { From 7db6259a279fe77cf419a2fce292e8f951c0addc Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 19 Jul 2025 23:06:27 +0200 Subject: [PATCH 22/27] ref: shortening type in test. --- express-zod-api/tests/common-helpers.spec.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 819a7a042..13e274c3a 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -13,7 +13,7 @@ import { import { z } from "zod/v4"; import { makeRequestMock } from "../src/testing"; import { methods } from "../src/method"; -import { InputSources } from "../src/config-type"; +import { CommonConfig, InputSources } from "../src/config-type"; describe("Common Helpers", () => { describe("defaultInputSources", () => { @@ -78,15 +78,14 @@ describe("Common Helpers", () => { }, ); - test.each | undefined>([ - undefined, - {}, - { get: ["body"] }, - ])("for HEAD should return the same as for GET", (userDefined) => { - expect(getInputSources("head", userDefined)).toEqual( - getInputSources("get", userDefined), - ); - }); + test.each([undefined, {}, { get: ["body"] }])( + "for HEAD should return the same as for GET", + (userDefined) => { + expect(getInputSources("head", userDefined)).toEqual( + getInputSources("get", userDefined), + ); + }, + ); }); describe("getInput()", () => { From d01b8bf318223132888933b47ac9e0e415fe88fa Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 20 Jul 2025 08:14:07 +0200 Subject: [PATCH 23/27] Ref: changing order of the arguments for OnEndpoint. --- express-zod-api/src/documentation.ts | 2 +- express-zod-api/src/integration.ts | 2 +- express-zod-api/src/routing-walker.ts | 16 ++++++++-------- express-zod-api/src/routing.ts | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 67ca24554..879dff13f 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -159,7 +159,7 @@ export class Documentation extends OpenApiBuilder { this.addInfo({ title, version }); for (const url of typeof serverUrl === "string" ? [serverUrl] : serverUrl) this.addServer({ url }); - const onEndpoint: OnEndpoint = (endpoint, path, method) => { + const onEndpoint: OnEndpoint = (method, path, endpoint) => { const commons = { path, method, diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 22d4684fb..fd3576e3a 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -95,7 +95,7 @@ export class Integration extends IntegrationBase { const commons = { makeAlias: this.#makeAlias.bind(this) }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; - const onEndpoint: OnEndpoint = (endpoint, path, method) => { + const onEndpoint: OnEndpoint = (method, path, endpoint) => { const entitle = makeCleanId.bind(null, method, path); // clean id with method+path prefix const { isDeprecated, inputSchema, tags } = endpoint; const request = `${method} ${path}`; diff --git a/express-zod-api/src/routing-walker.ts b/express-zod-api/src/routing-walker.ts index ff62b0605..a4313a30f 100644 --- a/express-zod-api/src/routing-walker.ts +++ b/express-zod-api/src/routing-walker.ts @@ -6,17 +6,17 @@ import { Routing } from "./routing"; import { ServeStatic, StaticHandler } from "./serve-static"; export type OnEndpoint = ( - endpoint: AbstractEndpoint, - path: string, method: M, + path: string, + endpoint: AbstractEndpoint, ) => void; /** Calls the given hook with HEAD each time it's called with GET method */ export const withHead = (onEndpoint: OnEndpoint): OnEndpoint => - (endpoint, path, method) => { - onEndpoint(endpoint, path, method); - if (method === "get") onEndpoint(endpoint, path, "head"); + (method, ...rest) => { + onEndpoint(method, ...rest); + if (method === "get") onEndpoint("head", ...rest); }; interface RoutingWalkerParams { @@ -86,12 +86,12 @@ export const walkRouting = ({ if (explicitMethod) { checkDuplicate(explicitMethod, path, visited); checkMethodSupported(explicitMethod, path, element.methods); - onEndpoint(element, path, explicitMethod); + onEndpoint(explicitMethod, path, element); } else { const { methods = ["get"] } = element; for (const method of methods) { checkDuplicate(method, path, visited); - onEndpoint(element, path, method); + onEndpoint(method, path, element); } } } else { @@ -103,7 +103,7 @@ export const walkRouting = ({ const { methods } = endpoint; checkDuplicate(method, path, visited); checkMethodSupported(method, path, methods); - onEndpoint(endpoint, path, method); + onEndpoint(method, path, endpoint); } } else { stack.unshift(...processEntries(element, path)); diff --git a/express-zod-api/src/routing.ts b/express-zod-api/src/routing.ts index dbea8ae59..22726fbb2 100644 --- a/express-zod-api/src/routing.ts +++ b/express-zod-api/src/routing.ts @@ -65,7 +65,7 @@ export const initRouting = ({ }) => { let doc = isProduction() ? undefined : new Diagnostics(getLogger()); // disposable const familiar = new Map(); - const onEndpoint: OnEndpoint = (endpoint, path, method) => { + const onEndpoint: OnEndpoint = (method, path, endpoint) => { if (!isProduction()) { doc?.checkSchema(endpoint, { path, method }); doc?.checkPathParams(path, endpoint, { method }); From d4a85011a3ab24db0af03fc720cac1208df7792c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 20 Jul 2025 09:35:38 +0200 Subject: [PATCH 24/27] minor: jsdoc for different kinds of methods. --- express-zod-api/src/method.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/express-zod-api/src/method.ts b/express-zod-api/src/method.ts index 9218f4b79..1c68f99a4 100644 --- a/express-zod-api/src/method.ts +++ b/express-zod-api/src/method.ts @@ -8,14 +8,26 @@ export const methods = [ "patch", ] satisfies Array; +/** + * @desc Methods supported by the framework API to produce Endpoints on EndpointsFactory. + * @see BuildProps + * */ export type Method = (typeof methods)[number]; +/** + * @desc Additional methods having some technical handling in the framework + * @see makeCorsHeaders + * */ export type AuxMethod = Extract; export const clientMethods = [...methods, "head"] satisfies Array< Method | Extract >; +/** + * @desc Methods usable on the client side, available via generated Integration and Documentation + * @see withHead + * */ export type ClientMethod = (typeof clientMethods)[number]; export const isMethod = (subject: string): subject is Method => From 8446e988bb4e383ed36c0c9bd5bbba942ffc1d3c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 20 Jul 2025 10:11:11 +0200 Subject: [PATCH 25/27] Changelog: ref. --- CHANGELOG.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37dba963d..cdf46685e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,22 +6,22 @@ - Supporting `HEAD` method: - The purpose of the `HEAD` method is to retrieve the headers without performing `GET` request; - - It is the built-in feature of Express to support `HEAD` method by request handlers for `GET` requests; - - Therefore, each `Endpoint` supporting `get` method also handles `head` requests (no changes required); - - Added `HEAD` method to CORS response headers, along with `OPTIONS`, for such endpoints; - - Positive response to `HEAD` request is the same as for `GET`, but without the body: + - It is the built-in feature of Express to handle `HEAD` requests by the handlers for `GET` requests; + - Therefore, each `Endpoint` supporting `get` method also handles `head` requests (no work needed); + - Added `HEAD` method to CORS response headers, along with `OPTIONS`, for `GET` method supporting endpoints; + - Positive response to `HEAD` request should contain same headers as `GET` would, but without the body: - Added `head` request depiction to the generated `Documentation`; - Added `head` request types to the generated `Integration` client; - - Regarding the expected `Content-Length` header in response to `HEAD` request: + - Positive response to `HEAD` request should contain the `Content-Length` header: - `ResultHandler`s using `response.send()` (as well as its shorthands such as `.json()`) automatically do that - instead of sending the response body (no changes needed); + instead of sending the response body (no work needed); - Other approaches, such as stream piping, might require to implement `Content-Length` header for `HEAD` requests; - - The following customizable functions can now receive `head` as an argument: + - This feature was suggested by [@pepegc](https://github.com/pepegc); +- Caveats: + - The following properties, when assigned with functions, can now receive `head` as an argument: - `operationId` supplied to `EndpointsFactory::build()`; - `isHeader` supplied to `Documentation::constructor()`; - - This feature was suggested by [@pepegc](https://github.com/pepegc); -- Caveat: - - If the `operationId` assigned with a string then it is appended with `__HEAD` for `head` method to avoid conflicts; + - If the `operationId` is assigned with a `string` then it may be appended with `__HEAD` for `head` method; ### v24.6.2 From daa2da619723ed92441a8267d6a6eb65d678b3f7 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 20 Jul 2025 11:10:24 +0200 Subject: [PATCH 26/27] mv doesImplyContent into depictResponse. --- express-zod-api/src/documentation-helpers.ts | 3 ++- express-zod-api/src/documentation.ts | 8 ++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index f3ce4cf06..6f0fc9b3e 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -28,6 +28,7 @@ import { z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { ezBufferBrand } from "./buffer-schema"; import { + doesImplyContent, FlatObject, getRoutePathParams, getTransformedType, @@ -498,7 +499,7 @@ export const depictResponse = ({ statusCode: number; hasMultipleStatusCodes: boolean; }): ResponseObject => { - if (!mimeTypes) return { description }; + if (!mimeTypes || !doesImplyContent(method, variant)) return { description }; const response = asOAS( depict(schema, { rules: { ...brandHandling, ...depicters }, diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 879dff13f..fa395d19b 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -11,11 +11,7 @@ import * as R from "ramda"; import { responseVariants } from "./api-response"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; -import { - doesImplyContent, - getInputSources, - makeCleanId, -} from "./common-helpers"; +import { getInputSources, makeCleanId } from "./common-helpers"; import { CommonConfig } from "./config-type"; import { processContainers } from "./logical-container"; import { ClientMethod } from "./method"; @@ -205,7 +201,7 @@ export class Documentation extends OpenApiBuilder { ...commons, variant, schema, - mimeTypes: doesImplyContent(method, variant) ? mimeTypes : null, + mimeTypes, statusCode, hasMultipleStatusCodes: apiResponses.length > 1 || statusCodes.length > 1, From 35134cdee8d76d2e67b78a0d5b007c3893a24606 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 20 Jul 2025 11:48:47 +0200 Subject: [PATCH 27/27] Tests for client methods and todo for AuxMethod type. --- express-zod-api/src/method.ts | 1 + express-zod-api/tests/method.spec.ts | 35 +++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/method.ts b/express-zod-api/src/method.ts index 1c68f99a4..76bd5f546 100644 --- a/express-zod-api/src/method.ts +++ b/express-zod-api/src/method.ts @@ -17,6 +17,7 @@ export type Method = (typeof methods)[number]; /** * @desc Additional methods having some technical handling in the framework * @see makeCorsHeaders + * @todo consider removing it and introducing CORSMethod = ClientMethod | "options" * */ export type AuxMethod = Extract; diff --git a/express-zod-api/tests/method.spec.ts b/express-zod-api/tests/method.spec.ts index ce117b5b1..a51357458 100644 --- a/express-zod-api/tests/method.spec.ts +++ b/express-zod-api/tests/method.spec.ts @@ -1,5 +1,13 @@ import * as R from "ramda"; -import { isMethod, methods, Method, AuxMethod } from "../src/method"; +import { + isMethod, + methods, + Method, + AuxMethod, + clientMethods, + ClientMethod, +} from "../src/method"; +import { describe } from "node:test"; describe("Method", () => { describe("methods array", () => { @@ -8,6 +16,19 @@ describe("Method", () => { }); }); + describe("clientMethods array", () => { + test("should be same methods and the head", () => { + expect(clientMethods).toEqual([ + "get", + "post", + "put", + "delete", + "patch", + "head", + ]); + }); + }); + describe("the type", () => { test("should match the entries of the methods array", () => { expectTypeOf<"get">().toExtend(); @@ -19,6 +40,18 @@ describe("Method", () => { }); }); + describe("ClientMethod type", () => { + test("should match the entries of the methods array", () => { + expectTypeOf<"get">().toExtend(); + expectTypeOf<"post">().toExtend(); + expectTypeOf<"put">().toExtend(); + expectTypeOf<"delete">().toExtend(); + expectTypeOf<"patch">().toExtend(); + expectTypeOf<"head">().toExtend(); + expectTypeOf<"wrong">().not.toExtend(); + }); + }); + describe("AuxMethod", () => { test("should be options or head", () => { expectTypeOf<"options">().toExtend();