Skip to content

Commit b0bce41

Browse files
authored
feat(v29): Supporting QUERY method (#3479)
# Supporting HTTP QUERY Method in express-zod-api ## Background: What Is HTTP QUERY? The HTTP QUERY method is defined in **RFC 10008** (published June 2026, Standards Track). In essence, QUERY is like GET but with a body. It is a **safe and idempotent** request method that can carry request content — a combination of properties that no existing standard HTTP method fully satisfies. QUERY requests that the target resource process the enclosed content (the query) and respond with the result, without changing resource state. The server uses the `Content-Type` to determine how to interpret the body, and clients can discover QUERY support via the `Accept-Query` response header. **Key properties:** | Property | QUERY | GET | POST | |---|---|---|---| | Safe | Yes | Yes | No | | Idempotent | Yes | Yes | No | | Can have body | Yes | No | Yes | | Cacheable | Yes | Yes | With care | | Request content in URI | Not required | Required | Not required | ## Why Use QUERY When GET and POST Already Exist? ### Problems with GET for queries GET encodes the entire query in the URI. This has several drawbacks: - **URI size limits** — many implementations (servers, proxies, CDNs) cap URIs at ~8 KB, making complex queries impossible. - **Encoding overhead** — binary data, complex filters, or structured query languages must be URI-encoded, inflating size and reducing readability. - **Exposure** — URIs are more likely to be logged by intermediaries and bookmarked by users, potentially leaking query details. ### Problems with POST for queries POST is neither safe nor idempotent. Using it for queries means: - **No safe retries** — intermediaries and clients cannot automatically retry a POST on network failure, because the request might have mutated state. - **No caching** — POST responses are not cacheable by default, so repeated identical queries cannot be served from a cache. - **Semantic mismatch** — POST implies creation or mutation; using it for read-only queries confuses tooling, monitoring, and human readers. ### How QUERY bridges the gap QUERY is semantically a read operation (safe, idempotent, cacheable) but with body support like POST. This makes it the natural choice for: - Complex search/filter APIs (GraphQL, Elasticsearch, SQL-over-HTTP) - APIs with large or structured query payloads - Any read operation where the query exceeds URI size limits - Safe operations that need content negotiation via `Content-Type` ## What Would It Take to Add QUERY Support? ### Current state - **Node.js** (22.19+, 24.x, 26.x) already has `QUERY` in `http.METHODS` — the parser accepts it. - **Express 5.1.x** at runtime handles QUERY correctly via its Layer-based routing — Express does not restrict which methods can be routed. - **Express types** (`@types/express-serve-static-core`) do not list `query` on `IRouter`. This is a deliberate policy: Express adds type entries only when the IETF method reaches sufficient maturity. (For reference, Express also does not type `"query"` despite it being the `?key=val` part of a URL — the name collision is coincidental.) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for the HTTP **QUERY** method (RFC 10008), including typed client/endpoints and default input handling (query, body, and path params). * Documentation improvements for OpenAPI **3.2.0** (including SSE schema handling) and enhanced security depiction (OAuth2 device authorization). * **Bug Fixes** * Improved routing initialization to validate **QUERY** registration and raise clearer errors when unsupported. * **Documentation** * Updated generated docs/snapshots and example payload structure (`value` → `dataValue`), and adjusted documentation constructor inputs to `info` + `server`. * **Tests** * Expanded coverage for **QUERY** routing, extraction, typing, and documentation output. * **Chores** * Updated server startup lifecycle to treat hooks as synchronous, and removed the deprecated `Integration.create()` factory. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a6f86b6 commit b0bce41

21 files changed

Lines changed: 223 additions & 160 deletions

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
- The static async method `Integration::create()` removed — use `new Integration()` instead;
99
- Server lifecycle hooks (`beforeRouting` and `afterRouting` config options) are no longer async;
1010
- The `createServer()` function is now synchronous — that should simplify the daily routines for beginners;
11-
- The Documentation generator is featuring the OpenAPI 3.2.0 with better SSE support and other features;
1211
- Changes to `Documentation` constructor:
1312
- `serverUrl` renamed to `server` and now also accepts OpenAPI's ServerObject;
1413
- `title` and `version` must be wrapped into `info`, assignable with OpenAPI's InfoObject;
14+
- The Documentation generator is featuring the OpenAPI 3.2.0 with better SSE support and other features;
15+
- Added HTTP QUERY method support (RFC 10008):
16+
- The QUERY method is like GET but with a body — safe, idempotent, and cacheable;
17+
- Default input sources for QUERY: `["query", "body", "params"]` (from the lowest priority to highest);
18+
- Supported by `Integration` and `Documentation` generators.
1519

1620
```diff
1721
- await Integration.create({});

dataflow.svg

Lines changed: 1 addition & 1 deletion
Loading

example/endpoints/list-users.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const users = [
2020
* */
2121
export const listUsersEndpoint = arrayRespondingFactory.build({
2222
tag: "users",
23+
method: "query",
2324
input: z.object({
2425
roles: z.array(roleSchema).optional(),
2526
}),

example/example.client.ts

Lines changed: 29 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -160,49 +160,28 @@ interface PostV1UserCreateNegativeResponseVariants {
160160
500: PostV1UserCreateNegativeVariant2;
161161
}
162162

163-
/** get /v1/user/list */
164-
type GetV1UserListInput = {
163+
/** query /v1/user/list */
164+
type QueryV1UserListInput = {
165165
roles?: ("manager" | "operator" | "admin")[] | undefined;
166166
};
167167

168-
/** get /v1/user/list */
169-
type GetV1UserListPositiveVariant1 = {
168+
/** query /v1/user/list */
169+
type QueryV1UserListPositiveVariant1 = {
170170
name: string;
171171
role: "manager" | "operator" | "admin";
172172
}[];
173173

174-
/** get /v1/user/list */
175-
interface GetV1UserListPositiveResponseVariants {
176-
200: GetV1UserListPositiveVariant1;
174+
/** query /v1/user/list */
175+
interface QueryV1UserListPositiveResponseVariants {
176+
200: QueryV1UserListPositiveVariant1;
177177
}
178178

179-
/** get /v1/user/list */
180-
type GetV1UserListNegativeVariant1 = string;
179+
/** query /v1/user/list */
180+
type QueryV1UserListNegativeVariant1 = string;
181181

182-
/** get /v1/user/list */
183-
interface GetV1UserListNegativeResponseVariants {
184-
400: GetV1UserListNegativeVariant1;
185-
}
186-
187-
/** head /v1/user/list */
188-
type HeadV1UserListInput = {
189-
roles?: ("manager" | "operator" | "admin")[] | undefined;
190-
};
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 = undefined;
202-
203-
/** head /v1/user/list */
204-
interface HeadV1UserListNegativeResponseVariants {
205-
400: HeadV1UserListNegativeVariant1;
182+
/** query /v1/user/list */
183+
interface QueryV1UserListNegativeResponseVariants {
184+
400: QueryV1UserListNegativeVariant1;
206185
}
207186

208187
/** post /v1/login */
@@ -557,16 +536,22 @@ export type Path =
557536
| "/v1/forms/feedback"
558537
| "/v2/users/list";
559538

560-
export type Method = "get" | "post" | "put" | "delete" | "patch" | "head";
539+
export type Method =
540+
| "get"
541+
| "post"
542+
| "put"
543+
| "delete"
544+
| "patch"
545+
| "query"
546+
| "head";
561547

562548
export interface Input {
563549
"get /v1/user/retrieve": GetV1UserRetrieveInput;
564550
"head /v1/user/retrieve": HeadV1UserRetrieveInput;
565551
"delete /v1/user/:id/remove": DeleteV1UserIdRemoveInput;
566552
"patch /v1/user/:id": PatchV1UserIdInput;
567553
"post /v1/user/create": PostV1UserCreateInput;
568-
"get /v1/user/list": GetV1UserListInput;
569-
"head /v1/user/list": HeadV1UserListInput;
554+
"query /v1/user/list": QueryV1UserListInput;
570555
"post /v1/login": PostV1LoginInput;
571556
/** @deprecated */
572557
"get /v1/avatar/send": GetV1AvatarSendInput;
@@ -589,8 +574,7 @@ export interface PositiveResponse {
589574
"delete /v1/user/:id/remove": SomeOf<DeleteV1UserIdRemovePositiveResponseVariants>;
590575
"patch /v1/user/:id": SomeOf<PatchV1UserIdPositiveResponseVariants>;
591576
"post /v1/user/create": SomeOf<PostV1UserCreatePositiveResponseVariants>;
592-
"get /v1/user/list": SomeOf<GetV1UserListPositiveResponseVariants>;
593-
"head /v1/user/list": SomeOf<HeadV1UserListPositiveResponseVariants>;
577+
"query /v1/user/list": SomeOf<QueryV1UserListPositiveResponseVariants>;
594578
"post /v1/login": SomeOf<PostV1LoginPositiveResponseVariants>;
595579
/** @deprecated */
596580
"get /v1/avatar/send": SomeOf<GetV1AvatarSendPositiveResponseVariants>;
@@ -613,8 +597,7 @@ export interface NegativeResponse {
613597
"delete /v1/user/:id/remove": SomeOf<DeleteV1UserIdRemoveNegativeResponseVariants>;
614598
"patch /v1/user/:id": SomeOf<PatchV1UserIdNegativeResponseVariants>;
615599
"post /v1/user/create": SomeOf<PostV1UserCreateNegativeResponseVariants>;
616-
"get /v1/user/list": SomeOf<GetV1UserListNegativeResponseVariants>;
617-
"head /v1/user/list": SomeOf<HeadV1UserListNegativeResponseVariants>;
600+
"query /v1/user/list": SomeOf<QueryV1UserListNegativeResponseVariants>;
618601
"post /v1/login": SomeOf<PostV1LoginNegativeResponseVariants>;
619602
/** @deprecated */
620603
"get /v1/avatar/send": SomeOf<GetV1AvatarSendNegativeResponseVariants>;
@@ -642,10 +625,8 @@ export interface EncodedResponse {
642625
PatchV1UserIdNegativeResponseVariants;
643626
"post /v1/user/create": PostV1UserCreatePositiveResponseVariants &
644627
PostV1UserCreateNegativeResponseVariants;
645-
"get /v1/user/list": GetV1UserListPositiveResponseVariants &
646-
GetV1UserListNegativeResponseVariants;
647-
"head /v1/user/list": HeadV1UserListPositiveResponseVariants &
648-
HeadV1UserListNegativeResponseVariants;
628+
"query /v1/user/list": QueryV1UserListPositiveResponseVariants &
629+
QueryV1UserListNegativeResponseVariants;
649630
"post /v1/login": PostV1LoginPositiveResponseVariants &
650631
PostV1LoginNegativeResponseVariants;
651632
/** @deprecated */
@@ -690,12 +671,9 @@ export interface Response {
690671
"post /v1/user/create":
691672
| PositiveResponse["post /v1/user/create"]
692673
| NegativeResponse["post /v1/user/create"];
693-
"get /v1/user/list":
694-
| PositiveResponse["get /v1/user/list"]
695-
| NegativeResponse["get /v1/user/list"];
696-
"head /v1/user/list":
697-
| PositiveResponse["head /v1/user/list"]
698-
| NegativeResponse["head /v1/user/list"];
674+
"query /v1/user/list":
675+
| PositiveResponse["query /v1/user/list"]
676+
| NegativeResponse["query /v1/user/list"];
699677
"post /v1/login":
700678
| PositiveResponse["post /v1/login"]
701679
| NegativeResponse["post /v1/login"];
@@ -744,8 +722,7 @@ export const endpointTags = {
744722
"delete /v1/user/:id/remove": ["users"],
745723
"patch /v1/user/:id": ["users"],
746724
"post /v1/user/create": ["users"],
747-
"get /v1/user/list": ["users"],
748-
"head /v1/user/list": ["users"],
725+
"query /v1/user/list": ["users"],
749726
"post /v1/login": ["cookies"],
750727
"get /v1/avatar/send": ["files", "users"],
751728
"head /v1/avatar/send": ["files", "users"],

example/example.documentation.yaml

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -388,26 +388,28 @@ paths:
388388
- reason
389389
additionalProperties: false
390390
/v1/user/list:
391-
get:
392-
operationId: GetV1UserList
391+
query:
392+
operationId: QueryV1UserList
393393
tags:
394394
- users
395-
parameters:
396-
- name: roles
397-
in: query
398-
required: false
399-
description: GET /v1/user/list Parameter
400-
schema:
401-
type: array
402-
items:
403-
type: string
404-
enum:
405-
- manager
406-
- operator
407-
- admin
395+
requestBody:
396+
description: QUERY /v1/user/list Request body
397+
content:
398+
application/json:
399+
schema:
400+
type: object
401+
properties:
402+
roles:
403+
type: array
404+
items:
405+
type: string
406+
enum:
407+
- manager
408+
- operator
409+
- admin
408410
responses:
409411
"200":
410-
description: GET /v1/user/list Positive response
412+
description: QUERY /v1/user/list Positive response
411413
content:
412414
application/json:
413415
schema:
@@ -437,36 +439,14 @@ paths:
437439
- name: Patti Harrison
438440
role: admin
439441
"400":
440-
description: GET /v1/user/list Negative response
442+
description: QUERY /v1/user/list Negative response
441443
content:
442444
text/plain:
443445
schema:
444446
type: string
445447
examples:
446448
example1:
447449
dataValue: Sample error message
448-
head:
449-
operationId: HeadV1UserList
450-
tags:
451-
- users
452-
parameters:
453-
- name: roles
454-
in: query
455-
required: false
456-
description: HEAD /v1/user/list Parameter
457-
schema:
458-
type: array
459-
items:
460-
type: string
461-
enum:
462-
- manager
463-
- operator
464-
- admin
465-
responses:
466-
"200":
467-
description: HEAD /v1/user/list Positive response
468-
"400":
469-
description: HEAD /v1/user/list Negative response
470450
/v1/login:
471451
post:
472452
operationId: PostV1Login

example/index.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ describe("Example", async () => {
117117
signal,
118118
}) => {
119119
const response = await fetch(`http://localhost:${port}/v1/user/list`, {
120+
method: "QUERY",
120121
signal,
121122
});
122123
expect(response.status).toBe(200);

