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
36 changes: 36 additions & 0 deletions .changeset/adr-0096-openapi-spec-file-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
'@objectstack/connector-openapi': minor
'@objectstack/cli': minor
---

feat(connector-openapi): resolve `providerConfig.spec` from a package-relative file path (#3016, ADR-0096 follow-up)

ADR-0096's canonical example authors an OpenAPI-backed instance as
`providerConfig: { spec: './billing-openapi.json' }`, but the landed `openapi`
provider factory only accepted an inline document object or an http(s) URL.
The spec union is now complete: **inline object | file path | remote URL**.

- **`@objectstack/spec`.** `ConnectorProviderContext` gains an optional
host-injected `loadPackageFile(relativePath)` capability (pure type): reads a
UTF-8 file resolved against the declaring stack/package root, confined to
that root. `undefined` on hosts without a filesystem.

- **`@objectstack/service-automation`.** New `packageRoot` plugin option (the
base for relative file refs; defaults to `process.cwd()`) and an exported
`createPackageFileLoader(packageRoot)` that implements the confinement
guard — absolute paths and `..`-escaping paths are rejected — with lazy
`node:fs`/`node:path` imports so non-Node hosts only fail if a file ref is
actually dereferenced. The materializer injects the capability into every
provider factory's context. Failures follow the existing reconcile policy:
**fatal at boot, entry skipped on reload**.

- **`@objectstack/connector-openapi`.** A string `providerConfig.spec` that is
not an http(s) URL is now read via `ctx.loadPackageFile` and parsed as an
OpenAPI JSON document (clear errors for missing/unreadable files, unparseable
JSON, and hosts without package file access).

- **`@objectstack/cli`.** `serve`/`dev` pass the project folder (the
`objectstack.config.ts` directory) as the automation service's `packageRoot`,
mirroring how the standalone sqlite default is anchored.
2 changes: 1 addition & 1 deletion docs/adr/0096-declarative-connector-instances.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,6 @@ keeps serving until its replacement materializes successfully. Boot keeps the

### Deliberate scope boundaries

- **`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.
- **`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.
- **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers.
- **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).
23 changes: 16 additions & 7 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { defineStack } from '@objectstack/spec';
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
import { ConnectorSlackPlugin } from '@objectstack/connector-slack';
import {
Expand Down Expand Up @@ -102,12 +103,17 @@ export default defineStack({

// Concrete connectors for the `connector_action` node. The baseline engine
// ships the dispatch node + an empty registry; these plugins populate it.
// • rest → points at the running server itself, so the REST connector
// flow's call + response are observable on the flow run with no
// external dependency. Override the target with SHOWCASE_SELF_URL.
// • slack → registered so TaskCompletedSlackFlow resolves its connector;
// live posting needs a real bot token (set SLACK_BOT_TOKEN).
// • rest → points at the running server itself, so the REST connector
// flow's call + response are observable on the flow run with no
// external dependency. Override the target with SHOWCASE_SELF_URL.
// • slack → registered so TaskCompletedSlackFlow resolves its connector;
// live posting needs a real bot token (set SLACK_BOT_TOKEN).
// • openapi → option-less: contributes only the `openapi` provider factory
// (ADR-0096), which materializes the StatusOpenApiConnector
// declarative instance below — its OpenAPI document is a
// package-relative FILE PATH read at boot (#3016).
plugins: [
new ConnectorOpenApiPlugin(),
new ConnectorRestPlugin({
name: 'rest',
baseUrl: process.env.SHOWCASE_SELF_URL ?? 'http://127.0.0.1:3000',
Expand Down Expand Up @@ -191,8 +197,11 @@ export default defineStack({
// Declarative REST endpoints (object_operation + flow) — the metadata
// counterpart of the code-mounted recalc endpoint (see src/system/apis/).
apis: allApis,
// Declarative connector CATALOG DESCRIPTORS (#2612) — metadata-only, never
// runtime-dispatchable; the live connectors are the plugins above. See
// Declarative `connectors:` — both kinds (ADR-0096): provider-bound
// INSTANCES (StatusApiConnector via `rest`; StatusOpenApiConnector via
// `openapi` with a package-relative file-path spec, #3016) materialized into
// live, dispatchable connectors at boot, plus a CATALOG DESCRIPTOR
// (ErpCatalogConnector, #2612) that stays metadata-only. See
// src/system/connectors/ for the full contract.
connectors: allConnectors,
hooks: allHooks,
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"dependencies": {
"@objectstack/cloud-connection": "workspace:*",
"@objectstack/connector-openapi": "workspace:*",
"@objectstack/connector-rest": "workspace:*",
"@objectstack/connector-slack": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
status: 'demonstrated',
files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'],
notes:
'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.',
'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.',
},
};

Expand Down
35 changes: 34 additions & 1 deletion examples/app-showcase/src/system/connectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,39 @@ export const StatusApiConnector = defineConnector({
},
auth: { type: 'none' },
});
/**
* ADR-0096 provider-bound instance, **file-path spec** form (#3016): the OpenAPI
* document lives next to this file (`status-openapi.json`) and is referenced by
* a path resolved relative to THIS package's root at materialization — reads
* are confined to the package root (absolute / `..`-escaping paths are
* rejected), and a missing or unparseable document fails boot loudly. The
* `openapi` provider factory (ConnectorOpenApiPlugin in objectstack.config.ts)
* turns the document's one operation (`getHealth` → `GET /api/v1/health`) into
* a dispatchable action against the running server itself, so the materialized
* connector is observable with no external dependency. Complements
* {@link StatusApiConnector}, which demos the same materialization from the
* `rest` provider's inline config.
*/
export const StatusOpenApiConnector = defineConnector({
name: 'showcase_status_openapi',
label: 'Status API (Declarative OpenAPI Instance, File-Path Spec)',
type: 'api',
description:
'Provider-bound declarative connector instance (ADR-0096) whose OpenAPI document is referenced as a ' +
'package-relative file path (#3016) and read at boot, confined to the package root. Materialized into a live ' +
'`openapi` connector — getHealth dispatches GET /api/v1/health against the running server.',
provider: 'openapi',
providerConfig: {
// Package-relative file ref (#3016) — resolved against the directory that
// holds objectstack.config.ts (the CLI passes it as the automation
// service's packageRoot). Inline documents and http(s) URLs stay valid.
spec: './src/system/connectors/status-openapi.json',
// Same self-pointing literal rationale as StatusApiConnector above.
baseUrl: 'http://127.0.0.1:3000',
},
auth: { type: 'none' },
});

export const ErpCatalogConnector = defineConnector({
name: 'showcase_erp_catalog',
label: 'ERP Integration (Catalog Descriptor)',
Expand Down Expand Up @@ -110,4 +143,4 @@ export const ErpCatalogConnector = defineConnector({
enabled: false,
});

export const allConnectors: Connector[] = [StatusApiConnector, ErpCatalogConnector];
export const allConnectors: Connector[] = [StatusApiConnector, StatusOpenApiConnector, ErpCatalogConnector];
21 changes: 21 additions & 0 deletions examples/app-showcase/src/system/connectors/status-openapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"openapi": "3.0.3",
"info": {
"title": "Showcase Status API",
"version": "1.0.0",
"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."
},
"servers": [{ "url": "http://127.0.0.1:3000" }],
"paths": {
"/api/v1/health": {
"get": {
"operationId": "getHealth",
"summary": "Server health probe",
"description": "Returns the running server's health status.",
"responses": {
"200": { "description": "Server is healthy" }
}
}
}
}
}
9 changes: 8 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1855,7 +1855,14 @@ export default class Serve extends Command {
}
// analytics needs cubes from config, others take no args
let arg: any;
if (spec.configKey === 'analyticsCubes') {
if (cap === 'automation') {
// #3016 — anchor declarative connector file refs (e.g. the openapi
// provider's `providerConfig.spec: './billing-openapi.json'`) to the
// project folder (next to objectstack.config.ts), mirroring how the
// standalone sqlite default is anchored above. Reads are confined to
// this root by the automation service's package file loader.
arg = { packageRoot: path.dirname(absolutePath) };
} else if (spec.configKey === 'analyticsCubes') {
const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? [];
arg = { cubes };
} else if (cap === 'email') {
Expand Down
41 changes: 39 additions & 2 deletions packages/connectors/connector-openapi/src/openapi-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,47 @@ describe('openapi provider factory (ADR-0096)', () => {
await expect(factory(ctx({ providerConfig: {} }))).rejects.toThrow(/providerConfig\.spec/);
});

it('rejects a bare file-path spec with a clear message', async () => {
// ── File-path specs (#3016 — ADR-0096 follow-up) ────────────────────────

it('reads a file-path spec through the host loadPackageFile capability', async () => {
const requested: string[] = [];
const loadPackageFile = async (rel: string) => {
requested.push(rel);
return JSON.stringify(petstore);
};
const factory = createOpenApiProviderFactory();
const { def, handlers } = await factory(
ctx({ providerConfig: { spec: './specs/petstore.json' }, loadPackageFile }),
);
expect(requested).toEqual(['./specs/petstore.json']);
expect(def.actions?.map((a) => a.key)).toEqual(['listPets']);
expect(Object.keys(handlers)).toEqual(['listPets']);
});

it('surfaces a loader failure (missing file / traversal rejection) with the connector name', async () => {
const loadPackageFile = async (rel: string) => {
throw new Error(`package file ref '${rel}' could not be read`);
};
const factory = createOpenApiProviderFactory();
await expect(
factory(ctx({ providerConfig: { spec: './missing.json' }, loadPackageFile })),
).rejects.toThrow(/'pets' failed to read providerConfig\.spec '\.\/missing\.json'.*could not be read/s);
});

it('rejects an unparseable file-path spec with a clear message', async () => {
const factory = createOpenApiProviderFactory();
await expect(
factory(ctx({ providerConfig: { spec: './broken.json' }, loadPackageFile: async () => 'not-json{' })),
).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
await expect(
factory(ctx({ providerConfig: { spec: './array.json' }, loadPackageFile: async () => '[1,2]' })),
).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
});

it('rejects a file-path spec with a clear message when the host has no package file access', async () => {
const factory = createOpenApiProviderFactory();
await expect(
factory(ctx({ providerConfig: { spec: './petstore.json' } })),
).rejects.toThrow(/not an http\(s\) URL/);
).rejects.toThrow(/no package file access/);
});
});
66 changes: 52 additions & 14 deletions packages/connectors/connector-openapi/src/openapi-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { ConnectorProviderFactory, ResolvedConnectorAuth } from '@objectstack/spec/integration';
import type {
ConnectorProviderContext,
ConnectorProviderFactory,
ResolvedConnectorAuth,
} from '@objectstack/spec/integration';
import {
createOpenApiConnector,
type OpenApiDocument,
Expand All @@ -21,24 +25,32 @@ export interface OpenApiProviderDeps {

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

/**
* Resolve `providerConfig.spec` into a parsed OpenAPI document. Accepts an inline
* document object (the reliable, no-network-at-boot form used by the showcase) or
* an http(s) URL fetched at materialization. A bare file path is rejected with a
* clear message: resolving `./x.json` relative to the stack is the stack loader's
* job, not the connector's — inline the document or serve it over HTTP.
* Resolve `providerConfig.spec` into a parsed OpenAPI document (ADR-0096;
* union per #3016): an inline document object (the reliable, no-I/O-at-boot
* form used by the showcase), an http(s) URL fetched at materialization, or a
* **file path** read through the host's `ctx.loadPackageFile` — which resolves
* it relative to the declaring stack/package root and confines the read to
* that root (absolute / `..`-escaping paths are rejected there). Every failure
* throws, so the materializer's reconcile policy applies: fatal at boot, the
* entry is skipped on reload.
*/
async function loadOpenApiDocument(
spec: unknown,
fetchImpl: typeof fetch | undefined,
connectorName: string,
ctx: ConnectorProviderContext,
): Promise<OpenApiDocument> {
const connectorName = ctx.name;
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
return spec as OpenApiDocument;
}
Expand All @@ -53,13 +65,39 @@ async function loadOpenApiDocument(
}
return (await res.json()) as OpenApiDocument;
}
throw new Error(
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not an http(s) URL. ` +
`Provide an inline OpenAPI document object or an http(s) URL — file-path refs are resolved by the stack loader, not the connector.`,
);
// File path — dereferenced through the host capability so resolution stays
// anchored to (and confined within) the declaring stack/package root.
if (!ctx.loadPackageFile) {
throw new Error(
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is a file path, ` +
`but this host provides no package file access — inline the OpenAPI document or use an http(s) URL.`,
);
}
let text: string;
try {
text = await ctx.loadPackageFile(spec);
} catch (err) {
throw new Error(
`connector-openapi provider: connector '${connectorName}' failed to read providerConfig.spec '${spec}': ` +
`${(err as Error).message}`,
);
}
try {
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('not a JSON object');
}
return parsed as OpenApiDocument;
} catch (err) {
throw new Error(
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not a parseable ` +
`OpenAPI JSON document: ${(err as Error).message}`,
);
}
}
throw new Error(
`connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x document object or an http(s) URL.`,
`connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x ` +
`document object, an http(s) URL, or a package-relative file path.`,
);
}

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