Skip to content

Commit 4579dd9

Browse files
SteveGT96jonty-compclaude
committed
feat: add support for the HTTP QUERY method (RFC 10008)
QUERY is a safe, idempotent HTTP method that carries a request body, filling the gap between GET (no body) and POST (neither safe nor idempotent). OpenAPI 3.2 recognises `query` as a Path Item verb, but openapi-typescript treated it as an unknown property and dropped it, so no types were generated and openapi-fetch had no way to call it. - openapi-typescript-helpers: add "query" to `HttpMethod` - openapi-typescript: emit a `query` operation for Path Items that declare one, and include it in path-param extraction and the paths enum. Path Items without a `query` operation are left untouched, so no existing generated output changes. - openapi-fetch: add `client.QUERY()` and `QUERY` on the path-based client Follow-up to #2844, which took the same approach but emitted `query?: never` on every Path Item of every document — churning ~4,400 lines of examples and fixtures, the concern its author raised for review. Emitting `query` only where it is declared keeps generated output byte-identical for documents that don't use it. Co-authored-by: Jonty Sewell <jonty@vallaton.co.uk> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9d133bd commit 4579dd9

14 files changed

Lines changed: 518 additions & 7 deletions

File tree

.changeset/lucky-jokes-search.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"openapi-typescript-helpers": minor
3+
"openapi-typescript": minor
4+
"openapi-fetch": minor
5+
---
6+
7+
Add support for the HTTP `QUERY` method ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)).
8+
9+
`query` is recognised as a path item verb in [OpenAPI 3.2](https://spec.openapis.org/oas/v3.2.0.html#path-item-object). openapi-typescript previously dropped it as an unknown property, so no types were emitted for it.
10+
11+
- `openapi-typescript` now emits a `query` operation for the path items that declare one. Path items without a `query` operation are unchanged, so existing generated output is not affected.
12+
- `openapi-typescript-helpers` adds `"query"` to `HttpMethod`.
13+
- `openapi-fetch` adds `client.QUERY()` (and `QUERY` on the path-based client), which sends a request body like `POST` while preserving QUERY's safe/idempotent semantics.

docs/openapi-fetch/api.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,46 @@ client.GET("/my-url", options);
4343
| `middleware` | `Middleware[]` | [See docs](/openapi-fetch/middleware-auth) |
4444
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal`, …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options)) |
4545

46+
## Request methods
47+
48+
A client exposes one method per HTTP verb: `.GET()`, `.PUT()`, `.POST()`, `.DELETE()`, `.OPTIONS()`, `.HEAD()`, `.PATCH()`, `.TRACE()`, and `.QUERY()`. Each one is typed against the operations your schema declares for that verb, so only the paths that actually support a verb are accepted.
49+
50+
### QUERY
51+
52+
[QUERY](https://www.rfc-editor.org/rfc/rfc10008) is a safe, idempotent method that carries a request body — it fills the gap between `GET` (no body) and `POST` (neither safe nor idempotent), and is useful for searches whose parameters are too large or too structured for a URL.
53+
54+
`query` is recognised as a path item verb in [OpenAPI 3.2](https://spec.openapis.org/oas/v3.2.0.html#path-item-object):
55+
56+
```yaml
57+
paths:
58+
/resources:
59+
query:
60+
requestBody:
61+
required: true
62+
content:
63+
application/json:
64+
schema:
65+
type: object
66+
properties:
67+
ids:
68+
type: array
69+
items:
70+
type: integer
71+
responses:
72+
200:
73+
description: OK
74+
```
75+
76+
```ts
77+
const { data, error } = await client.QUERY("/resources", {
78+
body: { ids: [1, 2, 3] },
79+
});
80+
```
81+
82+
Because QUERY is safe and idempotent, sending the same request twice must be equivalent to sending it once. openapi-fetch keeps that guarantee: it adds no per-request state of its own, and it does not read or mutate the `body` and `params` you pass in, so the same options object can be reused across retries.
83+
84+
Note that support is only as good as the runtime and the server. `QUERY` requests are constructed with the standard `Request` API, so any environment that rejects the verb (or any intermediary that doesn't forward it) will fail the request.
85+
4686
## wrapAsPathBasedClient
4787

4888
**wrapAsPathBasedClient** wraps the result of `createClient()` to return a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)-based client that allows path-indexed calls:

packages/openapi-fetch/src/index.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,14 @@ export interface Client<Paths extends {}, Media extends MediaType = MediaType> {
231231
request: ClientRequestMethod<Paths, Media>;
232232
/** Call a GET endpoint */
233233
GET: ClientMethod<Paths, "get", Media>;
234+
/**
235+
* Call a QUERY endpoint
236+
*
237+
* QUERY is safe and idempotent (RFC 10008): unlike POST it may be retried or
238+
* cached, and unlike GET it carries a request body.
239+
* @see https://www.rfc-editor.org/rfc/rfc10008
240+
*/
241+
QUERY: ClientMethod<Paths, "query", Media>;
234242
/** Call a PUT endpoint */
235243
PUT: ClientMethod<Paths, "put", Media>;
236244
/** Call a POST endpoint */

packages/openapi-fetch/src/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,13 @@ export default function createClient(clientOptions) {
287287
GET(url, init) {
288288
return coreFetch(url, { ...init, method: "GET" });
289289
},
290+
/**
291+
* Call a QUERY endpoint
292+
* @see https://www.rfc-editor.org/rfc/rfc10008 (safe & idempotent; carries a request body)
293+
*/
294+
QUERY(url, init) {
295+
return coreFetch(url, { ...init, method: "QUERY" });
296+
},
290297
/** Call a PUT endpoint */
291298
PUT(url, init) {
292299
return coreFetch(url, { ...init, method: "PUT" });
@@ -348,6 +355,9 @@ class PathCallForwarder {
348355
GET = (init) => {
349356
return this.client.GET(this.url, init);
350357
};
358+
QUERY = (init) => {
359+
return this.client.QUERY(this.url, init);
360+
};
351361
PUT = (init) => {
352362
return this.client.PUT(this.url, init);
353363
};
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { describe, expect, test } from "vitest";
2+
import { wrapAsPathBasedClient } from "../../src/index.js";
3+
import { createObservedClient, headersToObj } from "../helpers.js";
4+
import type { paths } from "./schemas/query.js";
5+
6+
describe("QUERY", () => {
7+
test("sends the correct method", async () => {
8+
let method = "";
9+
const client = createObservedClient<paths>({}, async (req) => {
10+
method = req.method;
11+
return Response.json({});
12+
});
13+
await client.QUERY("/resources/{id}", {
14+
params: { path: { id: 123 } },
15+
body: { ids: [1, 2, 3] },
16+
});
17+
expect(method).toBe("QUERY");
18+
});
19+
20+
describe("request body", () => {
21+
test("requires necessary requestBodies", async () => {
22+
const client = createObservedClient<paths>({});
23+
24+
// expect error on missing `body`
25+
await client.QUERY("/resources/{id}", {
26+
params: { path: { id: 1 } },
27+
// @ts-expect-error
28+
body: undefined,
29+
});
30+
31+
// expect error on missing required fields
32+
await client.QUERY("/resources/{id}", {
33+
params: { path: { id: 1 } },
34+
// @ts-expect-error
35+
body: {},
36+
});
37+
38+
// expect present body to be good enough
39+
await client.QUERY("/resources/{id}", {
40+
params: { path: { id: 1 } },
41+
body: { ids: [1, 2, 3] },
42+
});
43+
});
44+
45+
test("requestBody with required: false", async () => {
46+
const client = createObservedClient<paths>({});
47+
48+
// assert missing `body` doesn't raise a TS error
49+
await client.QUERY("/resources-optional", {
50+
params: { path: { id: 1 } },
51+
});
52+
53+
// assert error on type mismatch
54+
await client.QUERY("/resources-optional", {
55+
params: { path: { id: 1 } },
56+
body: {
57+
// @ts-expect-error
58+
ids: "not-an-array",
59+
},
60+
});
61+
});
62+
});
63+
64+
test("sends correct options, returns success", async () => {
65+
const mockData = { status: "ok" };
66+
let actualPathname = "";
67+
const client = createObservedClient<paths>({}, async (req) => {
68+
actualPathname = new URL(req.url).pathname;
69+
return Response.json(mockData, { status: 200 });
70+
});
71+
72+
const { data, error, response } = await client.QUERY("/resources/{id}", {
73+
params: { path: { id: 456 } },
74+
body: { ids: [7, 8, 9] },
75+
});
76+
77+
// assert correct URL was called
78+
expect(actualPathname).toBe("/resources/456");
79+
80+
// assert correct data was returned
81+
expect(data).toEqual(mockData);
82+
expect(response.status).toBe(200);
83+
84+
// assert error is empty
85+
expect(error).toBeUndefined();
86+
});
87+
88+
test("sends the request body with a Content-Type", async () => {
89+
// RFC 10008 §2: a QUERY request has content, so it must identify its media type
90+
let actualBody = "";
91+
let actualContentType: string | null = "";
92+
const client = createObservedClient<paths>({}, async (req) => {
93+
actualBody = await req.text();
94+
actualContentType = req.headers.get("Content-Type");
95+
return Response.json({});
96+
});
97+
98+
await client.QUERY("/resources/{id}", {
99+
params: { path: { id: 123 } },
100+
body: { ids: [1, 2, 3] },
101+
});
102+
103+
expect(actualBody).toBe(JSON.stringify({ ids: [1, 2, 3] }));
104+
expect(actualContentType).toBe("application/json");
105+
});
106+
107+
// QUERY is defined as safe & idempotent (RFC 10008 §2), so identical calls must stay
108+
// identical on the wire: the client may not add per-request state of its own, and it
109+
// may not consume or mutate the caller’s `init`.
110+
describe("idempotency", () => {
111+
test("repeated identical calls produce identical requests", async () => {
112+
const observed: { method: string; url: string; headers: Record<string, string>; body: string }[] = [];
113+
const client = createObservedClient<paths>({}, async (req) => {
114+
observed.push({
115+
method: req.method,
116+
url: req.url,
117+
headers: headersToObj(req.headers),
118+
body: await req.text(),
119+
});
120+
return Response.json({});
121+
});
122+
123+
const init = {
124+
params: { path: { id: 123 } },
125+
body: { ids: [1, 2, 3] },
126+
};
127+
128+
// reuse the exact same init object, to assert it is not consumed or mutated
129+
await client.QUERY("/resources/{id}", init);
130+
await client.QUERY("/resources/{id}", init);
131+
132+
expect(observed).toHaveLength(2);
133+
expect(observed[1]).toEqual(observed[0]);
134+
expect(init).toEqual({ params: { path: { id: 123 } }, body: { ids: [1, 2, 3] } });
135+
});
136+
137+
test("is safe: no request body is read or replayed across calls", async () => {
138+
// a request body may only be consumed once, so each call must build its own Request
139+
const bodies: string[] = [];
140+
const client = createObservedClient<paths>({}, async (req) => {
141+
bodies.push(await req.text());
142+
return Response.json({});
143+
});
144+
145+
await client.QUERY("/resources/{id}", { params: { path: { id: 1 } }, body: { ids: [1] } });
146+
await client.QUERY("/resources/{id}", { params: { path: { id: 1 } }, body: { ids: [1] } });
147+
148+
expect(bodies).toEqual([JSON.stringify({ ids: [1] }), JSON.stringify({ ids: [1] })]);
149+
});
150+
});
151+
152+
test("works with the path based client", async () => {
153+
let method = "";
154+
let actualPathname = "";
155+
const client = wrapAsPathBasedClient<paths>(
156+
createObservedClient<paths>({}, async (req) => {
157+
method = req.method;
158+
actualPathname = new URL(req.url).pathname;
159+
return Response.json({});
160+
}),
161+
);
162+
163+
await client["/resources/{id}"].QUERY({
164+
params: { path: { id: 123 } },
165+
body: { ids: [1, 2, 3] },
166+
});
167+
168+
expect(method).toBe("QUERY");
169+
expect(actualPathname).toBe("/resources/123");
170+
});
171+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* This file was auto-generated by openapi-typescript.
3+
* Do not make direct changes to the file.
4+
*/
5+
6+
export interface paths {
7+
"/resources/{id}": {
8+
parameters: {
9+
query?: never;
10+
header?: never;
11+
path: {
12+
id: number;
13+
};
14+
cookie?: never;
15+
};
16+
get?: never;
17+
put?: never;
18+
post?: never;
19+
delete?: never;
20+
options?: never;
21+
head?: never;
22+
patch?: never;
23+
trace?: never;
24+
query: {
25+
parameters: {
26+
query?: never;
27+
header?: never;
28+
path: {
29+
id: number;
30+
};
31+
cookie?: never;
32+
};
33+
requestBody: components["requestBodies"]["QueryPayload"];
34+
responses: {
35+
200: components["responses"]["QueryResult"];
36+
/** @description Not Found */
37+
404: {
38+
headers: {
39+
[name: string]: unknown;
40+
};
41+
content: {
42+
"application/json": {
43+
error?: string;
44+
};
45+
};
46+
};
47+
};
48+
};
49+
};
50+
"/resources-optional": {
51+
parameters: {
52+
query?: never;
53+
header?: never;
54+
path: {
55+
id: number;
56+
};
57+
cookie?: never;
58+
};
59+
get?: never;
60+
put?: never;
61+
post?: never;
62+
delete?: never;
63+
options?: never;
64+
head?: never;
65+
patch?: never;
66+
trace?: never;
67+
query: {
68+
parameters: {
69+
query?: never;
70+
header?: never;
71+
path: {
72+
id: number;
73+
};
74+
cookie?: never;
75+
};
76+
requestBody?: components["requestBodies"]["QueryPayloadOptional"];
77+
responses: {
78+
200: components["responses"]["QueryResult"];
79+
};
80+
};
81+
};
82+
}
83+
export type webhooks = Record<string, never>;
84+
export interface components {
85+
schemas: {
86+
QueryPayload: {
87+
ids: number[];
88+
};
89+
};
90+
responses: {
91+
/** @description OK */
92+
QueryResult: {
93+
headers: {
94+
[name: string]: unknown;
95+
};
96+
content: {
97+
"application/json": {
98+
status?: string;
99+
data?: Record<string, never>[];
100+
};
101+
};
102+
};
103+
};
104+
parameters: never;
105+
requestBodies: {
106+
QueryPayload: {
107+
content: {
108+
"application/json": components["schemas"]["QueryPayload"];
109+
};
110+
};
111+
QueryPayloadOptional: {
112+
content: {
113+
"application/json": components["schemas"]["QueryPayload"];
114+
};
115+
};
116+
};
117+
headers: never;
118+
pathItems: never;
119+
}
120+
export type $defs = Record<string, never>;
121+
export type operations = Record<string, never>;

0 commit comments

Comments
 (0)