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 ?? ''),