Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/openapi/src/core/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
31 changes: 28 additions & 3 deletions packages/openapi/src/core/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,22 @@ function toOperation(
root: Record<string, unknown>,
): 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<string, unknown> | undefined)
.filter((p): p is Record<string, unknown> => !!p)
.map(toParameter);
.filter((p) => !opOverrides(opParams, root, p));
const resolvedOpParams = opParams
.map((p) => resolveRef(p, root) as Record<string, unknown> | undefined)
.filter((p): p is Record<string, unknown> => !!p);

const parameters = [...resolvedPathParams, ...resolvedOpParams].map(toParameter);

return {
id: typeof op.operationId === 'string' ? op.operationId : autoId(method, path),
Expand All @@ -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<string, unknown>,
candidate: Record<string, unknown>,
): boolean {
return opParams.some((raw) => {
const p = resolveRef(raw, root) as Record<string, unknown> | undefined;
if (!p) return false;
return p.name === candidate.name && (p.in ?? 'query') === (candidate.in ?? 'query');
});
}

function toParameter(p: Record<string, unknown>): Parameter {
return {
name: String(p.name ?? ''),
Expand Down
Loading