From dd9bbc156107ef9ede46e23bfc6307f7dae09c04 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Mon, 29 Jun 2026 10:32:56 +0300 Subject: [PATCH] Merge path-level OpenAPI parameters --- src/spec/operation-parameters.ts | 47 ++++++++++++++++++ src/tools/get-endpoint.ts | 11 +++-- src/tools/get-types-tool.ts | 18 ++++--- src/types.ts | 2 + test/tools/get-endpoint.test.ts | 82 +++++++++++++++++++++++++++++++ test/tools/get-types-tool.test.ts | 54 ++++++++++++++++++++ 6 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 src/spec/operation-parameters.ts diff --git a/src/spec/operation-parameters.ts b/src/spec/operation-parameters.ts new file mode 100644 index 0000000..9ba8de2 --- /dev/null +++ b/src/spec/operation-parameters.ts @@ -0,0 +1,47 @@ +import type { OperationObject, PathItemObject } from '../types.ts' + +export type ParameterResolver = (parameters: unknown[] | undefined) => unknown + +export function mergePathItemParameters( + pathItem: PathItemObject | undefined, + operation: OperationObject, + resolveParameters: ParameterResolver, +): unknown[] | undefined { + const mergedParameters = toParameterArray(resolveParameters(pathItem?.parameters)) + const operationParameters = toParameterArray(resolveParameters(operation.parameters)) + + for (const parameter of operationParameters) { + const key = getParameterKey(parameter) + if (key === undefined) { + mergedParameters.push(parameter) + continue + } + + const existingIndex = mergedParameters.findIndex(existing => getParameterKey(existing) === key) + + if (existingIndex === -1) { + mergedParameters.push(parameter) + } else { + mergedParameters[existingIndex] = parameter + } + } + + return mergedParameters.length > 0 ? mergedParameters : undefined +} + +function toParameterArray(parameters: unknown): unknown[] { + return Array.isArray(parameters) ? parameters : [] +} + +function getParameterKey(parameter: unknown): string | undefined { + if (parameter === null || typeof parameter !== 'object' || Array.isArray(parameter)) { + return undefined + } + + const record = parameter as Record + if (typeof record.in !== 'string' || typeof record.name !== 'string') { + return undefined + } + + return `${record.in}:${record.name}` +} diff --git a/src/tools/get-endpoint.ts b/src/tools/get-endpoint.ts index d7661ef..7d34289 100644 --- a/src/tools/get-endpoint.ts +++ b/src/tools/get-endpoint.ts @@ -1,5 +1,6 @@ -import type { HttpMethod, OperationObject, ParsedSpec } from '../types.ts' +import type { HttpMethod, OperationObject, ParsedSpec, PathItemObject } from '../types.ts' import { HTTP_METHODS, isValidHttpMethod } from '../http-methods.ts' +import { mergePathItemParameters } from '../spec/operation-parameters.ts' import { resolveRefs } from '../spec/ref-resolver.ts' import { findOperationByOperationId } from './find-operation.ts' import { findSimilarNames } from './suggestions.ts' @@ -41,6 +42,7 @@ export function getEndpoint(spec: ParsedSpec, input: GetEndpointInput) { let normalizedMethod: HttpMethod let path: string let operation: OperationObject + let pathItem: PathItemObject | undefined if (hasOperationId) { if (input.operationId!.trim() === '') { @@ -69,6 +71,7 @@ export function getEndpoint(spec: ParsedSpec, input: GetEndpointInput) { normalizedMethod = found.method path = found.path operation = found.operation + pathItem = spec.rawSpec.paths?.[path] } else { const method = input.method!.toLowerCase() if (!isValidHttpMethod(method)) { @@ -81,7 +84,7 @@ export function getEndpoint(spec: ParsedSpec, input: GetEndpointInput) { normalizedMethod = method path = input.path! - const pathItem = spec.rawSpec.paths?.[path] + pathItem = spec.rawSpec.paths?.[path] if (!pathItem) { const suggestions = findSimilarNames(path, Object.keys(spec.rawSpec.paths ?? {})) @@ -108,7 +111,9 @@ export function getEndpoint(spec: ParsedSpec, input: GetEndpointInput) { summary: operation.summary ?? '', description: operation.description ?? '', tags: operation.tags ?? [], - parameters: resolveRefs(operation.parameters, spec.rawSpec), + parameters: mergePathItemParameters(pathItem, operation, parameters => + resolveRefs(parameters, spec.rawSpec), + ), requestBody: resolveRefs(operation.requestBody, spec.rawSpec), responses: resolveRefs(operation.responses, spec.rawSpec), } diff --git a/src/tools/get-types-tool.ts b/src/tools/get-types-tool.ts index 74cceec..c2683dd 100644 --- a/src/tools/get-types-tool.ts +++ b/src/tools/get-types-tool.ts @@ -1,4 +1,4 @@ -import type { HttpMethod, OperationObject, ParsedSpec } from '../types.ts' +import type { HttpMethod, OperationObject, ParsedSpec, PathItemObject } from '../types.ts' import { HTTP_METHODS, isValidHttpMethod } from '../http-methods.ts' import { addTransitiveDeps, @@ -7,6 +7,7 @@ import { topologicalSort, } from '../schema/dependency.ts' import { generateFileContent } from '../schema/to-ts.ts' +import { mergePathItemParameters } from '../spec/operation-parameters.ts' import { resolveNonSchemaComponentRefs } from '../spec/ref-resolver.ts' import { findOperationByOperationId } from './find-operation.ts' import { extractInlineSchemas } from './inline-schemas.ts' @@ -152,7 +153,8 @@ export function getTypesTool( endpointOperationId = operation.operationId } - const resolvedOperation = resolveOperationComponents(operation, spec) + const pathItem = endpointPath ? spec.rawSpec.paths?.[endpointPath] : undefined + const resolvedOperation = resolveOperationComponents(operation, pathItem, spec) inlineSchemas = extractInlineSchemas( resolvedOperation, @@ -247,12 +249,16 @@ function buildNotFoundMessage(message: string, suggestions: string[]): string { return `${message} Did you mean: ${suggestions.join(', ')}?` } -function resolveOperationComponents(operation: OperationObject, spec: ParsedSpec): OperationObject { +function resolveOperationComponents( + operation: OperationObject, + pathItem: PathItemObject | undefined, + spec: ParsedSpec, +): OperationObject { return { ...operation, - parameters: resolveNonSchemaComponentRefs(operation.parameters, spec.rawSpec) as - | unknown[] - | undefined, + parameters: mergePathItemParameters(pathItem, operation, parameters => + resolveNonSchemaComponentRefs(parameters, spec.rawSpec), + ), requestBody: resolveNonSchemaComponentRefs(operation.requestBody, spec.rawSpec), responses: resolveNonSchemaComponentRefs(operation.responses, spec.rawSpec) as | Record diff --git a/src/types.ts b/src/types.ts index 3e45e44..e5747c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -152,5 +152,7 @@ export interface OperationObject { } export type PathItemObject = { + parameters?: unknown[] +} & { [K in HttpMethod]?: OperationObject } diff --git a/test/tools/get-endpoint.test.ts b/test/tools/get-endpoint.test.ts index 0b492b8..d9dd0fb 100644 --- a/test/tools/get-endpoint.test.ts +++ b/test/tools/get-endpoint.test.ts @@ -238,6 +238,88 @@ describe('getEndpoint', () => { }, }) }) + + it('merges path-level parameters before operation parameters', () => { + const spec = createMockParsedSpec({ + rawSpec: { + paths: { + '/orgs/{orgId}/users': { + parameters: [ + { $ref: '#/components/parameters/OrgId' }, + { + name: 'X-Trace-Id', + in: 'header', + schema: { type: 'string' }, + }, + ], + get: { + operationId: 'listOrgUsers', + parameters: [ + { + name: 'X-Trace-Id', + in: 'header', + required: true, + schema: { type: 'string' }, + }, + { + name: 'page', + in: 'query', + schema: { type: 'integer' }, + }, + ], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + components: { + parameters: { + OrgId: { + name: 'orgId', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + }, + }, + }, + }) + + const result = getEndpoint(spec, { method: 'get', path: '/orgs/{orgId}/users' }) + + expect(result).toEqual({ + method: 'get', + path: '/orgs/{orgId}/users', + operationId: 'listOrgUsers', + summary: '', + description: '', + tags: [], + parameters: [ + { + name: 'orgId', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + { + name: 'X-Trace-Id', + in: 'header', + required: true, + schema: { type: 'string' }, + }, + { + name: 'page', + in: 'query', + schema: { type: 'integer' }, + }, + ], + requestBody: undefined, + responses: { + 200: { description: 'OK' }, + }, + }) + }) }) describe('operationId mode', () => { diff --git a/test/tools/get-types-tool.test.ts b/test/tools/get-types-tool.test.ts index 2a1305d..688e64a 100644 --- a/test/tools/get-types-tool.test.ts +++ b/test/tools/get-types-tool.test.ts @@ -559,6 +559,60 @@ describe('getTypesTool', () => { } }) + it('collects schemas from path-level parameters', () => { + const spec = createMockParsedSpec({ + schemas: { + CursorToken: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + }, + rawSpec: { + paths: { + '/users': { + parameters: [ + { + name: 'cursor', + in: 'query', + schema: { $ref: '#/components/schemas/CursorToken' }, + }, + ], + get: { + operationId: 'listUsers', + responses: { + 200: { + description: 'OK', + }, + }, + }, + }, + }, + }, + }) + + const result = getTypesTool(spec, { + method: 'get', + path: '/users', + }) + + expect('code' in result).toBe(true) + if ('code' in result) { + expect(result.code).toBe( + [ + '// Generated from endpoint: listUsers GET /users', + '// Includes schemas: CursorToken', + '', + 'export interface CursorToken {', + ' value?: string', + '}', + '', + ].join('\n'), + ) + } + }) + it('collects schemas from responses', () => { const spec = createMockParsedSpec({ schemas: {