diff --git a/packages/data-provider/specs/actions.spec.ts b/packages/data-provider/specs/actions.spec.ts index 59f068586dcc..fdc70fc9e605 100644 --- a/packages/data-provider/specs/actions.spec.ts +++ b/packages/data-provider/specs/actions.spec.ts @@ -1557,6 +1557,293 @@ describe('createURL', () => { }); }); +describe('openapiToFunction: path-item parameters and allOf request bodies', () => { + const baseSpec = { + openapi: '3.0.0' as const, + info: { title: 'Test', version: '1.0.0' }, + servers: [{ url: 'https://api.example.com' }], + }; + + describe('path-item level parameters', () => { + it('merges shared path-item parameters into all operations', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/items/{id}': { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + get: { + operationId: 'getItem', + responses: { '200': { description: 'ok' } }, + }, + delete: { + operationId: 'deleteItem', + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + + const getItem = functionSignatures.find((s) => s.name === 'getItem'); + const deleteItem = functionSignatures.find((s) => s.name === 'deleteItem'); + + expect(getItem).toBeDefined(); + expect(deleteItem).toBeDefined(); + expect(getItem?.parameters.properties).toHaveProperty('id'); + expect(getItem?.parameters.required).toContain('id'); + expect(deleteItem?.parameters.properties).toHaveProperty('id'); + expect(deleteItem?.parameters.required).toContain('id'); + }); + + it('does not create a ghost tool for the path-item parameters key', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/items/{id}': { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + get: { + operationId: 'getItem', + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + + const ghostTool = functionSignatures.find((s) => s.name.startsWith('parameters')); + expect(ghostTool).toBeUndefined(); + expect(functionSignatures).toHaveLength(1); + }); + + it('operation-level parameter wins over path-item parameter on (name, in) collision', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/items/{id}': { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + get: { + operationId: 'getItem', + // Operation-level overrides with integer type + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { '200': { description: 'ok' } }, + }, + delete: { + operationId: 'deleteItem', + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + + const getItem = functionSignatures.find((s) => s.name === 'getItem'); + const deleteItem = functionSignatures.find((s) => s.name === 'deleteItem'); + + // get uses operation-level integer + const getIdSchema = getItem?.parameters.properties['id'] as OpenAPIV3.SchemaObject; + expect(getIdSchema?.type).toBe('integer'); + + // delete still gets the path-item string + const deleteIdSchema = deleteItem?.parameters.properties['id'] as OpenAPIV3.SchemaObject; + expect(deleteIdSchema?.type).toBe('string'); + }); + + it('ignores non-method keys on path items (summary, description, servers)', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/items': { + summary: 'Item operations', + description: 'CRUD for items', + servers: [{ url: 'https://other.example.com' }], + get: { + operationId: 'listItems', + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + + expect(functionSignatures).toHaveLength(1); + expect(functionSignatures[0].name).toBe('listItems'); + }); + }); + + describe('allOf in request bodies', () => { + it('flattens allOf in a request body into a single set of properties', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/scrape': { + post: { + operationId: 'scrape', + requestBody: { + content: { + 'application/json': { + schema: { + allOf: [ + { + type: 'object', + properties: { url: { type: 'string' } }, + required: ['url'], + }, + { + type: 'object', + properties: { + formats: { type: 'array', items: { type: 'string' } }, + onlyMainContent: { type: 'boolean' }, + }, + }, + ], + } as OpenAPIV3.SchemaObject, + }, + }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + const scrape = functionSignatures.find((s) => s.name === 'scrape'); + + expect(scrape).toBeDefined(); + expect(scrape?.parameters.properties).toHaveProperty('url'); + expect(scrape?.parameters.properties).toHaveProperty('formats'); + expect(scrape?.parameters.properties).toHaveProperty('onlyMainContent'); + expect(scrape?.parameters.required).toContain('url'); + }); + + it('resolves $ref inside allOf members', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/scrape': { + post: { + operationId: 'scrape', + requestBody: { + content: { + 'application/json': { + schema: { + allOf: [ + { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] }, + { $ref: '#/components/schemas/ScrapeOptions' }, + ], + } as OpenAPIV3.SchemaObject, + }, + }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, + components: { + schemas: { + ScrapeOptions: { + type: 'object', + properties: { + formats: { type: 'array', items: { type: 'string' } }, + onlyMainContent: { type: 'boolean' }, + }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + const scrape = functionSignatures.find((s) => s.name === 'scrape'); + + expect(scrape?.parameters.properties).toHaveProperty('url'); + expect(scrape?.parameters.properties).toHaveProperty('formats'); + expect(scrape?.parameters.properties).toHaveProperty('onlyMainContent'); + expect(scrape?.parameters.required).toContain('url'); + }); + + it('resolves $ref to a component that itself uses allOf', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/scrape': { + post: { + operationId: 'scrape', + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ScrapeRequest' } as OpenAPIV3.ReferenceObject, + }, + }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, + components: { + schemas: { + ScrapeRequest: { + allOf: [ + { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] }, + { $ref: '#/components/schemas/ScrapeOptions' }, + ], + } as OpenAPIV3.SchemaObject, + ScrapeOptions: { + type: 'object', + properties: { + formats: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + const scrape = functionSignatures.find((s) => s.name === 'scrape'); + + expect(scrape?.parameters.properties).toHaveProperty('url'); + expect(scrape?.parameters.properties).toHaveProperty('formats'); + expect(scrape?.parameters.required).toContain('url'); + }); + + it('merges properties defined alongside allOf on the same schema object', () => { + const spec: OpenAPIV3.Document = { + ...baseSpec, + paths: { + '/create': { + post: { + operationId: 'createThing', + requestBody: { + content: { + 'application/json': { + schema: { + allOf: [ + { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + ], + properties: { extra: { type: 'number' } }, + } as OpenAPIV3.SchemaObject, + }, + }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }; + + const { functionSignatures } = openapiToFunction(spec); + const fn = functionSignatures.find((s) => s.name === 'createThing'); + + expect(fn?.parameters.properties).toHaveProperty('name'); + expect(fn?.parameters.properties).toHaveProperty('extra'); + expect(fn?.parameters.required).toContain('name'); + }); + }); +}); + describe('SSRF Protection', () => { describe('extractDomainFromUrl', () => { it('extracts domain from valid HTTPS URL', () => { diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index 53c9e8ae1c2c..207e0e844b45 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -449,6 +449,81 @@ function sanitizeOperationId(input: string) { return input.replace(/[^a-zA-Z0-9_-]/g, ''); } +/** Valid HTTP method names in OpenAPI 3. Used to skip non-operation path-item keys. */ +const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); + +/** + * Recursively resolves `$ref` and flattens `allOf` in a schema into a single merged object schema. + * Later allOf members win on property key collisions, matching JSON Schema merge semantics. + * @param schema - The schema or reference to flatten. + * @param components - The OpenAPI components section for resolving `$ref`s. + * @param seen - Tracks visited `$ref`s to prevent infinite loops. + */ +function flattenAllOf( + schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, + components?: OpenAPIV3.ComponentsObject, + seen: Set = new Set(), +): OpenAPIV3.SchemaObject { + // Resolve any top-level $ref, guarding against cycles + if ('$ref' in schema) { + const ref = schema.$ref; + if (seen.has(ref)) { + return {}; + } + seen = new Set(seen).add(ref); + schema = resolveRef(schema, components) as OpenAPIV3.SchemaObject; + } + + if (!schema.allOf) { + return schema as OpenAPIV3.SchemaObject; + } + + // Merge each allOf member into a single schema + const merged: OpenAPIV3.SchemaObject = { + type: 'object', + properties: {}, + required: [], + }; + + // Preserve any properties/required defined alongside allOf on the outer schema + const { allOf: _, ...outerRest } = schema as OpenAPIV3.SchemaObject & { allOf: unknown[] }; + + for (const member of schema.allOf) { + const flat = flattenAllOf( + member as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, + components, + seen, + ); + merged.properties = { ...merged.properties, ...flat.properties }; + if (flat.required) { + const existing = new Set(merged.required as string[]); + for (const r of flat.required) { + if (!existing.has(r)) { + (merged.required as string[]).push(r); + } + } + } + if (flat.type && flat.type !== 'object') { + (merged as OpenAPIV3.SchemaObject & { type?: string }).type = flat.type; + } + } + + // Merge outer-level properties last (they take precedence over allOf members) + if (outerRest.properties) { + merged.properties = { ...merged.properties, ...outerRest.properties }; + } + if (outerRest.required) { + const existing = new Set(merged.required as string[]); + for (const r of outerRest.required) { + if (!existing.has(r)) { + (merged.required as string[]).push(r); + } + } + } + + return merged; +} + /** * Converts an OpenAPI spec to function signatures and request builders. */ @@ -466,8 +541,18 @@ export function openapiToFunction( const baseUrl = openapiSpec.servers?.[0]?.url ?? ''; // Iterate over each path and method in the OpenAPI spec - for (const [path, methods] of Object.entries(openapiSpec.paths)) { - for (const [method, operation] of Object.entries(methods as OpenAPIV3.PathsObject)) { + for (const [path, pathItem] of Object.entries(openapiSpec.paths)) { + const methods = pathItem as OpenAPIV3.PathItemObject; + // Path-item-level parameters are shared across all operations on this path. + // We collect them here and merge them into each operation's own parameters below, + // with operation-level parameters taking precedence on (name, in) collisions. + const pathItemParameters = methods.parameters ?? []; + + for (const [method, operation] of Object.entries(methods)) { + // Skip non-operation keys (parameters, summary, description, servers, $ref, etc.) + if (!HTTP_METHODS.has(method)) { + continue; + } const paramLocations: Record = {}; const operationObj = operation as OpenAPIV3.OperationObject & { 'x-openai-isConsequential'?: boolean; @@ -487,8 +572,24 @@ export function openapiToFunction( required: [], }; - if (operationObj.parameters) { - for (const param of operationObj.parameters ?? []) { + // Merge path-item-level parameters with operation-level parameters. + // Operation-level parameters win when (name, in) collides (per OpenAPI 3.0 ยง4.8.9). + const operationParamKeys = new Set( + (operationObj.parameters ?? []).map((p) => { + const resolved = resolveRef(p, openapiSpec.components) as OpenAPIV3.ParameterObject; + return `${resolved.name}:${resolved.in}`; + }), + ); + const mergedParameters = [ + ...pathItemParameters.filter((p) => { + const resolved = resolveRef(p, openapiSpec.components) as OpenAPIV3.ParameterObject; + return !operationParamKeys.has(`${resolved.name}:${resolved.in}`); + }), + ...(operationObj.parameters ?? []), + ]; + + if (mergedParameters.length > 0) { + for (const param of mergedParameters) { const resolvedParam = resolveRef( param, openapiSpec.components, @@ -525,7 +626,10 @@ export function openapiToFunction( const content = requestBody.content; contentType = Object.keys(content ?? {})[0]; const schema = content?.[contentType]?.schema; - const resolvedSchema = resolveRef( + // Use flattenAllOf so that request bodies defined with `allOf` (a common pattern + // for merging a shared options schema with required fields) are fully expanded. + // resolveRef alone only unwraps $ref and leaves allOf untouched. + const resolvedSchema = flattenAllOf( schema as OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject, openapiSpec.components, );