Skip to content

Commit 6d5c021

Browse files
fix(connector-openapi): path-item parameters reach every operation's input schema (#27981)
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Fixes #27463 ## 维护者速读 - 改了什么:`connector-openapi` 的 `collectOperations` 现在把 OpenAPI 3.x Path Item 上的 `parameters` 合并进该路径下每个操作的参数;操作里 `name` + `in` 相同的条目整条覆盖路径级条目。另加四个固定测试和一个 patch changeset。 - 为什么改:路径级参数从来不被读取,所以在路径上声明的必填 id(GET / PATCH / DELETE 共用的常见写法)不会出现在动作的 `inputSchema` 里,作者和流程设计器看到的输入定义缺了必填项。 - 风险与回滚:只改动作的 `inputSchema`;运行时不会用它校验调用输入,实测缺 `petId` 的调用修复前后一样被派发,没有原本被接受、现在被拒绝的调用。回滚就是 revert 这一个提交。 - 先看:`packages/connectors/connector-openapi/src/openapi-connector.ts` 的 `collectOperations` / `mergeParameters`。 - 两分钟验证:`pnpm --filter @objectstack/connector-openapi test -t "path-item-level"`,四个测试都绿;把 `openapi-connector.ts` 还原到 base 后,其中三个变红。 - 需要你做的:审阅并合并。另外,经 `normalize-openapi` 转换的 Swagger 2.0 文档也会受益:实测升级器把路径级参数留在 path item 上,并没有下沉到操作里,与卡片描述不同。 Clause-②: no — only the generated action's `inputSchema` gains the path-level parameters the document already declares; nothing validates a call's input against it, so every call accepted before is still dispatched as before (measured end to end). Metadata-protocol: none ## What changes - `collectOperations` merges each path item's `parameters` into every operation under that path: path-level entries first, then the operation's own. An operation entry with the same `name` + `in` replaces the path-level one whole (its schema and its `required`), as OpenAPI 3.x defines. A path-level and an operation-level parameter that share a name but not `in` are both kept. - A path item with no `parameters` leaves the operation's array exactly as it was. A `$ref` entry has no `name`/`in` to match on, so it is kept as is; `buildInputSchema` skips an unresolved one, as before. - `OpenApiPathItem` declares the optional `parameters` key. ## Why The generator built each operation from `record[method]` only, so a parameter declared once on the path never reached the action's `inputSchema`. Measured at bacb3e6 through `createOpenApiProviderFactory`: an OpenAPI 3.0.3 document with `'/pets/{petId}': { parameters: [{ name: 'petId', in: 'path', required: true }], get: … }` materialized `getPet` with `inputSchema: null`. Agents and flows built from that schema call the action wrong. Swagger 2.0 documents converted by `normalize-openapi` have the same shape: measured, the upgrader keeps path-level parameters on the path item (in 3.x form) and does not copy them into operations. They now get the same fix, and the normalizer's own tests are unchanged and green. ## Verification - Pins in `openapi-provider.test.ts`, one per shape, all through the provider factory: path-level only (GET and DELETE both get `petId`, required), operation override (whole-entry replacement; a concatenation would keep the path-level `required`), same name with a different `in` (both kept), and the operation-only control. - Red first: pins 1–3 red before the fix, the control green. Ablation: with the fix reverted in the tree, the same three go red and the other 40 tests in the file stay green; restored, the file hash equals the committed one. - Clause-②: a real boot (LiteKernel + AutomationServicePlugin + ConnectorOpenApiPlugin, declared `provider: 'openapi'`) runs a `connector_action` flow calling `getPet` with `input: {}`. It succeeds and requests the same URL before and after the fix; nothing refuses it. - `pnpm --filter @objectstack/connector-openapi test` (67/67), `typecheck`, `pnpm lint`: all exit 0. `dispatch-gates --ran`: 58 derived, 57 run green, 1 not measured (`check:dual-build-cjs-loads` needs a whole-tree build), 0 unrun. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50e894d commit 6d5c021

