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
13 changes: 13 additions & 0 deletions .changeset/27463-openapi-path-item-parameters.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 29 additions & 2 deletions packages/connectors/connector-openapi/src/openapi-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<string, unknown>;
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
Expand Down
101 changes: 101 additions & 0 deletions packages/connectors/connector-openapi/src/openapi-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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'],
});
});
});
Loading