Skip to content
Merged
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
55 changes: 55 additions & 0 deletions packages/backend/src/connectors/parsers/openapi.parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
30 changes: 26 additions & 4 deletions packages/backend/src/connectors/parsers/openapi.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -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);
Expand All @@ -353,9 +354,15 @@ export class OpenApiParser {
const required: string[] = [];
const queryParams: Record<string, string> = {};
const bodyMapping: Record<string, string> = {};
const headerMapping: Record<string, string> = {};

// 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) {
Expand All @@ -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) {
Expand All @@ -374,14 +381,17 @@ 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()),
);
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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
}

Loading