3 files changed

Lines changed: 143 additions & 2 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/connector-openapi': patch
3+
---
4+
5+
fix(connector-openapi): parameters declared on an OpenAPI 3.x path item reach the input schema of every action under that path
6+
7+
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.
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
- **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.
13+
- **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.

‎packages/connectors/connector-openapi/src/openapi-connector.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export interface OpenApiDocument {
5353
}
5454

5555
export interface OpenApiPathItem {
56+
/** Parameters shared by every operation under this path (OpenAPI 3.x Path Item Object). */
57+
parameters?: OpenApiParameter[];
5658
[method: string]: OpenApiOperation | unknown;
5759
}
5860

@@ -253,21 +255,46 @@ interface Op extends OpenApiOperation {
253255
path: string;
254256
}
255257

256-
/** Flatten paths × methods into a deterministic list of operations. */
258+
/**
259+
* Flatten paths × methods into a deterministic list of operations, each
260+
* carrying its path item's shared `parameters` (see {@link mergeParameters}).
261+
*/
257262
function collectOperations(doc: OpenApiDocument): Op[] {
258263
const ops: Op[] = [];
259264
for (const [path, item] of Object.entries(doc.paths ?? {})) {
260265
if (!item || typeof item !== 'object') continue;
261266
const record = item as Record<string, unknown>;
267+
const shared = Array.isArray(record.parameters) ? (record.parameters as OpenApiParameter[]) : [];
262268
for (const method of HTTP_METHODS) {
263269
const operation = record[method] as OpenApiOperation | undefined;
264270
if (!operation || typeof operation !== 'object') continue;
265-
ops.push({ ...operation, method, path });
271+
ops.push({ ...operation, parameters: mergeParameters(shared, operation.parameters), method, path });
266272
}
267273
}
268274
return ops;
269275
}
270276

