From 9d133bda6544caab82fef587af920081f460b484 Mon Sep 17 00:00:00 2001 From: Steve Tsala Date: Mon, 24 Aug 2026 07:31:55 -0400 Subject: [PATCH 1/2] fix(ci): unblock test-e2e by upgrading Playwright past the yauzl hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm exec playwright install --with-deps` has been hanging until the 6h GitHub Actions limit kills the job, so `test-e2e` has not completed on any PR since 2026-05-12. The hang is not in the system dependency install: apt finishes normally, then the job stalls right after the browser archive download reaches 100%, during extraction. The cause is a yauzl regression (thejoshwolfe/yauzl#168) where the `for await` over `openReadStream` never completes on Node 24.16.0+ and Node 26.x — see microsoft/playwright#40724, which was filed against Playwright 1.59.1 on Node 26, exactly this configuration. Playwright vendored the fix in 1.60.0; this repo has been pinned to 1.59.1 since 2026-04-01. The e2e job runs `node-version: latest`, which started resolving to Node 26 when 26.0.0 shipped on 2026-05-05 — the last green test-e2e run was 2026-05-05, and the first 6h hang was 2026-05-12. Renovate had already queued the 1.62.1 bump, but it is rate-limited in the dependency dashboard (#2173), and its devDependency automerge waits on a CI run that can no longer go green — so the update that fixes this is blocked by the problem it fixes. Also add `timeout-minutes` to the job: without it a future hang costs 6h of runner time per run, and in-progress job logs cannot be read via the API, which makes such a hang needlessly hard to diagnose. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 3 +++ package.json | 2 +- pnpm-lock.yaml | 38 +++++++++++++++++++------------------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014c1f885..7f19b0d58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - run: pnpm test test-e2e: runs-on: ubuntu-latest + # Browser installation has hung indefinitely before (microsoft/playwright#40724). + # Without a timeout, a hung job burns the full 6h runner limit before being killed. + timeout-minutes: 20 steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 diff --git a/package.json b/package.json index aa1921aba..d7e79f90f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@biomejs/biome": "2.4.14", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", - "@playwright/test": "1.59.1", + "@playwright/test": "1.62.1", "@size-limit/preset-small-lib": "12.1.0", "@types/node": "25.6.0", "prettier": "3.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d3b87bc5..cb3be3a61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: 2.31.0 version: 2.31.0(@types/node@25.6.0) '@playwright/test': - specifier: 1.59.1 - version: 1.59.1 + specifier: 1.62.1 + version: 1.62.1 '@size-limit/preset-small-lib': specifier: 12.1.0 version: 12.1.0(size-limit@12.1.0(jiti@2.6.1)) @@ -116,7 +116,7 @@ importers: dependencies: next: specifier: ^15.5.12 - version: 15.5.12(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.5.12(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) openapi-fetch: specifier: workspace:^ version: link:../.. @@ -1562,9 +1562,9 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - '@playwright/test@1.59.1': - resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} - engines: {node: '>=18'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} hasBin: true '@polka/url@1.0.0-next.29': @@ -3573,14 +3573,14 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - playwright-core@1.59.1: - resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.59.1: - resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true pluralize@8.0.0: @@ -5648,9 +5648,9 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 - '@playwright/test@1.59.1': + '@playwright/test@1.62.1': dependencies: - playwright: 1.59.1 + playwright: 1.62.1 '@polka/url@1.0.0-next.29': {} @@ -7585,7 +7585,7 @@ snapshots: neo-async@2.6.2: {} - next@15.5.12(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.5.12(@playwright/test@1.62.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.5.12 '@swc/helpers': 0.5.15 @@ -7603,7 +7603,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.12 '@next/swc-win32-arm64-msvc': 15.5.12 '@next/swc-win32-x64-msvc': 15.5.12 - '@playwright/test': 1.59.1 + '@playwright/test': 1.62.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -7757,11 +7757,11 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - playwright-core@1.59.1: {} + playwright-core@1.62.1: {} - playwright@1.59.1: + playwright@1.62.1: dependencies: - playwright-core: 1.59.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 From 4579dd984ce644a09ca57a7d53227d26f78ef9dc Mon Sep 17 00:00:00 2001 From: Steve Tsala Date: Mon, 24 Aug 2026 06:27:27 -0400 Subject: [PATCH 2/2] feat: add support for the HTTP QUERY method (RFC 10008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/lucky-jokes-search.md | 13 ++ docs/openapi-fetch/api.md | 40 ++++ packages/openapi-fetch/src/index.d.ts | 8 + packages/openapi-fetch/src/index.js | 10 + .../test/http-methods/query.test.ts | 171 ++++++++++++++++++ .../test/http-methods/schemas/query.d.ts | 121 +++++++++++++ .../test/http-methods/schemas/query.yaml | 77 ++++++++ packages/openapi-fetch/test/redocly.yaml | 4 + .../openapi-typescript-helpers/src/index.ts | 2 +- .../src/transform/path-item-object.ts | 18 +- .../src/transform/paths-enum.ts | 3 +- .../src/transform/paths-object.ts | 4 +- packages/openapi-typescript/src/types.ts | 2 + .../test/transform/path-item-object.test.ts | 52 +++++- 14 files changed, 518 insertions(+), 7 deletions(-) create mode 100644 .changeset/lucky-jokes-search.md create mode 100644 packages/openapi-fetch/test/http-methods/query.test.ts create mode 100644 packages/openapi-fetch/test/http-methods/schemas/query.d.ts create mode 100644 packages/openapi-fetch/test/http-methods/schemas/query.yaml diff --git a/.changeset/lucky-jokes-search.md b/.changeset/lucky-jokes-search.md new file mode 100644 index 000000000..c72ab3ddc --- /dev/null +++ b/.changeset/lucky-jokes-search.md @@ -0,0 +1,13 @@ +--- +"openapi-typescript-helpers": minor +"openapi-typescript": minor +"openapi-fetch": minor +--- + +Add support for the HTTP `QUERY` method ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)). + +`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. + +- `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. +- `openapi-typescript-helpers` adds `"query"` to `HttpMethod`. +- `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. diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index 9af429290..2e31d5a92 100644 --- a/docs/openapi-fetch/api.md +++ b/docs/openapi-fetch/api.md @@ -43,6 +43,46 @@ client.GET("/my-url", options); | `middleware` | `Middleware[]` | [See docs](/openapi-fetch/middleware-auth) | | (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal`, …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options)) | +## Request methods + +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. + +### QUERY + +[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. + +`query` is recognised as a path item verb in [OpenAPI 3.2](https://spec.openapis.org/oas/v3.2.0.html#path-item-object): + +```yaml +paths: + /resources: + query: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: + type: integer + responses: + 200: + description: OK +``` + +```ts +const { data, error } = await client.QUERY("/resources", { + body: { ids: [1, 2, 3] }, +}); +``` + +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. + +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. + ## wrapAsPathBasedClient **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: diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 5bca81075..31ccdcff6 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -231,6 +231,14 @@ export interface Client { request: ClientRequestMethod; /** Call a GET endpoint */ GET: ClientMethod; + /** + * Call a QUERY endpoint + * + * QUERY is safe and idempotent (RFC 10008): unlike POST it may be retried or + * cached, and unlike GET it carries a request body. + * @see https://www.rfc-editor.org/rfc/rfc10008 + */ + QUERY: ClientMethod; /** Call a PUT endpoint */ PUT: ClientMethod; /** Call a POST endpoint */ diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index 4c7250fb0..e16b38c51 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -287,6 +287,13 @@ export default function createClient(clientOptions) { GET(url, init) { return coreFetch(url, { ...init, method: "GET" }); }, + /** + * Call a QUERY endpoint + * @see https://www.rfc-editor.org/rfc/rfc10008 (safe & idempotent; carries a request body) + */ + QUERY(url, init) { + return coreFetch(url, { ...init, method: "QUERY" }); + }, /** Call a PUT endpoint */ PUT(url, init) { return coreFetch(url, { ...init, method: "PUT" }); @@ -348,6 +355,9 @@ class PathCallForwarder { GET = (init) => { return this.client.GET(this.url, init); }; + QUERY = (init) => { + return this.client.QUERY(this.url, init); + }; PUT = (init) => { return this.client.PUT(this.url, init); }; diff --git a/packages/openapi-fetch/test/http-methods/query.test.ts b/packages/openapi-fetch/test/http-methods/query.test.ts new file mode 100644 index 000000000..e32a35b5d --- /dev/null +++ b/packages/openapi-fetch/test/http-methods/query.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "vitest"; +import { wrapAsPathBasedClient } from "../../src/index.js"; +import { createObservedClient, headersToObj } from "../helpers.js"; +import type { paths } from "./schemas/query.js"; + +describe("QUERY", () => { + test("sends the correct method", async () => { + let method = ""; + const client = createObservedClient({}, async (req) => { + method = req.method; + return Response.json({}); + }); + await client.QUERY("/resources/{id}", { + params: { path: { id: 123 } }, + body: { ids: [1, 2, 3] }, + }); + expect(method).toBe("QUERY"); + }); + + describe("request body", () => { + test("requires necessary requestBodies", async () => { + const client = createObservedClient({}); + + // expect error on missing `body` + await client.QUERY("/resources/{id}", { + params: { path: { id: 1 } }, + // @ts-expect-error + body: undefined, + }); + + // expect error on missing required fields + await client.QUERY("/resources/{id}", { + params: { path: { id: 1 } }, + // @ts-expect-error + body: {}, + }); + + // expect present body to be good enough + await client.QUERY("/resources/{id}", { + params: { path: { id: 1 } }, + body: { ids: [1, 2, 3] }, + }); + }); + + test("requestBody with required: false", async () => { + const client = createObservedClient({}); + + // assert missing `body` doesn't raise a TS error + await client.QUERY("/resources-optional", { + params: { path: { id: 1 } }, + }); + + // assert error on type mismatch + await client.QUERY("/resources-optional", { + params: { path: { id: 1 } }, + body: { + // @ts-expect-error + ids: "not-an-array", + }, + }); + }); + }); + + test("sends correct options, returns success", async () => { + const mockData = { status: "ok" }; + let actualPathname = ""; + const client = createObservedClient({}, async (req) => { + actualPathname = new URL(req.url).pathname; + return Response.json(mockData, { status: 200 }); + }); + + const { data, error, response } = await client.QUERY("/resources/{id}", { + params: { path: { id: 456 } }, + body: { ids: [7, 8, 9] }, + }); + + // assert correct URL was called + expect(actualPathname).toBe("/resources/456"); + + // assert correct data was returned + expect(data).toEqual(mockData); + expect(response.status).toBe(200); + + // assert error is empty + expect(error).toBeUndefined(); + }); + + test("sends the request body with a Content-Type", async () => { + // RFC 10008 §2: a QUERY request has content, so it must identify its media type + let actualBody = ""; + let actualContentType: string | null = ""; + const client = createObservedClient({}, async (req) => { + actualBody = await req.text(); + actualContentType = req.headers.get("Content-Type"); + return Response.json({}); + }); + + await client.QUERY("/resources/{id}", { + params: { path: { id: 123 } }, + body: { ids: [1, 2, 3] }, + }); + + expect(actualBody).toBe(JSON.stringify({ ids: [1, 2, 3] })); + expect(actualContentType).toBe("application/json"); + }); + + // QUERY is defined as safe & idempotent (RFC 10008 §2), so identical calls must stay + // identical on the wire: the client may not add per-request state of its own, and it + // may not consume or mutate the caller’s `init`. + describe("idempotency", () => { + test("repeated identical calls produce identical requests", async () => { + const observed: { method: string; url: string; headers: Record; body: string }[] = []; + const client = createObservedClient({}, async (req) => { + observed.push({ + method: req.method, + url: req.url, + headers: headersToObj(req.headers), + body: await req.text(), + }); + return Response.json({}); + }); + + const init = { + params: { path: { id: 123 } }, + body: { ids: [1, 2, 3] }, + }; + + // reuse the exact same init object, to assert it is not consumed or mutated + await client.QUERY("/resources/{id}", init); + await client.QUERY("/resources/{id}", init); + + expect(observed).toHaveLength(2); + expect(observed[1]).toEqual(observed[0]); + expect(init).toEqual({ params: { path: { id: 123 } }, body: { ids: [1, 2, 3] } }); + }); + + test("is safe: no request body is read or replayed across calls", async () => { + // a request body may only be consumed once, so each call must build its own Request + const bodies: string[] = []; + const client = createObservedClient({}, async (req) => { + bodies.push(await req.text()); + return Response.json({}); + }); + + await client.QUERY("/resources/{id}", { params: { path: { id: 1 } }, body: { ids: [1] } }); + await client.QUERY("/resources/{id}", { params: { path: { id: 1 } }, body: { ids: [1] } }); + + expect(bodies).toEqual([JSON.stringify({ ids: [1] }), JSON.stringify({ ids: [1] })]); + }); + }); + + test("works with the path based client", async () => { + let method = ""; + let actualPathname = ""; + const client = wrapAsPathBasedClient( + createObservedClient({}, async (req) => { + method = req.method; + actualPathname = new URL(req.url).pathname; + return Response.json({}); + }), + ); + + await client["/resources/{id}"].QUERY({ + params: { path: { id: 123 } }, + body: { ids: [1, 2, 3] }, + }); + + expect(method).toBe("QUERY"); + expect(actualPathname).toBe("/resources/123"); + }); +}); diff --git a/packages/openapi-fetch/test/http-methods/schemas/query.d.ts b/packages/openapi-fetch/test/http-methods/schemas/query.d.ts new file mode 100644 index 000000000..d0305b7b4 --- /dev/null +++ b/packages/openapi-fetch/test/http-methods/schemas/query.d.ts @@ -0,0 +1,121 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/resources/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + query: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["QueryPayload"]; + responses: { + 200: components["responses"]["QueryResult"]; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error?: string; + }; + }; + }; + }; + }; + }; + "/resources-optional": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + query: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["QueryPayloadOptional"]; + responses: { + 200: components["responses"]["QueryResult"]; + }; + }; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + QueryPayload: { + ids: number[]; + }; + }; + responses: { + /** @description OK */ + QueryResult: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status?: string; + data?: Record[]; + }; + }; + }; + }; + parameters: never; + requestBodies: { + QueryPayload: { + content: { + "application/json": components["schemas"]["QueryPayload"]; + }; + }; + QueryPayloadOptional: { + content: { + "application/json": components["schemas"]["QueryPayload"]; + }; + }; + }; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/openapi-fetch/test/http-methods/schemas/query.yaml b/packages/openapi-fetch/test/http-methods/schemas/query.yaml new file mode 100644 index 000000000..86955a490 --- /dev/null +++ b/packages/openapi-fetch/test/http-methods/schemas/query.yaml @@ -0,0 +1,77 @@ +openapi: 3.1.0 +info: + title: openapi-fetch + version: "1.0" +paths: + /resources/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: integer + query: + requestBody: + $ref: "#/components/requestBodies/QueryPayload" + responses: + 200: + $ref: "#/components/responses/QueryResult" + 404: + description: Not Found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /resources-optional: + parameters: + - name: id + in: path + required: true + schema: + type: integer + query: + requestBody: + $ref: "#/components/requestBodies/QueryPayloadOptional" + responses: + 200: + $ref: "#/components/responses/QueryResult" +components: + requestBodies: + QueryPayload: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/QueryPayload" + QueryPayloadOptional: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/QueryPayload" + responses: + QueryResult: + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + type: array + items: + type: object + schemas: + QueryPayload: + type: object + required: [ids] + properties: + ids: + type: array + items: + type: integer diff --git a/packages/openapi-fetch/test/redocly.yaml b/packages/openapi-fetch/test/redocly.yaml index 1f211f405..b337655d3 100644 --- a/packages/openapi-fetch/test/redocly.yaml +++ b/packages/openapi-fetch/test/redocly.yaml @@ -37,6 +37,10 @@ apis: root: ./http-methods/schemas/trace.yaml x-openapi-ts: output: ./http-methods/schemas/trace.d.ts + http-query: + root: ./http-methods/schemas/query.yaml + x-openapi-ts: + output: ./http-methods/schemas/query.d.ts middleware: root: ./middleware/schemas/middleware.yaml x-openapi-ts: diff --git a/packages/openapi-typescript-helpers/src/index.ts b/packages/openapi-typescript-helpers/src/index.ts index 391e0a694..193a2f59b 100644 --- a/packages/openapi-typescript-helpers/src/index.ts +++ b/packages/openapi-typescript-helpers/src/index.ts @@ -1,6 +1,6 @@ // HTTP types -export type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"; +export type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" | "query"; /** 2XX statuses */ export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX"; /** 4XX and 5XX statuses */ diff --git a/packages/openapi-typescript/src/transform/path-item-object.ts b/packages/openapi-typescript/src/transform/path-item-object.ts index 87ca02d78..62613e946 100644 --- a/packages/openapi-typescript/src/transform/path-item-object.ts +++ b/packages/openapi-typescript/src/transform/path-item-object.ts @@ -11,7 +11,19 @@ import type { import transformOperationObject, { injectOperationObject } from "./operation-object.js"; import { transformParametersArray } from "./parameters-array.js"; -export type Method = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"; +export type Method = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" | "query"; + +/** + * Methods emitted on every Path Item Object (absent ones are emitted as `never`). + * + * `query` (RFC 10008) is deliberately left out: it was only introduced in OpenAPI 3.2, + * so it’s emitted for the Path Items that declare it rather than added to every Path + * Item of every document. + */ +const ALWAYS_EMITTED_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; + +/** Every method a Path Item Object may declare */ +export const METHODS = [...ALWAYS_EMITTED_METHODS, "query"] as const satisfies readonly Method[]; /** * Transform PathItem nodes (4.8.9) @@ -29,7 +41,9 @@ export default function transformPathItemObject(pathItem: PathItemObject, option ); // methods - for (const method of ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as Method[]) { + const methods: readonly Method[] = "query" in pathItem ? METHODS : ALWAYS_EMITTED_METHODS; + + for (const method of methods) { const operationObject = pathItem[method]; if ( !operationObject || diff --git a/packages/openapi-typescript/src/transform/paths-enum.ts b/packages/openapi-typescript/src/transform/paths-enum.ts index ea2fbdb43..61f349ec6 100644 --- a/packages/openapi-typescript/src/transform/paths-enum.ts +++ b/packages/openapi-typescript/src/transform/paths-enum.ts @@ -2,6 +2,7 @@ import type ts from "typescript"; import { tsEnum } from "../lib/ts.js"; import { getEntries } from "../lib/utils.js"; import type { PathsObject } from "../types.js"; +import { METHODS } from "./path-item-object.js"; export default function makeApiPathsEnum(pathsObject: PathsObject): ts.EnumDeclaration { const enumKeys = []; @@ -9,7 +10,7 @@ export default function makeApiPathsEnum(pathsObject: PathsObject): ts.EnumDecla for (const [url, pathItemObject] of getEntries(pathsObject)) { for (const [method, operation] of Object.entries(pathItemObject)) { - if (!["get", "put", "post", "delete", "options", "head", "patch", "trace"].includes(method)) { + if (!(METHODS as readonly string[]).includes(method)) { continue; } diff --git a/packages/openapi-typescript/src/transform/paths-object.ts b/packages/openapi-typescript/src/transform/paths-object.ts index 83c36af1a..fadc2927e 100644 --- a/packages/openapi-typescript/src/transform/paths-object.ts +++ b/packages/openapi-typescript/src/transform/paths-object.ts @@ -10,7 +10,7 @@ import type { PathsObject, ReferenceObject, } from "../types.js"; -import transformPathItemObject, { type Method } from "./path-item-object.js"; +import transformPathItemObject, { METHODS } from "./path-item-object.js"; const PATH_PARAM_RE = /\{[^}]+\}/g; @@ -114,7 +114,7 @@ function extractPathParams(pathItemObject: PathItemObject, ctx: GlobalContext) { params[resolved.name] = resolved; } } - for (const method of ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as Method[]) { + for (const method of METHODS) { if (!(method in pathItemObject)) { continue; } diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index d19185cc6..033207b48 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -179,6 +179,8 @@ export interface PathItemObject extends Extensable { patch?: OperationObject | ReferenceObject; /** A definition of a TRACE operation on this path. */ trace?: OperationObject | ReferenceObject; + /** A definition of a QUERY operation on this path (OpenAPI 3.2 / RFC 10008). */ + query?: OperationObject | ReferenceObject; /** An alternative server array to service all operations in this path. */ servers?: ServerObject[]; /** A list of parameters that are applicable for all the operations described under this path. These parameters can be overridden at the operation level, but cannot be removed there. The list MUST NOT include duplicated parameters. A unique parameter is defined by a combination of a name and location. The list can use the Reference Object to link to parameters that are defined at the OpenAPI Object’s components/parameters. */ diff --git a/packages/openapi-typescript/test/transform/path-item-object.test.ts b/packages/openapi-typescript/test/transform/path-item-object.test.ts index 4e0920097..802ce1379 100644 --- a/packages/openapi-typescript/test/transform/path-item-object.test.ts +++ b/packages/openapi-typescript/test/transform/path-item-object.test.ts @@ -416,11 +416,61 @@ describe("transformPathItemObject", () => { head?: never; patch?: never; trace?: never; +}`, + }, + ], + [ + "query > RFC 10008", + { + given: { + query: { + description: "Basic QUERY", + requestBody: { + content: { + "application/json": { $ref: "#/components/schemas/User" }, + }, + }, + responses: { + 200: { $ref: "#/components/responses/AllGood" }, + }, + }, + }, + want: `{ + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + /** @description Basic QUERY */ + query: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["User"]; + }; + }; + responses: { + 200: components["responses"]["AllGood"]; + }; + }; }`, }, ], ]; - for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) { test.skipIf(ci?.skipIf)( testName,