diff --git a/.changeset/27463-openapi-path-item-parameters.md b/.changeset/27463-openapi-path-item-parameters.md new file mode 100644 index 00000000000..a59c5add791 --- /dev/null +++ b/.changeset/27463-openapi-path-item-parameters.md @@ -0,0 +1,13 @@ +--- +'@objectstack/connector-openapi': patch +--- + +fix(connector-openapi): parameters declared on an OpenAPI 3.x path item reach the input schema of every action under that path + +Clause-②: no — only the generated action's `inputSchema` (what `GET /automation/connectors` shows an author or the flow designer) gains the path-level parameters the document already declares; nothing validates a call's input against that schema, so every call accepted before is still dispatched as before. +Metadata-protocol: none + +A Path Item Object's `parameters` apply to every operation under that path, and the generator read only each operation's own `parameters`. So a document that declares an id once on the path, the usual vendor shape for GET / PATCH / DELETE on one resource (`'/pets/{petId}': { parameters: [{ name: 'petId', in: 'path', required: true }], get: …, delete: … }`), produced actions whose input schema had no `petId` at all, and none when that was the only parameter. + +- **Now.** Each operation's parameters are its path item's `parameters` followed by its own. An operation entry with the same `name` and `in` as a path-level one replaces it whole (its schema and its `required`), as OpenAPI defines; a path-level and an operation-level parameter that share a name but not `in` are both kept. +- **Unchanged.** An operation under a path item with no `parameters` gets exactly its own, as before. A `$ref` parameter is still left to the caller to de-reference and is skipped when it is not. A Swagger 2.0 document converted by `normalize-openapi` gets the same fix: the conversion keeps path-level parameters on the path item (in 3.x form), so they were dropped the same way before and arrive now. diff --git a/packages/connectors/connector-openapi/src/openapi-connector.ts b/packages/connectors/connector-openapi/src/openapi-connector.ts index 7d96b3bf205..387838e0831 100644 --- a/packages/connectors/connector-openapi/src/openapi-connector.ts +++ b/packages/connectors/connector-openapi/src/openapi-connector.ts @@ -53,6 +53,8 @@ export interface OpenApiDocument { } export interface OpenApiPathItem { + /** Parameters shared by every operation under this path (OpenAPI 3.x Path Item Object). */ + parameters?: OpenApiParameter[]; [method: string]: OpenApiOperation | unknown; } @@ -253,21 +255,46 @@ interface Op extends OpenApiOperation { path: string; } -/** Flatten paths × methods into a deterministic list of operations. */ +/** + * Flatten paths × methods into a deterministic list of operations, each + * carrying its path item's shared `parameters` (see {@link mergeParameters}). + */ function collectOperations(doc: OpenApiDocument): Op[] { const ops: Op[] = []; for (const [path, item] of Object.entries(doc.paths ?? {})) { if (!item || typeof item !== 'object') continue; const record = item as Record; + const shared = Array.isArray(record.parameters) ? (record.parameters as OpenApiParameter[]) : []; for (const method of HTTP_METHODS) { const operation = record[method] as OpenApiOperation | undefined; if (!operation || typeof operation !== 'object') continue; - ops.push({ ...operation, method, path }); + ops.push({ ...operation, parameters: mergeParameters(shared, operation.parameters), method, path }); } } return ops; } +/** + * The OpenAPI 3.x rule for a Path Item's `parameters`: they apply to every + * operation under the path, and an operation entry with the same `name` + `in` + * overrides the path-level one whole. A `$ref` entry has no `name` / `in` to + * match on, so it is kept as is — the caller de-references the document, and + * {@link buildInputSchema} skips an unresolved one exactly as it does an + * operation-level `$ref`. + */ +function mergeParameters( + shared: readonly OpenApiParameter[], + own: OpenApiParameter[] | undefined, +): OpenApiParameter[] | undefined { + if (shared.length === 0) return own; + const ownList = Array.isArray(own) ? own : []; + // `in` is a closed vocabulary with no ':' in it, so `in:name` is unambiguous. + const key = (p: OpenApiParameter): string | undefined => + p && typeof p === 'object' && !('$ref' in p) ? `${p.in}:${p.name}` : undefined; + const overridden = new Set(ownList.map(key)); + return [...shared.filter((p) => key(p) === undefined || !overridden.has(key(p))), ...ownList]; +} + /** * The action key an operation generates: its `operationId`, else the * `method_path` slug (`get /pets/{id}` → `get_pets_id`). The one derivation of diff --git a/packages/connectors/connector-openapi/src/openapi-provider.test.ts b/packages/connectors/connector-openapi/src/openapi-provider.test.ts index b72d56da219..c17087bf5ac 100644 --- a/packages/connectors/connector-openapi/src/openapi-provider.test.ts +++ b/packages/connectors/connector-openapi/src/openapi-provider.test.ts @@ -465,3 +465,104 @@ describe('openapi provider — providerConfig.include', () => { }); }); }); + +// ── Path-item-level `parameters` (OpenAPI 3.x Path Item Object) ───────────── +// +// A Path Item's `parameters` apply to every operation under that path, and an +// operation entry with the same `name` + `in` overrides the path-level one. +// The generator read the operation's own array only, so an id declared once on +// the path (the common vendor shape for GET / PATCH / DELETE on one resource) +// was missing from every action's input schema. One pin per shape, each read +// off the materialized action an author or the flow designer is shown. +describe('openapi provider — path-item-level parameters', () => { + const ok = { '200': { description: 'ok' } }; + async function inputSchemaOf(paths: Record, key: string) { + const spec = { openapi: '3.0.3', info: { title: 'Pets', version: '1.0.0' }, servers: [{ url: 'https://pets.example.com' }], paths }; + const { def } = await createOpenApiProviderFactory()(ctx({ providerConfig: { spec } })); + return def.actions?.find((a) => a.key === key)?.inputSchema; + } + + it('a path-level parameter reaches every operation under the path, and stays required', async () => { + const paths = { + '/pets/{petId}': { + parameters: [{ name: 'petId', in: 'path', required: true }], + get: { operationId: 'getPet', responses: ok }, + delete: { operationId: 'deletePet', responses: ok }, + }, + }; + for (const key of ['getPet', 'deletePet']) { + expect(await inputSchemaOf(paths, key), key).toEqual({ + type: 'object', + properties: { path: { type: 'object', properties: { petId: { type: 'string' } }, required: ['petId'] } }, + required: ['path'], + }); + } + }); + + it('an operation entry with the same name + in replaces the path-level one whole', async () => { + const schema = await inputSchemaOf({ + '/pets': { + parameters: [ + { name: 'limit', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'offset', in: 'query', schema: { type: 'integer' } }, + ], + get: { + operationId: 'listPets', + parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }], + responses: ok, + }, + }, + }, 'listPets'); + // The operation's entry wins: its schema, and its (absent) `required` — + // a concatenation would keep the path-level `required: ['limit']`. The + // path-level `offset` nobody overrides still arrives. + expect(schema).toEqual({ + type: 'object', + properties: { query: { type: 'object', properties: { offset: { type: 'integer' }, limit: { type: 'integer' } } } }, + }); + }); + + it('a path-level and an operation-level parameter that share a name but not `in` both appear', async () => { + const schema = await inputSchemaOf({ + '/pets/{id}': { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + get: { + operationId: 'getPet', + parameters: [{ name: 'id', in: 'header', schema: { type: 'string' } }], + responses: ok, + }, + }, + }, 'getPet'); + expect(schema).toEqual({ + type: 'object', + properties: { + path: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] }, + header: { type: 'object', properties: { id: { type: 'string' } } }, + }, + required: ['path'], + }); + }); + + it('an operation with no path-level parameters keeps exactly its own (control)', async () => { + const schema = await inputSchemaOf({ + '/pets/{petId}': { + get: { + operationId: 'getPet', + parameters: [ + { name: 'petId', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'detail', in: 'query', schema: { type: 'string' } }, + ], + responses: ok, + }, + }, + }, 'getPet'); + expect(schema).toEqual({ + type: 'object', + properties: { + path: { type: 'object', properties: { petId: { type: 'integer' } }, required: ['petId'] }, + query: { type: 'object', properties: { detail: { type: 'string' } } }, + }, + required: ['path'], + }); + }); +});