Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5552d96
Add HEAD method to methods.
RobinTail Jul 13, 2025
5e90d9f
Force no content for HEAD response by Documentation and Integration.
RobinTail Jul 13, 2025
8a316b2
ref: simpler condition for Integration.
RobinTail Jul 13, 2025
bfccb45
REV: moving HEAD method to AuxMethod type.
RobinTail Jul 14, 2025
86b253b
Add HEAD method to CORS when GET is there.
RobinTail Jul 14, 2025
ec05db7
Fix runtime error on accessing eTag caused by R.clone().
RobinTail Jul 14, 2025
1fc8cd3
REF: avoid cloning request.
RobinTail Jul 14, 2025
b04c0df
Test for HEAD and res.send().
RobinTail Jul 14, 2025
fc8a2aa
Adjusting example to ensure content-length for HEAD of streaming.
RobinTail Jul 14, 2025
b205b7d
minor: shortening.
RobinTail Jul 14, 2025
b0551ca
Ref: improving CORS method sorting.
RobinTail Jul 14, 2025
e74a0bf
REF: async async stats with async RH for streaming.
RobinTail Jul 14, 2025
16e0520
Merge branch 'master' into feat-method-head
RobinTail Jul 15, 2025
d2448c3
Merge branch 'master' into feat-method-head
RobinTail Jul 19, 2025
00ca5c0
FEAT: supporting HEAD method by Integration and Documentation.
RobinTail Jul 19, 2025
17c96aa
todo for getActualMethod.
RobinTail Jul 19, 2025
99eb536
Ref: extracting doesImplyContent() common helper.
RobinTail Jul 19, 2025
a2c5d65
Extracting withHead() helper.
RobinTail Jul 19, 2025
fda6b94
Tests for getInputSources().
RobinTail Jul 19, 2025
e11f856
Changelog: 24.7.0.
RobinTail Jul 19, 2025
c0962fa
Changelog: contributor credits.
RobinTail Jul 19, 2025
70fb79d
Changelog: note on ResultHandler approach.
RobinTail Jul 19, 2025
e73f6cc
Minor: type constraints in test.
RobinTail Jul 19, 2025
7db6259
ref: shortening type in test.
RobinTail Jul 19, 2025
d01b8bf
Ref: changing order of the arguments for OnEndpoint.
RobinTail Jul 20, 2025
d4a8501
minor: jsdoc for different kinds of methods.
RobinTail Jul 20, 2025
8446e98
Changelog: ref.
RobinTail Jul 20, 2025
daa2da6
mv doesImplyContent into depictResponse.
RobinTail Jul 20, 2025
35134cd
Tests for client methods and todo for AuxMethod type.
RobinTail Jul 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions example/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "express-zod-api";
import { authMiddleware } from "./middlewares";
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 */
Expand All @@ -34,12 +35,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: async ({ 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 } = await stat(output.filename);
return void target.set("Content-Length", `${size}`).end();
}
createReadStream(output.filename).pipe(target);
} else {
response.status(400).send("Filename is missing");
}
Expand Down
29 changes: 29 additions & 0 deletions example/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -194,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);
Expand Down
5 changes: 3 additions & 2 deletions express-zod-api/src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ export const getInput = (
req: Request,
userDefined: CommonConfig["inputSources"] = {},
): FlatObject => {
const method = getActualMethod(req);
if (method === "options") return {};
const actualMethod = getActualMethod(req);
if (actualMethod === "options") return {};
const method = actualMethod === "head" ? "get" : actualMethod;
return (
userDefined[method] ||
defaultInputSources[method] ||
Expand Down
2 changes: 1 addition & 1 deletion express-zod-api/src/method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const methods = [

export type Method = (typeof methods)[number];

export type AuxMethod = Extract<keyof IRouter, "options">;
export type AuxMethod = Extract<keyof IRouter, "options" | "head">;

export const isMethod = (subject: string): subject is Method =>
(methods as string[]).includes(subject);
8 changes: 5 additions & 3 deletions express-zod-api/src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,8 +25,8 @@ export interface Routing {
export type Parsers = Partial<Record<ContentType, RequestHandler[]>>;

const lineUp = (methods: Array<Method | AuxMethod>) =>
methods // options is last, fine to sort in-place
.sort((a, b) => +(a === "options") - +(b === "options"))
methods // auxiliary methods go last
.sort((a, b) => +isMethod(b) - +isMethod(a) || a.localeCompare(b))
.join(", ")
.toUpperCase();

Expand Down Expand Up @@ -81,6 +81,8 @@ export const initRouting = ({
const deprioritized = new Map<string, RequestHandler>();
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
Expand Down
7 changes: 7 additions & 0 deletions express-zod-api/tests/common-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions express-zod-api/tests/method.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ describe("Method", () => {
});

describe("AuxMethod", () => {
test("should be options", () => {
expectTypeOf<AuxMethod>().toEqualTypeOf("options" as const);
test("should be options or head", () => {
expectTypeOf<"options">().toExtend<AuxMethod>();
expectTypeOf<"head">().toExtend<AuxMethod>();
expectTypeOf<"other">().not.toExtend<AuxMethod>();
});
});

Expand Down
4 changes: 2 additions & 2 deletions express-zod-api/tests/routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, PATCH, POST, PUT, HEAD, OPTIONS",
"access-control-allow-headers": "content-type",
"x-custom-header": "Testing",
});
Expand Down Expand Up @@ -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",
});
});
Expand Down
2 changes: 1 addition & 1 deletion express-zod-api/tests/system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down