express-zod-api/src/common-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const defaultInputSources: InputSources = {
4848
put: ["body", "params"],
4949
patch: ["body", "params"],
5050
delete: ["query", "params"],
51+
query: ["query", "body", "params"],
5152
};
5253
const fallbackInputSources: InputSource[] = ["body", "query", "params"];
5354

express-zod-api/src/documentation-helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ export const depictRequestParams = ({
284284
(isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders))
285285
)
286286
return "header";
287-
if (isQueryEnabled) return "query";
287+
if (isQueryEnabled && method !== "query") return "query";
288288
};
289289

290290
return Object.entries(flat.properties).reduce<ParameterObject[]>(

express-zod-api/src/method.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ import type { IRouter } from "express";
22

33
export type SomeMethod = Lowercase<string>;
44

5-
type FamiliarMethod = Exclude<
6-
keyof IRouter,
7-
"param" | "use" | "route" | "stack"
8-
>;
5+
type FamiliarMethod =
6+
| Exclude<keyof IRouter, "param" | "use" | "route" | "stack" | "all">
7+
/** @todo remove when Express team added the "query" method into their types officially */
8+
| "query";
99

1010
export const methods = [
1111
"get",
1212
"post",
1313
"put",
1414
"delete",
1515
"patch",
16+
"query",
1617
] satisfies Array<FamiliarMethod>;
1718

1819
export const clientMethods = [
@@ -23,7 +24,7 @@ export const clientMethods = [
2324
/**
2425
* @desc Methods supported by the framework API to produce Endpoints on EndpointsFactory.
2526
* @see BuildProps
26-
* @example "get" | "post" | "put" | "delete" | "patch"
27+
* @example "get" | "post" | "put" | "delete" | "patch" | "query"
2728
* */
2829
export type Method = (typeof methods)[number];
2930

express-zod-api/src/routing.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { IRouter, RequestHandler } from "express";
1+
import type { IRouter, RequestHandler, IRouterMatcher } from "express";
22
import createHttpError from "http-errors";
33
import { isProduction } from "./common-helpers";
44
import type { CommonConfig } from "./config-type";
@@ -106,7 +106,10 @@ export const initRouting = ({ app, config, getLogger, ...rest }: InitProps) => {
106106
const logger = getLogger(request);
107107
return endpoint.execute({ request, response, logger, config });
108108
});
109-
app[method](path, ...handlers);
109+
/** @todo remove type assertion when Express teams adds the QUERY method into types officially */
110+
const register: (path: string, ...handlers: RequestHandler[]) => IRouter =
111+
(app as IRouter & { query: IRouterMatcher<IRouter> })[method];
112+
register.call(app, path, ...handlers);
110113
}
111114
if (config.hintAllowedMethods === false) continue;
112115
deprioritized.set(path, createWrongMethodHandler(accessMethods));

0 commit comments

Comments
 (0)