277+
/**
278+
* The OpenAPI 3.x rule for a Path Item's `parameters`: they apply to every
279+
* operation under the path, and an operation entry with the same `name` + `in`
280+
* overrides the path-level one whole. A `$ref` entry has no `name` / `in` to
281+
* match on, so it is kept as is — the caller de-references the document, and
282+
* {@link buildInputSchema} skips an unresolved one exactly as it does an
283+
* operation-level `$ref`.
284+
*/
285+
function mergeParameters(
286+
shared: readonly OpenApiParameter[],
287+
own: OpenApiParameter[] | undefined,
288+
): OpenApiParameter[] | undefined {
289+
if (shared.length === 0) return own;
290+
const ownList = Array.isArray(own) ? own : [];
291+
// `in` is a closed vocabulary with no ':' in it, so `in:name` is unambiguous.
292+
const key = (p: OpenApiParameter): string | undefined =>
293+
p && typeof p === 'object' && !('$ref' in p) ? `${p.in}:${p.name}` : undefined;
294+
const overridden = new Set(ownList.map(key));
295+
return [...shared.filter((p) => key(p) === undefined || !overridden.has(key(p))), ...ownList];
296+
}
297+
271298
/**
272299
* The action key an operation generates: its `operationId`, else the
273300
* `method_path` slug (`get /pets/{id}` → `get_pets_id`). The one derivation of

‎packages/connectors/connector-openapi/src/openapi-provider.test.ts‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,104 @@ describe('openapi provider — providerConfig.include', () => {
465465
});
466466
});
467467
});
468+
469+
// ── Path-item-level `parameters` (OpenAPI 3.x Path Item Object) ─────────────
470+
//
471+
// A Path Item's `parameters` apply to every operation under that path, and an
472+
// operation entry with the same `name` + `in` overrides the path-level one.
473+
// The generator read the operation's own array only, so an id declared once on
474+
// the path (the common vendor shape for GET / PATCH / DELETE on one resource)
475+
// was missing from every action's input schema. One pin per shape, each read
476+
// off the materialized action an author or the flow designer is shown.
477+
describe('openapi provider — path-item-level parameters', () => {
478+
const ok = { '200': { description: 'ok' } };
479+
async function inputSchemaOf(paths: Record<string, unknown>, key: string) {
480+
const spec = { openapi: '3.0.3', info: { title: 'Pets', version: '1.0.0' }, servers: [{ url: 'https://pets.example.com' }], paths };
481+
const { def } = await createOpenApiProviderFactory()(ctx({ providerConfig: { spec } }));
482+
return def.actions?.find((a) => a.key === key)?.inputSchema;
483+
}
484+
485+
it('a path-level parameter reaches every operation under the path, and stays required', async () => {
486+
const paths = {
487+
'/pets/{petId}': {
488+
parameters: [{ name: 'petId', in: 'path', required: true }],
489+
get: { operationId: 'getPet', responses: ok },
490+
delete: { operationId: 'deletePet', responses: ok },
491+
},
492+
};
493+
for (const key of ['getPet', 'deletePet']) {
494+
expect(await inputSchemaOf(paths, key), key).toEqual({
495+
type: 'object',
496+
properties: { path: { type: 'object', properties: { petId: { type: 'string' } }, required: ['petId'] } },
497+
required: ['path'],
498+
});
499+
}
500+
});
501+
502+
it('an operation entry with the same name + in replaces the path-level one whole', async () => {
503+
const schema = await inputSchemaOf({
504+
'/pets': {
505+
parameters: [
506+
{ name: 'limit', in: 'query', required: true, schema: { type: 'string' } },
507+
{ name: 'offset', in: 'query', schema: { type: 'integer' } },
508+
],
509+
get: {
510+
operationId: 'listPets',
511+
parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }],
512+
responses: ok,
513+
},
514+
},
515+
}, 'listPets');
516+
// The operation's entry wins: its schema, and its (absent) `required` —
517+
// a concatenation would keep the path-level `required: ['limit']`. The
518+
// path-level `offset` nobody overrides still arrives.
519+
expect(schema).toEqual({
520+
type: 'object',
521+
properties: { query: { type: 'object', properties: { offset: { type: 'integer' }, limit: { type: 'integer' } } } },
522+
});
523+
});
524+
525+
it('a path-level and an operation-level parameter that share a name but not `in` both appear', async () => {
526+
const schema = await inputSchemaOf({
527+
'/pets/{id}': {
528+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
529+
get: {
530+
operationId: 'getPet',
531+
parameters: [{ name: 'id', in: 'header', schema: { type: 'string' } }],
532+
responses: ok,
533+
},
534+
},
535+
}, 'getPet');
536+
expect(schema).toEqual({
537+
type: 'object',
538+
properties: {
539+
path: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
540+
header: { type: 'object', properties: { id: { type: 'string' } } },
541+
},
542+
required: ['path'],
543+
});
544+
});
545+
546+
it('an operation with no path-level parameters keeps exactly its own (control)', async () => {
547+
const schema = await inputSchemaOf({
548+
'/pets/{petId}': {
549+
get: {
550+
operationId: 'getPet',
551+
parameters: [
552+
{ name: 'petId', in: 'path', required: true, schema: { type: 'integer' } },
553+
{ name: 'detail', in: 'query', schema: { type: 'string' } },
554+
],
555+
responses: ok,
556+
},
557+
},
558+
}, 'getPet');
559+
expect(schema).toEqual({
560+
type: 'object',
561+
properties: {
562+
path: { type: 'object', properties: { petId: { type: 'integer' } }, required: ['petId'] },
563+
query: { type: 'object', properties: { detail: { type: 'string' } } },
564+
},
565+
required: ['path'],
566+
});
567+
});
568+
});

0 commit comments

Comments
 (0)