From fe7d30d1299ffc4107c9e0ab6b757bd425eb073c Mon Sep 17 00:00:00 2001 From: "Nadine Roth (COO)" Date: Mon, 24 Aug 2026 10:27:59 +0200 Subject: [PATCH] fix(openapi): operation-level parameters override path-level ones (same name+in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the OpenAPI spec, an operation parameter with the same name and location as a path-level parameter replaces it. normalize() concatenated both, so every downstream consumer saw duplicated parameters: - generated docs showed duplicate parameter table rows - MCP input schemas silently overwrote one entry - the TS SDK emitted invalid code: `async getPet(petId: string, petId: string, ...)` — duplicate function arguments do not compile Operation-level params now replace same name+in path params ($refs in both positions are resolved before comparing). Path-level params not overridden are still inherited. Verified against a Petstore-style spec: before, getPet generated two petId arguments; after, one. --- packages/openapi/src/core/normalize.test.ts | 59 +++++++++++++++++++++ packages/openapi/src/core/normalize.ts | 31 +++++++++-- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/openapi/src/core/normalize.test.ts b/packages/openapi/src/core/normalize.test.ts index 249fba04..3731326c 100644 --- a/packages/openapi/src/core/normalize.test.ts +++ b/packages/openapi/src/core/normalize.test.ts @@ -109,4 +109,63 @@ describe('normalize', () => { }); expect(ir.operations[0]?.id).toBe('deleteFooId'); }); + + it('operation-level parameters override path-level ones with the same name+in', () => { + const ir = normalize({ + openapi: '3.0.0', + info: { title: 'Override', version: '1' }, + paths: { + '/pets/{petId}': { + parameters: [ + { name: 'petId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'verbose', in: 'query', schema: { type: 'boolean' } }, + ], + get: { + operationId: 'getPet', + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + description: 'more specific override', + schema: { type: 'string', minLength: 1 }, + }, + ], + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }); + + const params = ir.operations[0]!.parameters; + // The path-level petId must be replaced, not duplicated alongside the op-level one. + expect(params.filter((p) => p.name === 'petId')).toHaveLength(1); + expect(params.find((p) => p.name === 'petId')?.description).toBe('more specific override'); + // Path-level parameters not overridden by the operation are kept. + expect(params.some((p) => p.name === 'verbose')).toBe(true); + }); + + it('resolves $ref path parameters before applying the same name+in override rule', () => { + const ir = normalize({ + openapi: '3.0.0', + info: { title: 'RefOverride', version: '1' }, + paths: { + '/pets/{petId}': { + parameters: [{ $ref: '#/components/parameters/PetId' }], + get: { + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', required: true, schema: { type: 'string' } }], + responses: { '200': { description: 'ok' } }, + }, + }, + }, + components: { + parameters: { + PetId: { name: 'petId', in: 'path', required: true, schema: { type: 'string' } }, + }, + }, + }); + + expect(ir.operations[0]!.parameters.filter((p) => p.name === 'petId')).toHaveLength(1); + }); }); diff --git a/packages/openapi/src/core/normalize.ts b/packages/openapi/src/core/normalize.ts index 925dcd9c..8edf4467 100644 --- a/packages/openapi/src/core/normalize.ts +++ b/packages/openapi/src/core/normalize.ts @@ -56,11 +56,22 @@ function toOperation( root: Record, ): Operation { const opParams = (op.parameters ?? []) as unknown[]; - const rawParams = [...pathLevelParams, ...opParams]; - const parameters = rawParams + + // OpenAPI spec: an operation-level parameter with the same `name` + `in` + // as a path-level one REPLACES it (it may override description, schema, + // required, …). Concatenating both produced duplicated parameters, which + // leaked into generated docs (duplicate table rows), MCP input schemas, + // and — worst case — TS SDKs with duplicate function arguments + // (`async getPet(petId: string, petId: string, …)`), which do not compile. + const resolvedPathParams = pathLevelParams .map((p) => resolveRef(p, root) as Record | undefined) .filter((p): p is Record => !!p) - .map(toParameter); + .filter((p) => !opOverrides(opParams, root, p)); + const resolvedOpParams = opParams + .map((p) => resolveRef(p, root) as Record | undefined) + .filter((p): p is Record => !!p); + + const parameters = [...resolvedPathParams, ...resolvedOpParams].map(toParameter); return { id: typeof op.operationId === 'string' ? op.operationId : autoId(method, path), @@ -76,6 +87,20 @@ function toOperation( }; } +// True when the operation declares its own parameter with the same +// name + location, which per the OpenAPI spec overrides `candidate`. +function opOverrides( + opParams: unknown[], + root: Record, + candidate: Record, +): boolean { + return opParams.some((raw) => { + const p = resolveRef(raw, root) as Record | undefined; + if (!p) return false; + return p.name === candidate.name && (p.in ?? 'query') === (candidate.in ?? 'query'); + }); +} + function toParameter(p: Record): Parameter { return { name: String(p.name ?? ''),