Skip to content

Commit b1762bf

Browse files
authored
feat: HEAD method handling (#2816)
Based on #2791 - MDN: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/HEAD - RFC: https://httpwg.org/specs/rfc9110.html#HEAD > MUST NOT send content in the response Findings in Express: - no need to set `app.head()` handler, it's automatically set for all `app.get()` ones: - https://expressjs.com/en/api.html#app.METHOD - No extra handling required for `res.send()` — it automatically sends `Content-Length` header instead of response body for requests having `HEAD` method: - https://expressjs.com/en/api.html#res.send - For streams, `Content-Length` should not be set anyway because of `Transfer-encoding: chunked`, according to RFC: - https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2 - expressjs/express#2893 - > A sender MUST NOT send a Content-Length header field in any message that contains a Transfer-Encoding header field. - HOWEVER, for `HEAD` it SHOULD be there: - The relevant nuance is described in [RFC 9110, Section 9.3.2. HEAD(https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.2): - > The server SHOULD send the same header fields in response to a HEAD request as it would have sent if the request had been a GET, except that the payload header fields (Section 6.4) MAY be omitted. This includes sending headers such as Content-Length, even though there is no response body. - And in [RFC 9110, Section 6.4.1. Content-Length](https://datatracker.ietf.org/doc/html/rfc9110#section-6.4.1): - > A server MAY send a Content-Length header field in a response to a HEAD request (Section 9.3.2; see also Section 9.3.6), indicating the size of the payload body that would have been sent had the request been a GET. - This means that even if a GET response would use chunked encoding and omit Content-Length, the HEAD response can and should include Content-Length if the size is known. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced support for HTTP HEAD requests across multiple API endpoints with proper Content-Length headers and input handling. * API specification extended to include HEAD operations mirroring existing GET endpoints. * Client and server now fully recognize HEAD as a standard HTTP method with appropriate response and input processing. * Improved routing and documentation generation to incorporate HEAD methods alongside GET. * **Bug Fixes** * HEAD requests now correctly return headers without streaming content, ensuring compliance with HTTP standards. * **Tests** * Added comprehensive tests verifying HEAD request handling, response headers, and allowed methods across endpoints. * **Documentation** * Updated API documentation and CORS headers to include HEAD method support and accurate method listings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7c344fc commit b1762bf

22 files changed

Lines changed: 1539 additions & 107 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
## Version 24
44

5+
### v24.7.0
6+
7+
- Supporting `HEAD` method:
8+
- The purpose of the `HEAD` method is to retrieve the headers without performing `GET` request;
9+
- It is the built-in feature of Express to handle `HEAD` requests by the handlers for `GET` requests;
10+
- Therefore, each `Endpoint` supporting `get` method also handles `head` requests (no work needed);
11+
- Added `HEAD` method to CORS response headers, along with `OPTIONS`, for `GET` method supporting endpoints;
12+
- Positive response to `HEAD` request should contain same headers as `GET` would, but without the body:
13+
- Added `head` request depiction to the generated `Documentation`;
14+
- Added `head` request types to the generated `Integration` client;
15+
- Positive response to `HEAD` request should contain the `Content-Length` header:
16+
- `ResultHandler`s using `response.send()` (as well as its shorthands such as `.json()`) automatically do that
17+
instead of sending the response body (no work needed);
18+
- Other approaches, such as stream piping, might require to implement `Content-Length` header for `HEAD` requests;
19+
- This feature was suggested by [@pepegc](https://github.com/pepegc);
20+
- Caveats:
21+
- The following properties, when assigned with functions, can now receive `head` as an argument:
22+
- `operationId` supplied to `EndpointsFactory::build()`;
23+
- `isHeader` supplied to `Documentation::constructor()`;
24+
- If the `operationId` is assigned with a `string` then it may be appended with `__HEAD` for `head` method;
25+
526
### v24.6.2
627

728
- Correcting recommendations given in [v24.6.0](#v2460) regarding using with `zod@^4.0.0`:

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Therefore, many basic tasks can be accomplished faster and easier, in particular
8585

8686
These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas:
8787

88+
[<img src="https://github.com/pepegc.png" alt="@pepegc" width="50px" />](https://github.com/pepegc)
8889
[<img src="https://github.com/MichaelHindley.png" alt="@MichaelHindley" width="50px" />](https://github.com/MichaelHindley)
8990
[<img src="https://github.com/zoton2.png" alt="@zoton2" width="50px" />](https://github.com/zoton2)
9091
[<img src="https://github.com/ThomasKientz.png" alt="@ThomasKientz" width="50px" />](https://github.com/ThomasKientz)

example/example.client.ts

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,33 @@ interface GetV1UserRetrieveNegativeResponseVariants {
3939
400: GetV1UserRetrieveNegativeVariant1;
4040
}
4141

42+
/** head /v1/user/retrieve */
43+
type HeadV1UserRetrieveInput = {
44+
/** a numeric string containing the id of the user */
45+
id: string;
46+
};
47+
48+
/** head /v1/user/retrieve */
49+
type HeadV1UserRetrievePositiveVariant1 = undefined;
50+
51+
/** head /v1/user/retrieve */
52+
interface HeadV1UserRetrievePositiveResponseVariants {
53+
200: HeadV1UserRetrievePositiveVariant1;
54+
}
55+
56+
/** head /v1/user/retrieve */
57+
type HeadV1UserRetrieveNegativeVariant1 = {
58+
status: "error";
59+
error: {
60+
message: string;
61+
};
62+
};
63+
64+
/** head /v1/user/retrieve */
65+
interface HeadV1UserRetrieveNegativeResponseVariants {
66+
400: HeadV1UserRetrieveNegativeVariant1;
67+
}
68+
4269
/** delete /v1/user/:id/remove */
4370
type DeleteV1UserIdRemoveInput = {
4471
/** numeric string */
@@ -159,6 +186,25 @@ interface GetV1UserListNegativeResponseVariants {
159186
400: GetV1UserListNegativeVariant1;
160187
}
161188

189+
/** head /v1/user/list */
190+
type HeadV1UserListInput = {};
191+
192+
/** head /v1/user/list */
193+
type HeadV1UserListPositiveVariant1 = undefined;
194+
195+
/** head /v1/user/list */
196+
interface HeadV1UserListPositiveResponseVariants {
197+
200: HeadV1UserListPositiveVariant1;
198+
}
199+
200+
/** head /v1/user/list */
201+
type HeadV1UserListNegativeVariant1 = string;
202+
203+
/** head /v1/user/list */
204+
interface HeadV1UserListNegativeResponseVariants {
205+
400: HeadV1UserListNegativeVariant1;
206+
}
207+
162208
/** get /v1/avatar/send */
163209
type GetV1AvatarSendInput = {
164210
userId: string;
@@ -180,6 +226,27 @@ interface GetV1AvatarSendNegativeResponseVariants {
180226
400: GetV1AvatarSendNegativeVariant1;
181227
}
182228

229+
/** head /v1/avatar/send */
230+
type HeadV1AvatarSendInput = {
231+
userId: string;
232+
};
233+
234+
/** head /v1/avatar/send */
235+
type HeadV1AvatarSendPositiveVariant1 = undefined;
236+
237+
/** head /v1/avatar/send */
238+
interface HeadV1AvatarSendPositiveResponseVariants {
239+
200: HeadV1AvatarSendPositiveVariant1;
240+
}
241+
242+
/** head /v1/avatar/send */
243+
type HeadV1AvatarSendNegativeVariant1 = string;
244+
245+
/** head /v1/avatar/send */
246+
interface HeadV1AvatarSendNegativeResponseVariants {
247+
400: HeadV1AvatarSendNegativeVariant1;
248+
}
249+
183250
/** get /v1/avatar/stream */
184251
type GetV1AvatarStreamInput = {
185252
userId: string;
@@ -201,6 +268,27 @@ interface GetV1AvatarStreamNegativeResponseVariants {
201268
400: GetV1AvatarStreamNegativeVariant1;
202269
}
203270

271+
/** head /v1/avatar/stream */
272+
type HeadV1AvatarStreamInput = {
273+
userId: string;
274+
};
275+
276+
/** head /v1/avatar/stream */
277+
type HeadV1AvatarStreamPositiveVariant1 = undefined;
278+
279+
/** head /v1/avatar/stream */
280+
interface HeadV1AvatarStreamPositiveResponseVariants {
281+
200: HeadV1AvatarStreamPositiveVariant1;
282+
}
283+
284+
/** head /v1/avatar/stream */
285+
type HeadV1AvatarStreamNegativeVariant1 = string;
286+
287+
/** head /v1/avatar/stream */
288+
interface HeadV1AvatarStreamNegativeResponseVariants {
289+
400: HeadV1AvatarStreamNegativeVariant1;
290+
}
291+
204292
/** post /v1/avatar/upload */
205293
type PostV1AvatarUploadInput = {
206294
avatar: any;
@@ -292,6 +380,28 @@ interface GetV1EventsStreamNegativeResponseVariants {
292380
400: GetV1EventsStreamNegativeVariant1;
293381
}
294382

383+
/** head /v1/events/stream */
384+
type HeadV1EventsStreamInput = {
385+
/** @deprecated for testing error response */
386+
trigger?: string | undefined;
387+
};
388+
389+
/** head /v1/events/stream */
390+
type HeadV1EventsStreamPositiveVariant1 = undefined;
391+
392+
/** head /v1/events/stream */
393+
interface HeadV1EventsStreamPositiveResponseVariants {
394+
200: HeadV1EventsStreamPositiveVariant1;
395+
}
396+
397+
/** head /v1/events/stream */
398+
type HeadV1EventsStreamNegativeVariant1 = string;
399+
400+
/** head /v1/events/stream */
401+
interface HeadV1EventsStreamNegativeResponseVariants {
402+
400: HeadV1EventsStreamNegativeVariant1;
403+
}
404+
295405
/** post /v1/forms/feedback */
296406
type PostV1FormsFeedbackInput = {
297407
name: string;
@@ -338,56 +448,76 @@ export type Path =
338448
| "/v1/events/stream"
339449
| "/v1/forms/feedback";
340450

341-
export type Method = "get" | "post" | "put" | "delete" | "patch";
451+
export type Method = "get" | "post" | "put" | "delete" | "patch" | "head";
342452

343453
export interface Input {
344454
"get /v1/user/retrieve": GetV1UserRetrieveInput;
455+
"head /v1/user/retrieve": HeadV1UserRetrieveInput;
345456
"delete /v1/user/:id/remove": DeleteV1UserIdRemoveInput;
346457
"patch /v1/user/:id": PatchV1UserIdInput;
347458
"post /v1/user/create": PostV1UserCreateInput;
348459
"get /v1/user/list": GetV1UserListInput;
460+
"head /v1/user/list": HeadV1UserListInput;
349461
/** @deprecated */
350462
"get /v1/avatar/send": GetV1AvatarSendInput;
463+
/** @deprecated */
464+
"head /v1/avatar/send": HeadV1AvatarSendInput;
351465
"get /v1/avatar/stream": GetV1AvatarStreamInput;
466+
"head /v1/avatar/stream": HeadV1AvatarStreamInput;
352467
"post /v1/avatar/upload": PostV1AvatarUploadInput;
353468
"post /v1/avatar/raw": PostV1AvatarRawInput;
354469
"get /v1/events/stream": GetV1EventsStreamInput;
470+
"head /v1/events/stream": HeadV1EventsStreamInput;
355471
"post /v1/forms/feedback": PostV1FormsFeedbackInput;
356472
}
357473

358474
export interface PositiveResponse {
359475
"get /v1/user/retrieve": SomeOf<GetV1UserRetrievePositiveResponseVariants>;
476+
"head /v1/user/retrieve": SomeOf<HeadV1UserRetrievePositiveResponseVariants>;
360477
"delete /v1/user/:id/remove": SomeOf<DeleteV1UserIdRemovePositiveResponseVariants>;
361478
"patch /v1/user/:id": SomeOf<PatchV1UserIdPositiveResponseVariants>;
362479
"post /v1/user/create": SomeOf<PostV1UserCreatePositiveResponseVariants>;
363480
"get /v1/user/list": SomeOf<GetV1UserListPositiveResponseVariants>;
481+
"head /v1/user/list": SomeOf<HeadV1UserListPositiveResponseVariants>;
364482
/** @deprecated */
365483
"get /v1/avatar/send": SomeOf<GetV1AvatarSendPositiveResponseVariants>;
484+
/** @deprecated */
485+
"head /v1/avatar/send": SomeOf<HeadV1AvatarSendPositiveResponseVariants>;
366486
"get /v1/avatar/stream": SomeOf<GetV1AvatarStreamPositiveResponseVariants>;
487+
"head /v1/avatar/stream": SomeOf<HeadV1AvatarStreamPositiveResponseVariants>;
367488
"post /v1/avatar/upload": SomeOf<PostV1AvatarUploadPositiveResponseVariants>;
368489
"post /v1/avatar/raw": SomeOf<PostV1AvatarRawPositiveResponseVariants>;
369490
"get /v1/events/stream": SomeOf<GetV1EventsStreamPositiveResponseVariants>;
491+
"head /v1/events/stream": SomeOf<HeadV1EventsStreamPositiveResponseVariants>;
370492
"post /v1/forms/feedback": SomeOf<PostV1FormsFeedbackPositiveResponseVariants>;
371493
}
372494

373495
export interface NegativeResponse {
374496
"get /v1/user/retrieve": SomeOf<GetV1UserRetrieveNegativeResponseVariants>;
497+
"head /v1/user/retrieve": SomeOf<HeadV1UserRetrieveNegativeResponseVariants>;
375498
"delete /v1/user/:id/remove": SomeOf<DeleteV1UserIdRemoveNegativeResponseVariants>;
376499
"patch /v1/user/:id": SomeOf<PatchV1UserIdNegativeResponseVariants>;
377500
"post /v1/user/create": SomeOf<PostV1UserCreateNegativeResponseVariants>;
378501
"get /v1/user/list": SomeOf<GetV1UserListNegativeResponseVariants>;
502+
"head /v1/user/list": SomeOf<HeadV1UserListNegativeResponseVariants>;
379503
/** @deprecated */
380504
"get /v1/avatar/send": SomeOf<GetV1AvatarSendNegativeResponseVariants>;
505+
/** @deprecated */
506+
"head /v1/avatar/send": SomeOf<HeadV1AvatarSendNegativeResponseVariants>;
381507
"get /v1/avatar/stream": SomeOf<GetV1AvatarStreamNegativeResponseVariants>;
508+
"head /v1/avatar/stream": SomeOf<HeadV1AvatarStreamNegativeResponseVariants>;
382509
"post /v1/avatar/upload": SomeOf<PostV1AvatarUploadNegativeResponseVariants>;
383510
"post /v1/avatar/raw": SomeOf<PostV1AvatarRawNegativeResponseVariants>;
384511
"get /v1/events/stream": SomeOf<GetV1EventsStreamNegativeResponseVariants>;
512+
"head /v1/events/stream": SomeOf<HeadV1EventsStreamNegativeResponseVariants>;
385513
"post /v1/forms/feedback": SomeOf<PostV1FormsFeedbackNegativeResponseVariants>;
386514
}
387515

388516
export interface EncodedResponse {
389517
"get /v1/user/retrieve": GetV1UserRetrievePositiveResponseVariants &
390518
GetV1UserRetrieveNegativeResponseVariants;
519+
"head /v1/user/retrieve": HeadV1UserRetrievePositiveResponseVariants &
520+
HeadV1UserRetrieveNegativeResponseVariants;
391521
"delete /v1/user/:id/remove": DeleteV1UserIdRemovePositiveResponseVariants &
392522
DeleteV1UserIdRemoveNegativeResponseVariants;
393523
"patch /v1/user/:id": PatchV1UserIdPositiveResponseVariants &
@@ -396,17 +526,26 @@ export interface EncodedResponse {
396526
PostV1UserCreateNegativeResponseVariants;
397527
"get /v1/user/list": GetV1UserListPositiveResponseVariants &
398528
GetV1UserListNegativeResponseVariants;
529+
"head /v1/user/list": HeadV1UserListPositiveResponseVariants &
530+
HeadV1UserListNegativeResponseVariants;
399531
/** @deprecated */
400532
"get /v1/avatar/send": GetV1AvatarSendPositiveResponseVariants &
401533
GetV1AvatarSendNegativeResponseVariants;
534+
/** @deprecated */
535+
"head /v1/avatar/send": HeadV1AvatarSendPositiveResponseVariants &
536+
HeadV1AvatarSendNegativeResponseVariants;
402537
"get /v1/avatar/stream": GetV1AvatarStreamPositiveResponseVariants &
403538
GetV1AvatarStreamNegativeResponseVariants;
539+
"head /v1/avatar/stream": HeadV1AvatarStreamPositiveResponseVariants &
540+
HeadV1AvatarStreamNegativeResponseVariants;
404541
"post /v1/avatar/upload": PostV1AvatarUploadPositiveResponseVariants &
405542
PostV1AvatarUploadNegativeResponseVariants;
406543
"post /v1/avatar/raw": PostV1AvatarRawPositiveResponseVariants &
407544
PostV1AvatarRawNegativeResponseVariants;
408545
"get /v1/events/stream": GetV1EventsStreamPositiveResponseVariants &
409546
GetV1EventsStreamNegativeResponseVariants;
547+
"head /v1/events/stream": HeadV1EventsStreamPositiveResponseVariants &
548+
HeadV1EventsStreamNegativeResponseVariants;
410549
"post /v1/forms/feedback": PostV1FormsFeedbackPositiveResponseVariants &
411550
PostV1FormsFeedbackNegativeResponseVariants;
412551
}
@@ -415,6 +554,9 @@ export interface Response {
415554
"get /v1/user/retrieve":
416555
| PositiveResponse["get /v1/user/retrieve"]
417556
| NegativeResponse["get /v1/user/retrieve"];
557+
"head /v1/user/retrieve":
558+
| PositiveResponse["head /v1/user/retrieve"]
559+
| NegativeResponse["head /v1/user/retrieve"];
418560
"delete /v1/user/:id/remove":
419561
| PositiveResponse["delete /v1/user/:id/remove"]
420562
| NegativeResponse["delete /v1/user/:id/remove"];
@@ -427,13 +569,23 @@ export interface Response {
427569
"get /v1/user/list":
428570
| PositiveResponse["get /v1/user/list"]
429571
| NegativeResponse["get /v1/user/list"];
572+
"head /v1/user/list":
573+
| PositiveResponse["head /v1/user/list"]
574+
| NegativeResponse["head /v1/user/list"];
430575
/** @deprecated */
431576
"get /v1/avatar/send":
432577
| PositiveResponse["get /v1/avatar/send"]
433578
| NegativeResponse["get /v1/avatar/send"];
579+
/** @deprecated */
580+
"head /v1/avatar/send":
581+
| PositiveResponse["head /v1/avatar/send"]
582+
| NegativeResponse["head /v1/avatar/send"];
434583
"get /v1/avatar/stream":
435584
| PositiveResponse["get /v1/avatar/stream"]
436585
| NegativeResponse["get /v1/avatar/stream"];
586+
"head /v1/avatar/stream":
587+
| PositiveResponse["head /v1/avatar/stream"]
588+
| NegativeResponse["head /v1/avatar/stream"];
437589
"post /v1/avatar/upload":
438590
| PositiveResponse["post /v1/avatar/upload"]
439591
| NegativeResponse["post /v1/avatar/upload"];
@@ -443,6 +595,9 @@ export interface Response {
443595
"get /v1/events/stream":
444596
| PositiveResponse["get /v1/events/stream"]
445597
| NegativeResponse["get /v1/events/stream"];
598+
"head /v1/events/stream":
599+
| PositiveResponse["head /v1/events/stream"]
600+
| NegativeResponse["head /v1/events/stream"];
446601
"post /v1/forms/feedback":
447602
| PositiveResponse["post /v1/forms/feedback"]
448603
| NegativeResponse["post /v1/forms/feedback"];
@@ -452,15 +607,20 @@ export type Request = keyof Input;
452607

453608
export const endpointTags = {
454609
"get /v1/user/retrieve": ["users"],
610+
"head /v1/user/retrieve": ["users"],
455611
"delete /v1/user/:id/remove": ["users"],
456612
"patch /v1/user/:id": ["users"],
457613
"post /v1/user/create": ["users"],
458614
"get /v1/user/list": ["users"],
615+
"head /v1/user/list": ["users"],
459616
"get /v1/avatar/send": ["files", "users"],
617+
"head /v1/avatar/send": ["files", "users"],
460618
"get /v1/avatar/stream": ["users", "files"],
619+
"head /v1/avatar/stream": ["users", "files"],
461620
"post /v1/avatar/upload": ["files"],
462621
"post /v1/avatar/raw": ["files"],
463622
"get /v1/events/stream": ["subscriptions"],
623+
"head /v1/events/stream": ["subscriptions"],
464624
"post /v1/forms/feedback": ["forms"],
465625
};
466626

@@ -486,7 +646,7 @@ export type Implementation<T = unknown> = (
486646
) => Promise<any>;
487647

488648
const defaultImplementation: Implementation = async (method, path, params) => {
489-
const hasBody = !["get", "delete"].includes(method);
649+
const hasBody = !["get", "head", "delete"].includes(method);
490650
const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`;
491651
const response = await fetch(
492652
new URL(`${path}${searchParams}`, "http://localhost:8090"),

0 commit comments

Comments
 (0)