Skip to content

Commit 8e02fab

Browse files
fix(openapi): stop the seam erasing path parameters on any route with a body (#9705) (#9739)
registerRouteSpec built one object literal carrying the key `request` twice: path parameters in the first, body and query in a conditional spread further down. A property arriving through a later spread replaces an earlier one of the same name, and TypeScript does not flag it because the duplicate is a spread rather than a literal duplicate key. So any route declaring BOTH a templated segment and a request body or query published no `parameters` array at all -- exactly what pathParameters() exists to prevent, since a templated segment with no matching parameter is a schema-validation warning and leaves a generated client holding a URL it cannot fill. Latent only because every production entry today is response-only. The first migrated POST /v1/repos/:owner/:repo/... would have dropped owner and repo silently, and the existing test registered precisely that combination while asserting only that requestBody was defined. The fix is to build `request` once; the emitter, pathParameters(), and toSpecPath() are untouched. The regenerated openapi.json is byte-identical, as expected while no caller passes a request. Four cases pin it -- body, query, both, and the unchanged no-request arm -- and each of the first three fails against the pre-fix emitter. The existing spec-only assertion now also checks its `id` parameter, and a new case covers the ORB and webhook auth levels, whose own security schemes had no test. Co-authored-by: loopover-orb[bot] <296761690+loopover-orb[bot]@users.noreply.github.com>
1 parent 03909f6 commit 8e02fab

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

src/openapi/define-route.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@ export function registerRouteSpec(registry: OpenAPIRegistry, options: RouteSpecO
177177
}
178178

179179
const security = securityFor(options.auth);
180+
// ONE `request` key, built once (#9705). This literal used to carry two: path parameters in the first,
181+
// body and query in a conditional spread further down. A property arriving through a later spread
182+
// replaces an earlier one of the same name -- and TypeScript does not flag it, because the duplicate is
183+
// a spread rather than a literal duplicate key -- so any route with BOTH a templated segment and a body
184+
// or query published no `parameters` at all. That is precisely what pathParameters() exists to prevent:
185+
// a templated segment with no matching parameter is a schema-validation warning and leaves a generated
186+
// client holding a URL it cannot fill. Latent only because no caller passed `request` yet; the first
187+
// migrated POST /v1/repos/:owner/:repo/... would have lost owner and repo silently.
180188
registry.registerPath({
181189
method: options.method,
182190
path: toSpecPath(options.path),
@@ -191,14 +199,6 @@ export function registerRouteSpec(registry: OpenAPIRegistry, options: RouteSpecO
191199
summary: options.summary,
192200
...(options.description ? { description: options.description } : {}),
193201
...(security ? { security } : {}),
194-
...(options.request?.body || options.request?.query
195-
? {
196-
request: {
197-
...(options.request.body ? { body: { content: { "application/json": { schema: options.request.body } } } } : {}),
198-
...(options.request.query ? { query: options.request.query } : {}),
199-
},
200-
}
201-
: {}),
202202
responses,
203203
});
204204
}

test/unit/define-route.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
33
import { Hono } from "hono";
44
import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
55
import { z } from "zod";
6-
import { defineRoute, invalidRequestBody, registerRouteSpec } from "../../src/openapi/define-route";
6+
import { defineRoute, invalidRequestBody, registerRouteSpec, type RouteSpecOptions } from "../../src/openapi/define-route";
77

88
type TestEnv = { Bindings: Record<string, unknown>; Variables: Record<string, unknown> };
99

@@ -172,6 +172,22 @@ describe("defineRoute", () => {
172172
}
173173
});
174174

