Skip to content

Commit 10a570a

Browse files
os-zhuangclaude
andauthored
feat(connector-openapi): resolve providerConfig.spec from a package-relative file path (#3016) (#3024)
* feat(connector-openapi): resolve providerConfig.spec from a package-relative file path (#3016) ADR-0096 follow-up: complete the spec union (inline object | file path | remote URL) for the declarative openapi provider. - spec: ConnectorProviderContext gains an optional host-injected loadPackageFile capability (pure type) - service-automation: packageRoot option + createPackageFileLoader with a root-confinement guard (rejects absolute and ..-escaping paths; lazy node:fs/node:path imports); capability injected into every provider ctx; failures follow the reconcile policy (fatal at boot, soft on reload) - connector-openapi: non-URL string specs are read via ctx.loadPackageFile and parsed as OpenAPI JSON with clear errors - cli: serve/dev anchor packageRoot to the objectstack.config.ts directory - tests: file-path happy path, missing file (fatal at boot / skipped on reload), traversal rejection; ADR-0096 scope-boundary note updated Closes #3016 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016eY7byWABTUPtJG7R2AEFU * docs(spec) + showcase: demo the openapi file-path spec (#3016) - spec: providerConfig describe/TSDoc now documents the spec union (inline object | package-relative file path | http(s) URL) so the auto-generated references pick it up - showcase: StatusOpenApiConnector — a provider: 'openapi' declarative instance whose OpenAPI document is referenced as a package-relative file path (src/system/connectors/status-openapi.json), materialized at boot by an option-less ConnectorOpenApiPlugin; getHealth dispatches GET /api/v1/health against the running server itself - coverage notes + stale connectors: comment updated (the collection has held provider-bound instances since ADR-0096, not only descriptors) Verified: booted the showcase (--fresh, random port); GET /api/v1/automation/connectors lists showcase_status_openapi (origin: declarative) with the getHealth action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016eY7byWABTUPtJG7R2AEFU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ea3c93 commit 10a570a

16 files changed

Lines changed: 408 additions & 38 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/connector-openapi': minor
5+
'@objectstack/cli': minor
6+
---
7+
8+
feat(connector-openapi): resolve `providerConfig.spec` from a package-relative file path (#3016, ADR-0096 follow-up)
9+
10+
ADR-0096's canonical example authors an OpenAPI-backed instance as
11+
`providerConfig: { spec: './billing-openapi.json' }`, but the landed `openapi`
12+
provider factory only accepted an inline document object or an http(s) URL.
13+
The spec union is now complete: **inline object | file path | remote URL**.
14+
15+
- **`@objectstack/spec`.** `ConnectorProviderContext` gains an optional
16+
host-injected `loadPackageFile(relativePath)` capability (pure type): reads a
17+
UTF-8 file resolved against the declaring stack/package root, confined to
18+
that root. `undefined` on hosts without a filesystem.
19+
20+
- **`@objectstack/service-automation`.** New `packageRoot` plugin option (the
21+
base for relative file refs; defaults to `process.cwd()`) and an exported
22+
`createPackageFileLoader(packageRoot)` that implements the confinement
23+
guard — absolute paths and `..`-escaping paths are rejected — with lazy
24+
`node:fs`/`node:path` imports so non-Node hosts only fail if a file ref is
25+
actually dereferenced. The materializer injects the capability into every
26+
provider factory's context. Failures follow the existing reconcile policy:
27+
**fatal at boot, entry skipped on reload**.
28+
29+
- **`@objectstack/connector-openapi`.** A string `providerConfig.spec` that is
30+
not an http(s) URL is now read via `ctx.loadPackageFile` and parsed as an
31+
OpenAPI JSON document (clear errors for missing/unreadable files, unparseable
32+
JSON, and hosts without package file access).
33+
34+
- **`@objectstack/cli`.** `serve`/`dev` pass the project folder (the
35+
`objectstack.config.ts` directory) as the automation service's `packageRoot`,
36+
mirroring how the standalone sqlite default is anchored.

docs/adr/0096-declarative-connector-instances.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,6 @@ keeps serving until its replacement materializes successfully. Boot keeps the
134134

135135
### Deliberate scope boundaries
136136

137-
- **`providerConfig.spec` (openapi)** accepts an inline document or an http(s) URL; resolving a `./file.json` ref relative to the stack is the stack loader's job, not the connector's.
137+
- **`providerConfig.spec` (openapi)** accepts an inline document, an http(s) URL, **or a package-relative file path** (#3016 follow-up). The connector still owns no filesystem access: the automation service injects a `loadPackageFile` capability into `ConnectorProviderContext` that resolves the ref against the declaring stack/package root (`packageRoot`, CLI default: the `objectstack.config.ts` directory) and **confines reads to that root** — absolute and `..`-escaping paths are rejected. Read/parse failures follow the reconcile policy above: fatal at boot, skipped on reload.
138138
- **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers.
139139
- **MCP at boot** connects during materialization, so an unreachable server fails boot; a fail-soft "optional" marker for boot-time materialization is a possible future refinement (runtime reloads are already soft).

examples/app-showcase/objectstack.config.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { defineStack } from '@objectstack/spec';
4+
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
45
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
56
import { ConnectorSlackPlugin } from '@objectstack/connector-slack';
67
import {
@@ -102,12 +103,17 @@ export default defineStack({
102103

103104
// Concrete connectors for the `connector_action` node. The baseline engine
104105
// ships the dispatch node + an empty registry; these plugins populate it.
105-
// • rest → points at the running server itself, so the REST connector
106-
// flow's call + response are observable on the flow run with no
107-
// external dependency. Override the target with SHOWCASE_SELF_URL.
108-
// • slack → registered so TaskCompletedSlackFlow resolves its connector;
109-
// live posting needs a real bot token (set SLACK_BOT_TOKEN).
106+
// • rest → points at the running server itself, so the REST connector
107+
// flow's call + response are observable on the flow run with no
108+
// external dependency. Override the target with SHOWCASE_SELF_URL.
109+
// • slack → registered so TaskCompletedSlackFlow resolves its connector;
110+
// live posting needs a real bot token (set SLACK_BOT_TOKEN).
111+
// • openapi → option-less: contributes only the `openapi` provider factory
112+
// (ADR-0096), which materializes the StatusOpenApiConnector
113+
// declarative instance below — its OpenAPI document is a
114+
// package-relative FILE PATH read at boot (#3016).
110115
plugins: [
116+
new ConnectorOpenApiPlugin(),
111117
new ConnectorRestPlugin({
112118
name: 'rest',
113119
baseUrl: process.env.SHOWCASE_SELF_URL ?? 'http://127.0.0.1:3000',
@@ -191,8 +197,11 @@ export default defineStack({
191197
// Declarative REST endpoints (object_operation + flow) — the metadata
192198
// counterpart of the code-mounted recalc endpoint (see src/system/apis/).
193199
apis: allApis,
194-
// Declarative connector CATALOG DESCRIPTORS (#2612) — metadata-only, never
195-
// runtime-dispatchable; the live connectors are the plugins above. See
200+
// Declarative `connectors:` — both kinds (ADR-0096): provider-bound
201+
// INSTANCES (StatusApiConnector via `rest`; StatusOpenApiConnector via
202+
// `openapi` with a package-relative file-path spec, #3016) materialized into
203+
// live, dispatchable connectors at boot, plus a CATALOG DESCRIPTOR
204+
// (ErpCatalogConnector, #2612) that stays metadata-only. See
196205
// src/system/connectors/ for the full contract.
197206
connectors: allConnectors,
198207
hooks: allHooks,

examples/app-showcase/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
},
2424
"dependencies": {
2525
"@objectstack/cloud-connection": "workspace:*",
26+
"@objectstack/connector-openapi": "workspace:*",
2627
"@objectstack/connector-rest": "workspace:*",
2728
"@objectstack/connector-slack": "workspace:*",
2829
"@objectstack/driver-sql": "workspace:*",

examples/app-showcase/src/coverage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
179179
status: 'demonstrated',
180180
files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'],
181181
notes:
182-
'Both connector kinds are demonstrated. (1) Provider-bound INSTANCE (ADR-0096 / #2977): StatusApiConnector declares `provider: rest` and is materialized into a live, dispatchable connector at boot by ConnectorRestPlugin\'s provider factory — ShowcaseDeclarativeConnectorPingFlow calls it via connector_action and it appears in GET /connectors. (2) Catalog DESCRIPTOR (#2612): ErpCatalogConnector has no provider, so it stays inert metadata; enabled:false marks the deliberate catalog entry and silences the boot audit. Plugin-registered connectors (ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts) are also exercised by the connector flows.',
182+
'Both connector kinds are demonstrated. (1) Provider-bound INSTANCES (ADR-0096 / #2977): StatusApiConnector declares `provider: rest` (inline config) and StatusOpenApiConnector declares `provider: openapi` with its OpenAPI document referenced as a package-relative FILE PATH (#3016, read at boot and confined to the package root) — both are materialized into live, dispatchable connectors at boot; ShowcaseDeclarativeConnectorPingFlow calls the rest instance via connector_action and both appear in GET /connectors. (2) Catalog DESCRIPTOR (#2612): ErpCatalogConnector has no provider, so it stays inert metadata; enabled:false marks the deliberate catalog entry and silences the boot audit. Plugin-registered connectors (ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts) are also exercised by the connector flows.',
183183
},
184184
};
185185

examples/app-showcase/src/system/connectors/index.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,39 @@ export const StatusApiConnector = defineConnector({
5858
},
5959
auth: { type: 'none' },
6060
});
61+
/**
62+
* ADR-0096 provider-bound instance, **file-path spec** form (#3016): the OpenAPI
63+
* document lives next to this file (`status-openapi.json`) and is referenced by
64+
* a path resolved relative to THIS package's root at materialization — reads
65+
* are confined to the package root (absolute / `..`-escaping paths are
66+
* rejected), and a missing or unparseable document fails boot loudly. The
67+
* `openapi` provider factory (ConnectorOpenApiPlugin in objectstack.config.ts)
68+
* turns the document's one operation (`getHealth` → `GET /api/v1/health`) into
69+
* a dispatchable action against the running server itself, so the materialized
70+
* connector is observable with no external dependency. Complements
71+
* {@link StatusApiConnector}, which demos the same materialization from the
72+
* `rest` provider's inline config.
73+
*/
74+
export const StatusOpenApiConnector = defineConnector({
75+
name: 'showcase_status_openapi',
76+
label: 'Status API (Declarative OpenAPI Instance, File-Path Spec)',
77+
type: 'api',
78+
description:
79+
'Provider-bound declarative connector instance (ADR-0096) whose OpenAPI document is referenced as a ' +
80+
'package-relative file path (#3016) and read at boot, confined to the package root. Materialized into a live ' +
81+
'`openapi` connector — getHealth dispatches GET /api/v1/health against the running server.',
82+
provider: 'openapi',
83+
providerConfig: {
84+
// Package-relative file ref (#3016) — resolved against the directory that
85+
// holds objectstack.config.ts (the CLI passes it as the automation
86+
// service's packageRoot). Inline documents and http(s) URLs stay valid.
87+
spec: './src/system/connectors/status-openapi.json',
88+
// Same self-pointing literal rationale as StatusApiConnector above.
89+
baseUrl: 'http://127.0.0.1:3000',
90+
},
91+
auth: { type: 'none' },
92+
});
93+
6194
export const ErpCatalogConnector = defineConnector({
6295
name: 'showcase_erp_catalog',
6396
label: 'ERP Integration (Catalog Descriptor)',
@@ -110,4 +143,4 @@ export const ErpCatalogConnector = defineConnector({
110143
enabled: false,
111144
});
112145

113-
export const allConnectors: Connector[] = [StatusApiConnector, ErpCatalogConnector];
146+
export const allConnectors: Connector[] = [StatusApiConnector, StatusOpenApiConnector, ErpCatalogConnector];
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"openapi": "3.0.3",
3+
"info": {
4+
"title": "Showcase Status API",
5+
"version": "1.0.0",
6+
"description": "Minimal OpenAPI document for the showcase's own health probe. Referenced by the StatusOpenApiConnector declarative instance (src/system/connectors/index.ts) as a package-relative file path — the #3016 / ADR-0096 spec form resolved and confined to this package's root at boot."
7+
},
8+
"servers": [{ "url": "http://127.0.0.1:3000" }],
9+
"paths": {
10+
"/api/v1/health": {
11+
"get": {
12+
"operationId": "getHealth",
13+
"summary": "Server health probe",
14+
"description": "Returns the running server's health status.",
15+
"responses": {
16+
"200": { "description": "Server is healthy" }
17+
}
18+
}
19+
}
20+
}
21+
}

packages/cli/src/commands/serve.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1881,7 +1881,14 @@ export default class Serve extends Command {
18811881
}
18821882
// analytics needs cubes from config, others take no args
18831883
let arg: any;
1884-
if (spec.configKey === 'analyticsCubes') {
1884+
if (cap === 'automation') {
1885+
// #3016 — anchor declarative connector file refs (e.g. the openapi
1886+
// provider's `providerConfig.spec: './billing-openapi.json'`) to the
1887+
// project folder (next to objectstack.config.ts), mirroring how the
1888+
// standalone sqlite default is anchored above. Reads are confined to
1889+
// this root by the automation service's package file loader.
1890+
arg = { packageRoot: path.dirname(absolutePath) };
1891+
} else if (spec.configKey === 'analyticsCubes') {
18851892
const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? [];
18861893
arg = { cubes };
18871894
} else if (cap === 'email') {

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,47 @@ describe('openapi provider factory (ADR-0096)', () => {
5252
await expect(factory(ctx({ providerConfig: {} }))).rejects.toThrow(/providerConfig\.spec/);
5353
});
5454

55-
it('rejects a bare file-path spec with a clear message', async () => {
55+
// ── File-path specs (#3016 — ADR-0096 follow-up) ────────────────────────
56+
57+
it('reads a file-path spec through the host loadPackageFile capability', async () => {
58+
const requested: string[] = [];
59+
const loadPackageFile = async (rel: string) => {
60+
requested.push(rel);
61+
return JSON.stringify(petstore);
62+
};
63+
const factory = createOpenApiProviderFactory();
64+
const { def, handlers } = await factory(
65+
ctx({ providerConfig: { spec: './specs/petstore.json' }, loadPackageFile }),
66+
);
67+
expect(requested).toEqual(['./specs/petstore.json']);
68+
expect(def.actions?.map((a) => a.key)).toEqual(['listPets']);
69+
expect(Object.keys(handlers)).toEqual(['listPets']);
70+
});
71+
72+
it('surfaces a loader failure (missing file / traversal rejection) with the connector name', async () => {
73+
const loadPackageFile = async (rel: string) => {
74+
throw new Error(`package file ref '${rel}' could not be read`);
75+
};
76+
const factory = createOpenApiProviderFactory();
77+
await expect(
78+
factory(ctx({ providerConfig: { spec: './missing.json' }, loadPackageFile })),
79+
).rejects.toThrow(/'pets' failed to read providerConfig\.spec '\.\/missing\.json'.*could not be read/s);
80+
});
81+
82+
it('rejects an unparseable file-path spec with a clear message', async () => {
83+
const factory = createOpenApiProviderFactory();
84+
await expect(
85+
factory(ctx({ providerConfig: { spec: './broken.json' }, loadPackageFile: async () => 'not-json{' })),
86+
).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
87+
await expect(
88+
factory(ctx({ providerConfig: { spec: './array.json' }, loadPackageFile: async () => '[1,2]' })),
89+
).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
90+
});
91+
92+
it('rejects a file-path spec with a clear message when the host has no package file access', async () => {
5693
const factory = createOpenApiProviderFactory();
5794
await expect(
5895
factory(ctx({ providerConfig: { spec: './petstore.json' } })),
59-
).rejects.toThrow(/not an http\(s\) URL/);
96+
).rejects.toThrow(/no package file access/);
6097
});
6198
});

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

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { ConnectorProviderFactory, ResolvedConnectorAuth } from '@objectstack/spec/integration';
3+
import type {
4+
ConnectorProviderContext,
5+
ConnectorProviderFactory,
6+
ResolvedConnectorAuth,
7+
} from '@objectstack/spec/integration';
48
import {
59
createOpenApiConnector,
610
type OpenApiDocument,
@@ -21,24 +25,32 @@ export interface OpenApiProviderDeps {
2125

2226
/** Shape of `providerConfig` for a `provider: 'openapi'` declarative instance. */
2327
interface OpenApiProviderConfig {
24-
/** The OpenAPI 3.x document: an inline object, or an http(s) URL to fetch at boot. */
28+
/**
29+
* The OpenAPI 3.x document: an inline object, an http(s) URL to fetch at
30+
* boot, or a file path resolved relative to the declaring stack/package root
31+
* (`'./billing-openapi.json'`, #3016).
32+
*/
2533
spec?: unknown;
2634
/** Override the base URL (else the document's `servers[0].url`). */
2735
baseUrl?: unknown;
2836
}
2937

3038
/**
31-
* Resolve `providerConfig.spec` into a parsed OpenAPI document. Accepts an inline
32-
* document object (the reliable, no-network-at-boot form used by the showcase) or
33-
* an http(s) URL fetched at materialization. A bare file path is rejected with a
34-
* clear message: resolving `./x.json` relative to the stack is the stack loader's
35-
* job, not the connector's — inline the document or serve it over HTTP.
39+
* Resolve `providerConfig.spec` into a parsed OpenAPI document (ADR-0096;
40+
* union per #3016): an inline document object (the reliable, no-I/O-at-boot
41+
* form used by the showcase), an http(s) URL fetched at materialization, or a
42+
* **file path** read through the host's `ctx.loadPackageFile` — which resolves
43+
* it relative to the declaring stack/package root and confines the read to
44+
* that root (absolute / `..`-escaping paths are rejected there). Every failure
45+
* throws, so the materializer's reconcile policy applies: fatal at boot, the
46+
* entry is skipped on reload.
3647
*/
3748
async function loadOpenApiDocument(
3849
spec: unknown,
3950
fetchImpl: typeof fetch | undefined,
40-
connectorName: string,
51+
ctx: ConnectorProviderContext,
4152
): Promise<OpenApiDocument> {
53+
const connectorName = ctx.name;
4254
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
4355
return spec as OpenApiDocument;
4456
}
@@ -53,13 +65,39 @@ async function loadOpenApiDocument(
5365
}
5466
return (await res.json()) as OpenApiDocument;
5567
}
56-
throw new Error(
57-
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not an http(s) URL. ` +
58-
`Provide an inline OpenAPI document object or an http(s) URL — file-path refs are resolved by the stack loader, not the connector.`,
59-
);
68+
// File path — dereferenced through the host capability so resolution stays
69+
// anchored to (and confined within) the declaring stack/package root.
70+
if (!ctx.loadPackageFile) {
71+
throw new Error(
72+
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is a file path, ` +
73+
`but this host provides no package file access — inline the OpenAPI document or use an http(s) URL.`,
74+
);
75+
}
76+
let text: string;
77+
try {
78+
text = await ctx.loadPackageFile(spec);
79+
} catch (err) {
80+
throw new Error(
81+
`connector-openapi provider: connector '${connectorName}' failed to read providerConfig.spec '${spec}': ` +
82+
`${(err as Error).message}`,
83+
);
84+
}
85+
try {
86+
const parsed: unknown = JSON.parse(text);
87+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
88+
throw new Error('not a JSON object');
89+
}
90+
return parsed as OpenApiDocument;
91+
} catch (err) {
92+
throw new Error(
93+
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not a parseable ` +
94+
`OpenAPI JSON document: ${(err as Error).message}`,
95+
);
96+
}
6097
}
6198
throw new Error(
62-
`connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x document object or an http(s) URL.`,
99+
`connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x ` +
100+
`document object, an http(s) URL, or a package-relative file path.`,
63101
);
64102
}
65103

@@ -82,7 +120,7 @@ export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): Co
82120
`connector-openapi provider: connector '${ctx.name}' providerConfig.baseUrl must be a string when set.`,
83121
);
84122
}
85-
const document = await loadOpenApiDocument(cfg.spec, deps.fetchImpl, ctx.name);
123+
const document = await loadOpenApiDocument(cfg.spec, deps.fetchImpl, ctx);
86124
const auth = ctx.auth as ResolvedConnectorAuth | undefined as RestAuth | undefined;
87125
return createOpenApiConnector({
88126
name: ctx.name,

0 commit comments

Comments
 (0)