From aa0310818dab92572aa18cd9f4ea828ee22dce59 Mon Sep 17 00:00:00 2001 From: Matteo Date: Mon, 5 Oct 2026 11:04:22 +0200 Subject: [PATCH] OpenAPI import: send header parameters, read path-item parameters Reported in #868: the schema the model saw and the request the engine sent disagreed in two places. - Header parameters went into the tool's input schema (even as required) but never into endpointMapping.headers, so the value was dropped. They are now mapped as `$name`, which the REST engine already resolves and leaves out when the argument is not given. - Parameters declared on the path item (next to get/post) were ignored, so `/users/{userId}` had no input for userId and the literal `{userId}` went out in the URL. They are now merged into every operation, the operation's own entry winning on the same name + in. Closes #868 --- .../connectors/parsers/openapi.parser.spec.ts | 55 +++++++++++++++++++ .../src/connectors/parsers/openapi.parser.ts | 30 ++++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/connectors/parsers/openapi.parser.spec.ts b/packages/backend/src/connectors/parsers/openapi.parser.spec.ts index 06e41ce3..662eef67 100644 --- a/packages/backend/src/connectors/parsers/openapi.parser.spec.ts +++ b/packages/backend/src/connectors/parsers/openapi.parser.spec.ts @@ -458,6 +458,61 @@ describe('OpenApiParser', () => { const params = tools[0].parameters as any; expect(params.properties['X-Request-ID']).toBeDefined(); expect(params.required).toContain('X-Request-ID'); + // What the model is told it must send has to be sent (#868). + expect(tools[0].endpointMapping.headers).toEqual({ 'X-Request-ID': '$X-Request-ID' }); + }); + + it('sends a header parameter next to a JSON body (#868)', async () => { + const spec = { + ...minimalSpec, + paths: { + '/orders': { + post: { + operationId: 'createOrder', + parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string' } }], + requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { sku: { type: 'string' } } } } } }, + responses: { '201': { description: 'Created' } }, + }, + }, + }, + }; + const [tool] = await parser.parse(spec); + expect(tool.endpointMapping).toMatchObject({ + method: 'POST', + path: '/orders', + bodyMapping: { sku: '$sku' }, + headers: { 'Idempotency-Key': '$Idempotency-Key' }, + }); + }); + + it('applies parameters declared on the path item to every operation (#868)', async () => { + const spec = { + ...minimalSpec, + paths: { + '/users/{userId}': { + parameters: [ + { name: 'userId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' }, description: 'from the path item' }, + ], + get: { + operationId: 'getUser', + parameters: [{ name: 'fields', in: 'query', schema: { type: 'string' }, description: 'from the operation' }], + responses: { '200': { description: 'OK' } }, + }, + delete: { operationId: 'deleteUser', responses: { '204': { description: 'Deleted' } } }, + }, + }, + }; + const tools = await parser.parse(spec); + for (const tool of tools) { + const params = tool.parameters as any; + expect(params.properties.userId).toBeDefined(); + expect(params.required).toContain('userId'); + } + const get = tools.find((t) => t.operationId === 'getUser')!; + // The operation's own entry wins over the path item's on the same name + in. + expect((get.parameters as any).properties.fields.description).toBe('from the operation'); + expect(get.endpointMapping.queryParams).toEqual({ fields: '$fields' }); }); it('should skip authorization and content-type header parameters', async () => { diff --git a/packages/backend/src/connectors/parsers/openapi.parser.ts b/packages/backend/src/connectors/parsers/openapi.parser.ts index 0709fd68..b2661491 100644 --- a/packages/backend/src/connectors/parsers/openapi.parser.ts +++ b/packages/backend/src/connectors/parsers/openapi.parser.ts @@ -331,7 +331,7 @@ export class OpenApiParser { const operation = (pathItem as any)[method]; if (!operation) continue; - const tool = this.operationToTool(method, path, operation, api); + const tool = this.operationToTool(method, path, operation, api, (pathItem as any).parameters); if (tool) tools.push(tool); } } @@ -345,6 +345,7 @@ export class OpenApiParser { path: string, operation: any, api: any, + pathItemParameters?: any[], ): ParsedTool | null { const name = this.generateToolName(method, path, operation); const description = this.generateDescription(operation); @@ -353,9 +354,15 @@ export class OpenApiParser { const required: string[] = []; const queryParams: Record = {}; const bodyMapping: Record = {}; + const headerMapping: Record = {}; + + // Parameters declared on the path item apply to every operation under it + // (swagger-parser does not copy them down); the operation's own entry + // wins on the same name + location. + const allParams = mergeParameters(pathItemParameters, operation.parameters); // Path parameters - const pathParams = (operation.parameters || []).filter( + const pathParams = allParams.filter( (p: any) => p.in === 'path', ); for (const param of pathParams) { @@ -364,7 +371,7 @@ export class OpenApiParser { } // Query parameters - const queryParamsDef = (operation.parameters || []).filter( + const queryParamsDef = allParams.filter( (p: any) => p.in === 'query', ); for (const param of queryParamsDef) { @@ -374,7 +381,7 @@ export class OpenApiParser { } // Header parameters (non-auth) - const headerParams = (operation.parameters || []).filter( + const headerParams = allParams.filter( (p: any) => p.in === 'header' && !['authorization', 'content-type'].includes(p.name.toLowerCase()), @@ -382,6 +389,9 @@ export class OpenApiParser { for (const param of headerParams) { properties[param.name] = this.paramToJsonSchema(param); if (param.required) required.push(param.name); + // Sent as a header: the engine resolves `$name` from the call's + // arguments and leaves the header out when the value is not given. + headerMapping[param.name] = `$${param.name}`; } // Request body @@ -422,6 +432,9 @@ export class OpenApiParser { if (Object.keys(bodyMapping).length > 0) { endpointMapping.bodyMapping = bodyMapping; } + if (Object.keys(headerMapping).length > 0) { + endpointMapping.headers = headerMapping; + } const result: ParsedTool = { name, description, parameters, endpointMapping }; if (typeof operation.operationId === 'string' && operation.operationId.length > 0) { @@ -619,3 +632,12 @@ export class OpenApiParser { return result; } } + +/** Path-item parameters plus the operation's, the operation's entry winning on name + `in`. */ +function mergeParameters(pathItemParameters: unknown, operationParameters: unknown): any[] { + const list = (v: unknown): any[] => (Array.isArray(v) ? v.filter((p) => p && typeof p === 'object') : []); + const own = list(operationParameters); + const ownKeys = new Set(own.map((p) => `${p.in}:${p.name}`)); + return [...list(pathItemParameters).filter((p) => !ownKeys.has(`${p.in}:${p.name}`)), ...own]; +} +