175+
it("gives the ORB ingress its OWN schemes rather than the LoopOver pair", () => {
176+
// The two auth levels that are not a LoopOver credential at all: an ORB-issued instance token, and a
177+
// webhook that carries no bearer and is verified by a body signature. Publishing the generic pair for
178+
// either would tell a client to send something the route does not accept.
179+
const { app, registry } = build();
180+
for (const [auth, path, id] of [
181+
["orb", "/v1/orb/relay", "orbOp"],
182+
["webhook", "/v1/orb/webhook", "webhookOp"],
183+
] as const) {
184+
defineRoute(app, registry, { method: "post", path, operationId: id, tags: ["t"], summary: "s", auth, responses: { 200: { description: "ok" } } }, (c) => c.json({}));
185+
}
186+
const paths = generate(registry).paths ?? {};
187+
expect(paths["/v1/orb/relay"]?.post?.security).toEqual([{ OrbBearer: [] }]);
188+
expect(paths["/v1/orb/webhook"]?.post?.security).toEqual([{ OrbWebhookSignature: [] }]);
189+
});
190+
175191
it("emits responses with and without a schema", () => {
176192
const { app, registry } = build();
177193
defineRoute(app, registry, {
@@ -206,6 +222,58 @@ describe("registerRouteSpec", () => {
206222
const operation = generate(registry).paths?.["/v1/spec-only/{id}"]?.post;
207223
expect(operation?.operationId).toBe("specOnly");
208224
expect(operation?.requestBody).toBeDefined();
225+
// #9705: and its path parameter, which the duplicate `request` key used to erase. This route declares
226+
// both a templated segment and a body, so before the fix `parameters` was absent entirely.
227+
expect(operation?.parameters).toContainEqual(expect.objectContaining({ in: "path", name: "id", required: true }));
228+
});
229+
230+
// #9705 regression suite. The defect was that a route carrying a templated path AND any request
231+
// declaration published no `parameters` array -- silently, because the duplicate key arrived via a
232+
// spread and TypeScript does not flag that. Each case below fails on the pre-fix emitter.
233+
function specOnlyOperation(request: RouteSpecOptions["request"]) {
234+
const registry = new OpenAPIRegistry();
235+
registerRouteSpec(registry, {
236+
method: "post",
237+
path: "/v1/repos/:owner/:repo/thing",
238+
operationId: "repoThing",
239+
tags: ["ops"],
240+
summary: "Repo thing",
241+
auth: "token",
242+
...(request ? { request } : {}),
243+
responses: { 202: { description: "accepted" } },
244+
});
245+
return generate(registry).paths?.["/v1/repos/{owner}/{repo}/thing"]?.post;
246+
}
247+
248+
function pathParameterNames(operation: ReturnType<typeof specOnlyOperation>): string[] {
249+
return (operation?.parameters ?? []).filter((parameter) => "in" in parameter && parameter.in === "path").map((parameter) => ("name" in parameter ? parameter.name : ""));
250+
}
251+
252+
it("keeps every path parameter alongside a request BODY", () => {
253+
const operation = specOnlyOperation({ body: z.object({ a: z.string() }) });
254+
expect(pathParameterNames(operation)).toEqual(["owner", "repo"]);
255+
expect(operation?.requestBody).toBeDefined();
256+
});
257+
258+
it("keeps every path parameter alongside a QUERY schema", () => {
259+
const operation = specOnlyOperation({ query: z.object({ since: z.string() }) });
260+
expect(pathParameterNames(operation)).toEqual(["owner", "repo"]);
261+
expect(operation?.parameters).toContainEqual(expect.objectContaining({ in: "query", name: "since" }));
262+
});
263+
264+
it("keeps path parameters, the body, and the query together in ONE operation", () => {
265+
const operation = specOnlyOperation({ body: z.object({ a: z.string() }), query: z.object({ since: z.string() }) });
266+
expect(pathParameterNames(operation)).toEqual(["owner", "repo"]);
267+
expect(operation?.parameters).toContainEqual(expect.objectContaining({ in: "query", name: "since" }));
268+
expect(operation?.requestBody).toBeDefined();
269+
});
270+
271+
it("still emits path parameters for a route that declares no request at all", () => {
272+
// The unchanged arm: every production entry today is response-only, and their operations must be
273+
// byte-for-byte what they were.
274+
const operation = specOnlyOperation(undefined);
275+
expect(pathParameterNames(operation)).toEqual(["owner", "repo"]);
276+
expect(operation?.requestBody).toBeUndefined();
209277
});
210278
});
211279

0 commit comments

Comments
 (0)