From 343c51839d17c4a5f1c4c92fca05dc240d452627 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 31 May 2026 23:30:55 +0800 Subject: [PATCH] feat(automation): connector_action as baseline generic dispatch + connector-rest (ADR-0018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote `connector_action` to a built-in baseline node — the generic-dispatch counterpart to `http_request`: where http_request calls any raw URL, connector_action invokes any registered connector's declared action. - engine: connector registry (registerConnector / unregisterConnector / resolveConnectorAction / getRegisteredConnectors) + ConnectorActionHandler / ConnectorActionContext / RegisteredConnector types. registerConnector validates via ConnectorSchema and asserts every declared action has a handler. - builtin/connector-nodes.ts: connector_action executor (source:'builtin', category:'io', all three paradigms), wired into installBuiltinNodes() — the core plugin now seeds 11 baseline node types. Missing connector fails the step (not flow registration) with a clear error. - packages/connectors/connector-rest (@objectstack/connector-rest): the reference concrete connector. createRestConnector + ConnectorRestPlugin, `request` action, static auth (none/api-key/basic/bearer), no OAuth2 refresh (enterprise tier). - New packages/connectors/ workspace category (alongside plugins/services/adapters). - ADR-0018 §Addendum: records the decision, resolves Open-question #1, supersedes M2's "connector_action dropped from baseline". Tests: service-automation 87/87, connector-rest 10/10 (incl. end-to-end kernel boot: both plugins -> connector_action flow -> REST handler). --- docs/adr/0018-unified-node-action-registry.md | 39 ++++ packages/connectors/connector-rest/LICENSE | 93 +++++++++ .../connectors/connector-rest/package.json | 36 ++++ .../src/connector-rest-plugin.test.ts | 83 ++++++++ .../src/connector-rest-plugin.ts | 79 ++++++++ .../connectors/connector-rest/src/index.ts | 26 +++ .../connector-rest/src/rest-connector.test.ts | 139 ++++++++++++++ .../connector-rest/src/rest-connector.ts | 174 +++++++++++++++++ .../connectors/connector-rest/tsconfig.json | 10 + .../src/builtin/connector-nodes.test.ts | 179 ++++++++++++++++++ .../src/builtin/connector-nodes.ts | 86 +++++++++ .../src/builtin/http-nodes.ts | 10 +- .../service-automation/src/builtin/index.ts | 15 +- .../service-automation/src/engine.test.ts | 15 +- .../services/service-automation/src/engine.ts | 83 ++++++++ .../services/service-automation/src/index.ts | 10 +- pnpm-lock.yaml | 22 +++ pnpm-workspace.yaml | 1 + 18 files changed, 1083 insertions(+), 17 deletions(-) create mode 100644 packages/connectors/connector-rest/LICENSE create mode 100644 packages/connectors/connector-rest/package.json create mode 100644 packages/connectors/connector-rest/src/connector-rest-plugin.test.ts create mode 100644 packages/connectors/connector-rest/src/connector-rest-plugin.ts create mode 100644 packages/connectors/connector-rest/src/index.ts create mode 100644 packages/connectors/connector-rest/src/rest-connector.test.ts create mode 100644 packages/connectors/connector-rest/src/rest-connector.ts create mode 100644 packages/connectors/connector-rest/tsconfig.json create mode 100644 packages/services/service-automation/src/builtin/connector-nodes.test.ts create mode 100644 packages/services/service-automation/src/builtin/connector-nodes.ts diff --git a/docs/adr/0018-unified-node-action-registry.md b/docs/adr/0018-unified-node-action-registry.md index 8cc37b1668..f799569164 100644 --- a/docs/adr/0018-unified-node-action-registry.md +++ b/docs/adr/0018-unified-node-action-registry.md @@ -195,3 +195,42 @@ No fourth engine. Workflow Rules stays a **simplified authoring view** for busin 1. Does `connector_action` (the one verb already present in all three paradigms) become the *general* extension action, with `http`/`notify` as well-known specializations — or stay peer-level? Leaning: keep peer-level; `connector_action` targets a registered connector, `http` is raw. 2. Should `screen` / `user_task` (human-input nodes) carry their own descriptor category (`human`) that the runtime treats as always-`isAsync`? Likely yes. 3. Where does the action registry live for **cross-environment** consistency — is it per-environment (a plugin enabled in env A but not B yields different palettes)? Tie to the package/environment model (ADR-0006). + +--- + +## Addendum (2026-05-31): `connector_action` is baseline generic dispatch + +> Status of this addendum: **implemented.** This re-scopes the §Migration M2 note and resolves §Open-questions #1. The baseline registry + `connector_action` executor ship in `service-automation`, with `@objectstack/connector-rest` as the first concrete connector plugin. + +### Decision + +`connector_action` is promoted to a **built-in (`source: 'builtin'`) baseline node**, the generic-dispatch counterpart to `http_request`: + +- where `http_request` calls **any raw URL**, `connector_action` invokes **any registered connector's declared action**; +- the engine ships the dispatch node **plus an initially-empty connector registry** (`registerConnector` / `resolveConnectorAction` / `getRegisteredConnectors`); +- **concrete** connectors (`@objectstack/connector-rest`, `connector-slack`, `connector-salesforce`, …) remain **plugins** that populate the registry at runtime. + +This is the **mechanism/policy split**: the *mechanism* (registry + dispatch node) is baseline; the *concrete integrations* (and their credentials/lifecycle) are not. It mirrors the ADR-0015 datasource split — federation contract is in the open framework, managed connection lifecycle lives outside it. + +### Why this reverses M2's "connector_action dropped from baseline" + +M2 dropped `connector_action` because it would need "a connector registry the platform doesn't ship." That is circular: the registry is the missing piece, and an **empty** registry is zero-dependency and zero-cost. The protocol already commits to the node — `connector_action` is in `FLOW_BUILTIN_NODE_TYPES` and `connectorConfig {connectorId, actionId, input}` is already a `FlowNode` field — but ships **no executor**, so any flow referencing it fails at execution. Shipping the empty registry + dispatch executor closes that spec/runtime gap without pulling any concrete integration into the core. + +### Resolves Open-question #1 + +The leaning ("keep peer-level") is **overturned for the dispatch direction, kept for the verbs**: `connector_action` *does* become the general connector-extension action, while `http`/`notify` stay **peer-level raw verbs** (not specializations of it). `http_request` calls a URL with no registration; `connector_action` calls a registered, named capability. Both are baseline; neither is implemented in terms of the other. + +### Graceful degradation + +Because the registry starts empty, a flow that references a connector no plugin has registered **fails that step with a clear error** (`no handler for '.' — is the connector plugin registered?`) rather than failing to register the flow — the same fail-soft posture `http_request` takes on a bad URL. + +### Out of scope (deliberately not baseline) + +Managed credentials/secret vault, OAuth2 token refresh, multi-tenant connection lifecycle, and a connector marketplace are **not** part of this mechanism — they are the enterprise tier, on the ADR-0015 precedent. The open framework ships the contract + dispatch + an in-process registry only. + +### Implementation checklist + +- [x] `AutomationEngine`: connector registry (`registerConnector` / `unregisterConnector` / `resolveConnectorAction` / `getRegisteredConnectors`) + `ConnectorActionHandler` / `ConnectorActionContext` types. +- [x] `builtin/connector-nodes.ts`: `connector_action` executor + descriptor (`category: 'io'`, `source: 'builtin'`, `paradigms: ['flow','workflow_rule','approval']`), wired into `installBuiltinNodes()`. The core plugin now seeds 11 baseline node types (was 10). +- [x] First concrete plugin `@objectstack/connector-rest` (the reference connector) validating the registry — `request` action, static auth (`none`/`api-key`/`basic`/`bearer`), no OAuth2 refresh. +- [x] Tests: baseline dispatch (fake connector) + REST plugin auth-header injection + end-to-end kernel boot (both plugins → `connector_action` flow → REST handler). diff --git a/packages/connectors/connector-rest/LICENSE b/packages/connectors/connector-rest/LICENSE new file mode 100644 index 0000000000..93fba8d887 --- /dev/null +++ b/packages/connectors/connector-rest/LICENSE @@ -0,0 +1,93 @@ +License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +Parameters + +Licensor: ObjectStack AI LLC +Licensed Work: ObjectStack Runtime: the BSL-licensed packages + of the ObjectStack monorepo as listed in LICENSING.md. + Copyright (c) 2026 ObjectStack AI LLC. +Additional Use Grant: You may make production use of the Licensed Work, provided + Your use does not include offering the Licensed Work to third + parties on a hosted or embedded basis in order to compete with + ObjectStack AI LLC's paid version(s) of the Licensed Work. For purposes + of this license: + + A "competitive offering" is a Product that is offered to third + parties on a paid basis, including through paid support + arrangements, that significantly overlaps with the capabilities + of ObjectStack AI LLC's paid version(s) of the Licensed Work. If Your + Product is not a competitive offering when You first make it + generally available, it will not become a competitive offering + later due to ObjectStack AI LLC releasing a new version of the Licensed + Work with additional capabilities. In addition, Products that + are not provided on a paid basis are not competitive. + + "Product" means software that is offered to end users to manage + in their own environments or offered as a service on a hosted + basis. + + "Embedded" means including the source code or executable code + from the Licensed Work in a competitive offering. "Embedded" + also means packaging the competitive offering in such a way + that the Licensed Work must be accessed or downloaded for the + competitive offering to operate. + + Hosting or using the Licensed Work(s) for internal purposes + within an organization is not considered a competitive + offering. ObjectStack AI LLC considers your organization to include all + of your affiliates under common control. + + For binding interpretive guidance on using ObjectStack AI LLC products + under the Business Source License, please visit our FAQ. + (see LICENSING.md in this repository) +Change Date: Four years from the date the Licensed Work is published. +Change License: Apache License, Version 2.0 + +For information about alternative licensing arrangements for the Licensed Work, +please contact licensing@objectstack.dev. + +Notice + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json new file mode 100644 index 0000000000..c3c067e7b1 --- /dev/null +++ b/packages/connectors/connector-rest/package.json @@ -0,0 +1,36 @@ +{ + "name": "@objectstack/connector-rest", + "version": "7.3.0", + "license": "Apache-2.0", + "description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@objectstack/service-automation": "workspace:*", + "@types/node": "^25.9.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + }, + "keywords": [ + "objectstack", + "connector", + "rest", + "integration", + "http" + ] +} diff --git a/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts b/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts new file mode 100644 index 0000000000..a3508b49a1 --- /dev/null +++ b/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { ConnectorRestPlugin } from './connector-rest-plugin.js'; + +/** A fetch stub recording calls, returning a fixed JSON response. */ +function stubFetch() { + const calls: Array<{ url: string; init: RequestInit }> = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return { + status: 201, + ok: true, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'application/json' : null) }, + json: async () => ({ id: 'created-1' }), + text: async () => '{"id":"created-1"}', + }; + }) as unknown as typeof fetch; + return { impl, calls }; +} + +describe('ConnectorRestPlugin — end to end with the automation engine', () => { + it('registers the REST connector so a connector_action flow dispatches to it', async () => { + const { impl, calls } = stubFetch(); + + const kernel = new LiteKernel(); + kernel.use(new AutomationServicePlugin()); + kernel.use( + new ConnectorRestPlugin({ + baseUrl: 'https://api.example.com', + auth: { type: 'bearer', token: 'secret-token' }, + fetchImpl: impl, + }), + ); + await kernel.bootstrap(); + + const engine = kernel.getService('automation'); + + // The baseline node and the plugin-contributed connector are both present. + expect(engine.getRegisteredNodeTypes()).toContain('connector_action'); + expect(engine.getRegisteredConnectors()).toContain('rest'); + + engine.registerFlow('create_via_rest', { + name: 'create_via_rest', + label: 'Create via REST', + type: 'autolaunched', + variables: [{ name: 'call.body', type: 'json', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'call', + type: 'connector_action', + label: 'POST /items', + connectorConfig: { + connectorId: 'rest', + actionId: 'request', + input: { method: 'POST', path: '/items', body: { name: 'Widget' } }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }); + + const result = await engine.execute('create_via_rest'); + + expect(result.success).toBe(true); + // The REST connector handled the dispatch: one fetch with auth + body. + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://api.example.com/items'); + expect(calls[0].init.method).toBe('POST'); + expect((calls[0].init.headers as Record)['Authorization']).toBe('Bearer secret-token'); + // The action output propagated back into the flow. + expect(result.output).toEqual({ 'call.body': { id: 'created-1' } }); + + await kernel.shutdown(); + }); +}); diff --git a/packages/connectors/connector-rest/src/connector-rest-plugin.ts b/packages/connectors/connector-rest/src/connector-rest-plugin.ts new file mode 100644 index 0000000000..6621c63626 --- /dev/null +++ b/packages/connectors/connector-rest/src/connector-rest-plugin.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { Connector } from '@objectstack/spec/integration'; +import { createRestConnector, type RestConnectorOptions } from './rest-connector.js'; + +/** + * Minimal surface of the automation engine this plugin depends on — the + * connector registry from ADR-0018 §Addendum. Kept structural so the plugin + * needs no runtime dependency on `@objectstack/service-automation`. + */ +export interface ConnectorRegistrySurface { + registerConnector( + def: Connector, + handlers: Record< + string, + (input: Record, ctx: unknown) => Promise> + >, + ): void; + unregisterConnector(name: string): void; +} + +export interface ConnectorRestPluginOptions extends RestConnectorOptions {} + +/** + * ConnectorRestPlugin — registers a generic REST connector on the automation + * engine. This is the **reference concrete connector** (ADR-0018 §Addendum): + * the dispatch node + registry are baseline; a connector like this one is a + * plugin that populates the registry. + * + * If no automation engine is present the plugin logs and skips — the connector + * has nowhere to register, which is not an error. + */ +export class ConnectorRestPlugin implements Plugin { + name = 'com.objectstack.connector.rest'; + version = '1.0.0'; + type = 'standard' as const; + // Ensure the automation engine (and its connector registry) is started first. + dependencies = ['com.objectstack.service-automation']; + + private readonly options: ConnectorRestPluginOptions; + private connectorName?: string; + private automation?: ConnectorRegistrySurface; + + constructor(options: ConnectorRestPluginOptions) { + this.options = options; + } + + async init(_ctx: PluginContext): Promise { + // No services to register; the connector is registered in start() once + // the automation engine is available. + } + + async start(ctx: PluginContext): Promise { + let automation: ConnectorRegistrySurface | undefined; + try { + automation = ctx.getService('automation'); + } catch { + automation = undefined; + } + + if (!automation || typeof automation.registerConnector !== 'function') { + ctx.logger.info('ConnectorRestPlugin: no automation engine — REST connector not registered'); + return; + } + + const { def, handlers } = createRestConnector(this.options); + automation.registerConnector(def, handlers); + this.automation = automation; + this.connectorName = def.name; + ctx.logger.info(`ConnectorRestPlugin: REST connector '${def.name}' registered`); + } + + async stop(_ctx: PluginContext): Promise { + if (this.automation && this.connectorName) { + try { this.automation.unregisterConnector(this.connectorName); } catch { /* ignore */ } + } + } +} diff --git a/packages/connectors/connector-rest/src/index.ts b/packages/connectors/connector-rest/src/index.ts new file mode 100644 index 0000000000..a282b75ae1 --- /dev/null +++ b/packages/connectors/connector-rest/src/index.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/connector-rest + * + * Generic REST connector — the reference *concrete* connector (ADR-0018 + * §Addendum). The baseline automation engine ships the `connector_action` + * dispatch node + an empty connector registry; this plugin populates the + * registry with a `rest` connector exposing a `request` action. + * + * Static auth only (`none` / `api-key` / `basic` / `bearer`); OAuth2 refresh, + * credential vaulting, and multi-tenant lifecycle are the enterprise tier. + */ + +export { + createRestConnector, + type RestConnectorOptions, + type RestConnectorBundle, + type RestRequestInput, + type RestAuth, +} from './rest-connector.js'; +export { + ConnectorRestPlugin, + type ConnectorRestPluginOptions, + type ConnectorRegistrySurface, +} from './connector-rest-plugin.js'; diff --git a/packages/connectors/connector-rest/src/rest-connector.test.ts b/packages/connectors/connector-rest/src/rest-connector.test.ts new file mode 100644 index 0000000000..050a332757 --- /dev/null +++ b/packages/connectors/connector-rest/src/rest-connector.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { createRestConnector } from './rest-connector.js'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +interface CapturedCall { + url: string; + init: RequestInit; +} + +/** A fetch stub that records calls and returns a fixed JSON response. */ +function stubFetch(responseBody: unknown = { ok: true }, status = 200) { + const calls: CapturedCall[] = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'application/json' : null) }, + json: async () => responseBody, + text: async () => JSON.stringify(responseBody), + }; + }) as unknown as typeof fetch; + return { impl, calls }; +} + +function headersOf(call: CapturedCall): Record { + return (call.init.headers ?? {}) as Record; +} + +// ─── request action ────────────────────────────────────────────────── + +describe('createRestConnector — request action', () => { + it('builds the URL from base + path + query and returns the parsed body', async () => { + const { impl, calls } = stubFetch({ id: 1, name: 'Ada' }); + const { def, handlers } = createRestConnector({ baseUrl: 'https://api.example.com/', fetchImpl: impl }); + + expect(def.name).toBe('rest'); + expect(def.actions?.[0].key).toBe('request'); + + const out = await handlers.request({ path: '/users', query: { page: 2, active: true } }, {}); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://api.example.com/users?page=2&active=true'); + expect(calls[0].init.method).toBe('GET'); + expect(out).toEqual({ status: 200, ok: true, body: { id: 1, name: 'Ada' } }); + }); + + it('JSON-encodes the body and sets Content-Type for non-GET', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ baseUrl: 'https://api.example.com', fetchImpl: impl }); + + await handlers.request({ method: 'post', path: 'items', body: { name: 'x' } }, {}); + + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.body).toBe('{"name":"x"}'); + expect(headersOf(calls[0])['Content-Type']).toBe('application/json'); + }); + + it('does not send a body on GET', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ baseUrl: 'https://api.example.com', fetchImpl: impl }); + + await handlers.request({ method: 'GET', path: '/ping', body: { ignored: true } }, {}); + expect(calls[0].init.body).toBeUndefined(); + }); +}); + +// ─── auth injection ────────────────────────────────────────────────── + +describe('createRestConnector — static auth', () => { + it('injects a bearer token', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ + baseUrl: 'https://api.example.com', + auth: { type: 'bearer', token: 'tok-123' }, + fetchImpl: impl, + }); + await handlers.request({ path: '/me' }, {}); + expect(headersOf(calls[0])['Authorization']).toBe('Bearer tok-123'); + }); + + it('injects a basic auth header', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ + baseUrl: 'https://api.example.com', + auth: { type: 'basic', username: 'user', password: 'pass' }, + fetchImpl: impl, + }); + await handlers.request({ path: '/me' }, {}); + const expected = `Basic ${Buffer.from('user:pass').toString('base64')}`; + expect(headersOf(calls[0])['Authorization']).toBe(expected); + }); + + it('injects an api-key header by default', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ + baseUrl: 'https://api.example.com', + auth: { type: 'api-key', key: 'k-1', headerName: 'X-Api-Key' }, + fetchImpl: impl, + }); + await handlers.request({ path: '/me' }, {}); + expect(headersOf(calls[0])['X-Api-Key']).toBe('k-1'); + }); + + it('injects an api-key as a query param when paramName is set', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ + baseUrl: 'https://api.example.com', + auth: { type: 'api-key', key: 'k-1', headerName: 'X-API-Key', paramName: 'api_key' }, + fetchImpl: impl, + }); + await handlers.request({ path: '/me' }, {}); + expect(calls[0].url).toBe('https://api.example.com/me?api_key=k-1'); + expect(headersOf(calls[0])['X-API-Key']).toBeUndefined(); + }); + + it('adds no auth for type none', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ baseUrl: 'https://api.example.com', fetchImpl: impl }); + await handlers.request({ path: '/public' }, {}); + expect(headersOf(calls[0])['Authorization']).toBeUndefined(); + }); + + it('merges defaultHeaders, with per-request headers winning', async () => { + const { impl, calls } = stubFetch(); + const { handlers } = createRestConnector({ + baseUrl: 'https://api.example.com', + defaultHeaders: { 'X-Trace': 'on', 'X-Env': 'prod' }, + fetchImpl: impl, + }); + await handlers.request({ path: '/x', headers: { 'X-Env': 'dev' } }, {}); + const h = headersOf(calls[0]); + expect(h['X-Trace']).toBe('on'); + expect(h['X-Env']).toBe('dev'); + }); +}); diff --git a/packages/connectors/connector-rest/src/rest-connector.ts b/packages/connectors/connector-rest/src/rest-connector.ts new file mode 100644 index 0000000000..a98104434d --- /dev/null +++ b/packages/connectors/connector-rest/src/rest-connector.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Connector } from '@objectstack/spec/integration'; + +/** + * Generic REST connector — the reference *concrete* connector (ADR-0018 + * §Addendum). It produces a {@link Connector} definition plus the handler for + * its one action, `request`, which the baseline `connector_action` node + * dispatches to. + * + * Open-source scope: **static** auth only (`none` / `api-key` / `basic` / + * `bearer`), with credentials supplied by the caller. OAuth2 token acquisition + * and refresh, credential vaulting, and multi-tenant connection lifecycle are + * the enterprise tier (see `../cloud/docs/design/connector-tiering.md`) and are + * deliberately out of scope here. + */ + +/** Auth config understood by the REST connector (the static subset). */ +export type RestAuth = Extract< + Connector['authentication'], + { type: 'none' | 'api-key' | 'basic' | 'bearer' } +>; + +export interface RestConnectorOptions { + /** Connector machine name (snake_case). Defaults to `rest`. */ + name?: string; + /** Human-readable label. Defaults to a title derived from `name`. */ + label?: string; + /** Base URL prepended to each request's `path` (e.g. `https://api.example.com`). */ + baseUrl: string; + /** Static authentication. Defaults to `{ type: 'none' }`. */ + auth?: RestAuth; + /** Headers merged into every request (request-level headers win). */ + defaultHeaders?: Record; + /** Injected for tests; defaults to the global `fetch`. */ + fetchImpl?: typeof fetch; +} + +/** Input accepted by the `request` action. */ +export interface RestRequestInput { + method?: string; + path?: string; + headers?: Record; + query?: Record; + body?: unknown; +} + +/** A connector definition paired with its action handlers, ready for registerConnector(). */ +export interface RestConnectorBundle { + def: Connector; + handlers: Record< + string, + (input: Record, ctx: unknown) => Promise> + >; +} + +/** Build the request URL from base + path + query, encoding query params. */ +function buildUrl(baseUrl: string, path: string, query?: RestRequestInput['query']): string { + const base = baseUrl.replace(/\/+$/, ''); + const suffix = path ? (path.startsWith('/') ? path : `/${path}`) : ''; + const url = new URL(base + suffix); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); + } + } + return url.toString(); +} + +/** + * Apply static auth to the outgoing headers / query. Returns possibly-extended + * query so an `api-key` configured with `paramName` can ride the query string. + */ +function applyAuth( + auth: RestAuth, + headers: Record, + query: Record, +): void { + switch (auth.type) { + case 'none': + return; + case 'bearer': + headers['Authorization'] = `Bearer ${auth.token}`; + return; + case 'basic': { + const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64'); + headers['Authorization'] = `Basic ${encoded}`; + return; + } + case 'api-key': + if (auth.paramName) query[auth.paramName] = auth.key; + else headers[auth.headerName ?? 'X-API-Key'] = auth.key; + return; + } +} + +export function createRestConnector(opts: RestConnectorOptions): RestConnectorBundle { + const name = opts.name ?? 'rest'; + const auth: RestAuth = opts.auth ?? { type: 'none' }; + const doFetch = opts.fetchImpl ?? fetch; + + const def: Connector = { + name, + label: opts.label ?? 'REST Connector', + type: 'api', + description: 'Generic REST/HTTP connector with static authentication.', + icon: 'globe', + authentication: auth, + // Defaulted by ConnectorSchema; set explicitly so the literal satisfies + // the (post-parse) Connector output type. + status: 'active', + enabled: true, + connectionTimeoutMs: 30000, + requestTimeoutMs: 30000, + actions: [ + { + key: 'request', + label: 'HTTP Request', + description: 'Send an HTTP request to the connector\'s base URL with static auth applied.', + inputSchema: { + type: 'object', + properties: { + method: { type: 'string', description: 'HTTP method (default GET)' }, + path: { type: 'string', description: 'Path appended to the base URL' }, + headers: { type: 'object', description: 'Per-request headers' }, + query: { type: 'object', description: 'Query parameters' }, + body: { description: 'Request body (JSON-encoded for non-GET)' }, + }, + }, + outputSchema: { + type: 'object', + properties: { + status: { type: 'number' }, + ok: { type: 'boolean' }, + body: {}, + }, + }, + }, + ], + }; + + async function request(input: Record): Promise> { + const req = input as RestRequestInput; + const method = (req.method ?? 'GET').toUpperCase(); + const headers: Record = { ...opts.defaultHeaders, ...req.headers }; + const query: Record = { ...req.query }; + + applyAuth(auth, headers, query); + + const url = buildUrl(opts.baseUrl, req.path ?? '', query); + + const hasBody = req.body !== undefined && method !== 'GET' && method !== 'HEAD'; + if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) { + headers['Content-Type'] = 'application/json'; + } + + const response = await doFetch(url, { + method, + headers, + body: hasBody ? JSON.stringify(req.body) : undefined, + }); + + // Parse JSON when advertised; fall back to text so non-JSON endpoints + // don't throw. + const contentType = response.headers.get('content-type') ?? ''; + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text(); + + return { status: response.status, ok: response.ok, body: parsed }; + } + + return { def, handlers: { request } }; +} diff --git a/packages/connectors/connector-rest/tsconfig.json b/packages/connectors/connector-rest/tsconfig.json new file mode 100644 index 0000000000..385be7ea89 --- /dev/null +++ b/packages/connectors/connector-rest/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/services/service-automation/src/builtin/connector-nodes.test.ts b/packages/services/service-automation/src/builtin/connector-nodes.test.ts new file mode 100644 index 0000000000..71259b43f8 --- /dev/null +++ b/packages/services/service-automation/src/builtin/connector-nodes.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import type { ConnectorActionContext } from '../engine.js'; +import { registerConnectorNodes } from './connector-nodes.js'; +import type { Connector } from '@objectstack/spec/integration'; + +// ─── Test helpers ──────────────────────────────────────────────────── + +function createTestLogger() { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => createTestLogger(), + } as any; +} + +/** Minimal PluginContext — registerConnectorNodes only touches ctx.logger. */ +function createCtx() { + return { logger: createTestLogger() } as any; +} + +/** A fake connector with one echo action, for exercising the registry. */ +function fakeConnector(): Connector { + return { + name: 'fake', + label: 'Fake Connector', + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'echo', label: 'Echo' }], + } as Connector; +} + +// ─── connector_action baseline node ────────────────────────────────── + +describe('connector_action (baseline node)', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + registerConnectorNodes(engine, createCtx()); + }); + + it('publishes a builtin descriptor in the action registry', () => { + expect(engine.getRegisteredNodeTypes()).toContain('connector_action'); + const descriptor = engine.getActionDescriptor('connector_action'); + expect(descriptor).toBeDefined(); + expect(descriptor?.source).toBe('builtin'); + expect(descriptor?.category).toBe('io'); + expect(descriptor?.paradigms).toEqual( + expect.arrayContaining(['flow', 'workflow_rule', 'approval']), + ); + }); + + it('dispatches to the registered handler, passing input through, and surfaces output', async () => { + let received: { input: Record; ctx: ConnectorActionContext } | undefined; + engine.registerConnector(fakeConnector(), { + async echo(input, ctx) { + received = { input, ctx }; + return { echoed: input.message, upper: String(input.message).toUpperCase() }; + }, + }); + + engine.registerFlow('connector_flow', { + name: 'connector_flow', + label: 'Connector Flow', + type: 'autolaunched', + variables: [{ name: 'call.upper', type: 'text', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'call', + type: 'connector_action', + label: 'Call Fake', + connectorConfig: { connectorId: 'fake', actionId: 'echo', input: { message: 'hi' } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }); + + const result = await engine.execute('connector_flow'); + + expect(result.success).toBe(true); + // Handler was invoked with the node's mapped input. + expect(received?.input).toEqual({ message: 'hi' }); + // Handler context carries the live flow variable map + a logger. + expect(received?.ctx.variables).toBeInstanceOf(Map); + expect(typeof received?.ctx.logger.info).toBe('function'); + // Output is written back under `${nodeId}.${key}` and collected as flow output. + expect(result.output).toEqual({ 'call.upper': 'HI' }); + }); + + it('fails the step (not the flow registration) when the connector is unregistered', async () => { + engine.registerFlow('missing_connector', { + name: 'missing_connector', + label: 'Missing Connector', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'call', + type: 'connector_action', + label: 'Call Ghost', + connectorConfig: { connectorId: 'ghost', actionId: 'noop', input: {} }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }); + + const result = await engine.execute('missing_connector'); + expect(result.success).toBe(false); + expect(result.error).toContain('ghost.noop'); + }); + + it('fails the step when connectorConfig is missing required fields', async () => { + engine.registerFlow('bad_config', { + name: 'bad_config', + label: 'Bad Config', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: 'connector_action', label: 'No Config' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }); + + const result = await engine.execute('bad_config'); + expect(result.success).toBe(false); + expect(result.error).toContain('connectorId'); + }); +}); + +// ─── Engine connector registry ─────────────────────────────────────── + +describe('AutomationEngine connector registry', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + }); + + it('registers and lists a connector', () => { + engine.registerConnector(fakeConnector(), { async echo() { return {}; } }); + expect(engine.getRegisteredConnectors()).toContain('fake'); + expect(engine.resolveConnectorAction('fake', 'echo')).toBeTypeOf('function'); + }); + + it('throws when a declared action has no handler', () => { + expect(() => engine.registerConnector(fakeConnector(), {})).toThrow(/echo/); + }); + + it('rejects an invalid connector definition', () => { + expect(() => + engine.registerConnector({ name: 'Bad Name', type: 'api' } as any, {}), + ).toThrow(); + }); + + it('unregisters a connector', () => { + engine.registerConnector(fakeConnector(), { async echo() { return {}; } }); + engine.unregisterConnector('fake'); + expect(engine.getRegisteredConnectors()).not.toContain('fake'); + expect(engine.resolveConnectorAction('fake', 'echo')).toBeUndefined(); + }); +}); diff --git a/packages/services/service-automation/src/builtin/connector-nodes.ts b/packages/services/service-automation/src/builtin/connector-nodes.ts new file mode 100644 index 0000000000..3d3f9aee82 --- /dev/null +++ b/packages/services/service-automation/src/builtin/connector-nodes.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { PluginContext } from '@objectstack/core'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import type { AutomationEngine, ConnectorActionContext } from '../engine.js'; + +/** + * Connector built-in node — `connector_action` (generic integration dispatch). + * + * Part of the platform baseline alongside `http_request` (ADR-0018 §Addendum): + * where `http_request` calls *any raw URL*, `connector_action` invokes *any + * registered connector's action*. The platform ships the generic dispatch node + * + an (initially empty) connector registry on the engine; concrete connectors + * — `connector-rest`, `connector-slack`, `connector-salesforce`, … — populate + * the registry at runtime via `engine.registerConnector()`. + * + * Because the registry starts empty, a flow referencing a connector that no + * plugin has registered fails the *step* with a clear error rather than failing + * to register — graceful degradation matching `http_request`'s fail-soft style. + */ +export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginContext): void { + engine.registerNodeExecutor({ + type: 'connector_action', + descriptor: defineActionDescriptor({ + type: 'connector_action', + version: '1.0.0', + name: 'Connector Action', + description: + 'Invoke an action on a registered connector (Slack, Salesforce, a REST API, …). ' + + 'The connector itself is contributed by an integration plugin via registerConnector().', + icon: 'plug', + category: 'io', + source: 'builtin', + supportsRetry: true, + // Present in all three authoring paradigms (ADR-0018 §registry table). + paradigms: ['flow', 'workflow_rule', 'approval'], + // Config contract — drives the Studio property form and flow validation. + configSchema: { + type: 'object', + required: ['connectorId', 'actionId'], + properties: { + connectorId: { type: 'string', description: 'Registered connector name' }, + actionId: { type: 'string', description: 'Action key declared by the connector' }, + input: { type: 'object', description: 'Mapped inputs for the action' }, + }, + }, + }), + async execute(node, variables, context) { + const cfg = node.connectorConfig; + if (!cfg?.connectorId || !cfg?.actionId) { + return { + success: false, + error: `connector_action '${node.id}': connectorConfig.connectorId and .actionId are required`, + }; + } + + const handler = engine.resolveConnectorAction(cfg.connectorId, cfg.actionId); + if (!handler) { + return { + success: false, + error: + `connector_action '${node.id}': no handler for ` + + `'${cfg.connectorId}.${cfg.actionId}' — is the connector plugin registered?`, + }; + } + + const handlerCtx: ConnectorActionContext = { + variables, + automation: context, + logger: ctx.logger, + }; + + try { + const output = await handler((cfg.input ?? {}) as Record, handlerCtx); + return { success: true, output }; + } catch (err) { + return { + success: false, + error: `connector_action(${cfg.connectorId}.${cfg.actionId}) failed: ${(err as Error).message}`, + }; + } + }, + }); + + ctx.logger.info('[Connector] 1 built-in node executor registered (connector_action)'); +} diff --git a/packages/services/service-automation/src/builtin/http-nodes.ts b/packages/services/service-automation/src/builtin/http-nodes.ts index ab654fd24c..20309b6752 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.ts @@ -8,11 +8,11 @@ import type { AutomationEngine } from '../engine.js'; * HTTP built-in node — `http_request` (foundational outbound I/O). * * Part of the platform baseline, so the core {@link AutomationServicePlugin} - * seeds it directly (ADR-0018). The `connector_action` node was deliberately - * NOT kept in the baseline: it is an *integration* concern that depends on a - * connector registry the platform does not ship — the integration layer (or a - * marketplace plugin) registers it via `engine.registerNodeExecutor()` when - * connectors are present. + * seeds it directly (ADR-0018). Its generic-dispatch sibling `connector_action` + * (see {@link ./connector-nodes.ts}) is now also baseline: where `http_request` + * calls a raw URL, `connector_action` invokes a registered connector's action, + * with concrete connectors contributed by plugins via `engine.registerConnector()` + * (ADR-0018 §Addendum). * * ADR-0018 §M3 target: route `http_request` through the service-messaging * outbox (retry / idempotency / dead-letter) under the canonical `http` type. diff --git a/packages/services/service-automation/src/builtin/index.ts b/packages/services/service-automation/src/builtin/index.ts index daf7a1fe6b..52cb0aebc1 100644 --- a/packages/services/service-automation/src/builtin/index.ts +++ b/packages/services/service-automation/src/builtin/index.ts @@ -14,12 +14,14 @@ * - data — get/create/update/delete_record (platform CRUD baseline) * - human — screen / script (core flow capability) * - io — http_request (foundational outbound I/O) + * - io — connector_action (generic integration dispatch) * - * Deliberately NOT baseline: `connector_action` (an integration concern that - * needs a connector registry the platform does not ship). Third-party node - * types — including connector_action — extend the registry at runtime via - * `engine.registerNodeExecutor()`, keeping the action vocabulary open and - * marketplace-extensible. + * `connector_action` is the *generic dispatch* counterpart to `http_request` + * (ADR-0018 §Addendum): the platform ships the node + an (initially empty) + * connector registry on the engine, and *concrete* connectors populate it at + * runtime via `engine.registerConnector()`. Third-party node types continue to + * extend the vocabulary via `engine.registerNodeExecutor()`, keeping the action + * list open and marketplace-extensible. */ import type { PluginContext } from '@objectstack/core'; @@ -28,11 +30,13 @@ import { registerLogicNodes } from './logic-nodes.js'; import { registerCrudNodes } from './crud-nodes.js'; import { registerScreenNodes } from './screen-nodes.js'; import { registerHttpNodes } from './http-nodes.js'; +import { registerConnectorNodes } from './connector-nodes.js'; export { registerLogicNodes } from './logic-nodes.js'; export { registerCrudNodes } from './crud-nodes.js'; export { registerScreenNodes } from './screen-nodes.js'; export { registerHttpNodes } from './http-nodes.js'; +export { registerConnectorNodes } from './connector-nodes.js'; /** * Seed every built-in node executor into the engine. Called by @@ -44,6 +48,7 @@ export function installBuiltinNodes(engine: AutomationEngine, ctx: PluginContext registerCrudNodes(engine, ctx); registerScreenNodes(engine, ctx); registerHttpNodes(engine, ctx); + registerConnectorNodes(engine, ctx); const types = engine.getRegisteredNodeTypes(); ctx.logger.info( diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 5d8aa00d1a..44b1d72fdd 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -561,8 +561,10 @@ describe('AutomationServicePlugin (Kernel Integration)', () => { // HTTP node (foundational I/O) expect(nodeTypes).toContain('http_request'); - // connector_action is an integration concern — NOT in the built-in baseline. - expect(nodeTypes).not.toContain('connector_action'); + // connector_action is the generic-dispatch sibling of http_request and is + // baseline (ADR-0018 §Addendum): the engine ships the node + an empty + // connector registry; concrete connectors are plugins. + expect(nodeTypes).toContain('connector_action'); await kernel.shutdown(); }); @@ -767,10 +769,11 @@ describe('Built-in HTTP node', () => { expect(types).toContain('http_request'); }); - it('should NOT register connector_action in the built-in baseline', () => { - // connector_action is an integration concern requiring a connector - // registry the platform does not ship — left to the integration layer. - expect(engine.getRegisteredNodeTypes()).not.toContain('connector_action'); + it('should register connector_action in the built-in baseline', () => { + // connector_action is baseline (ADR-0018 §Addendum): the engine ships the + // generic-dispatch node + an empty connector registry; concrete connectors + // are contributed by plugins via engine.registerConnector(). + expect(engine.getRegisteredNodeTypes()).toContain('connector_action'); }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 1d377dbaf4..d95b8a629c 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -5,6 +5,8 @@ import type { ExecutionLog, ActionDescriptor } from '@objectstack/spec/automatio import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService } from '@objectstack/spec/contracts'; import type { Logger } from '@objectstack/spec/contracts'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES } from '@objectstack/spec/automation'; +import type { Connector } from '@objectstack/spec/integration'; +import { ConnectorSchema } from '@objectstack/spec/integration'; // ─── Node Executor Interface (Plugin Extension Point) ─────────────── @@ -73,6 +75,40 @@ export interface FlowTrigger { stop(flowName: string): void; } +// ─── Connector Registry (Plugin Extension Point) ──────────────────── + +/** + * Context handed to a connector action handler. Carries the live flow variable + * map and the trigger context so a handler can read prior-node output, plus a + * logger. The platform ships the registry + the `connector_action` dispatch + * node (baseline, ADR-0018 §Addendum); *concrete* connectors — `connector-rest`, + * `connector-slack`, … — are plugins that register handlers here. + */ +export interface ConnectorActionContext { + readonly variables: Map; + readonly automation: AutomationContext; + readonly logger: Logger; +} + +/** + * A handler for one connector action. Receives the (already-resolved) input + * mapped from the flow node and returns the action's output, which the + * `connector_action` node writes back into flow variables. + */ +export type ConnectorActionHandler = ( + input: Record, + ctx: ConnectorActionContext, +) => Promise>; + +/** + * A connector registered on the engine: its validated {@link Connector} + * definition plus the handler for each action it declares. + */ +export interface RegisteredConnector { + readonly def: Connector; + readonly handlers: Record; +} + // ─── Core Automation Engine ───────────────────────────────────────── /** @@ -155,6 +191,8 @@ export class AutomationEngine implements IAutomationService { private nodeExecutors = new Map(); private actionDescriptors = new Map(); private triggers = new Map(); + /** Connectors registered by integration plugins, keyed by connector name (ADR-0018 §Addendum). */ + private connectors = new Map(); private executionLogs: ExecutionLogEntry[] = []; private maxLogSize = 1000; private logger: Logger; @@ -218,6 +256,51 @@ export class AutomationEngine implements IAutomationService { this.logger.info(`Trigger unregistered: ${type}`); } + /** + * Register a connector (called by integration plugins, ADR-0018 §Addendum). + * Validates the definition against {@link ConnectorSchema} and asserts every + * declared action has a handler, so a half-wired connector fails loudly at + * registration rather than silently at dispatch. Re-registering the same + * name replaces (mirrors {@link registerNodeExecutor}). + */ + registerConnector(def: Connector, handlers: Record): void { + const parsed = ConnectorSchema.parse(def); + for (const action of parsed.actions ?? []) { + if (typeof handlers[action.key] !== 'function') { + throw new Error( + `Connector '${parsed.name}': action '${action.key}' is declared but no handler was provided`, + ); + } + } + if (this.connectors.has(parsed.name)) { + this.logger.warn(`Connector '${parsed.name}' replaced`); + } + this.connectors.set(parsed.name, { def: parsed, handlers }); + this.logger.info( + `Connector registered: ${parsed.name} (${Object.keys(handlers).length} action handlers)`, + ); + } + + /** Unregister a connector (hot-unplug). */ + unregisterConnector(name: string): void { + this.connectors.delete(name); + this.logger.info(`Connector unregistered: ${name}`); + } + + /** + * Resolve the handler for a connector action, used by the baseline + * `connector_action` node. Returns `undefined` when the connector or action + * is not registered, so the node can fail the step with a clear error. + */ + resolveConnectorAction(connectorId: string, actionId: string): ConnectorActionHandler | undefined { + return this.connectors.get(connectorId)?.handlers[actionId]; + } + + /** Get all registered connector names. */ + getRegisteredConnectors(): string[] { + return [...this.connectors.keys()]; + } + /** Get all registered node types */ getRegisteredNodeTypes(): string[] { return [...this.nodeExecutors.keys()]; diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index e53d3efa54..ea0269f030 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -2,7 +2,14 @@ // Core engine export { AutomationEngine } from './engine.js'; -export type { NodeExecutor, NodeExecutionResult, FlowTrigger } from './engine.js'; +export type { + NodeExecutor, + NodeExecutionResult, + FlowTrigger, + ConnectorActionHandler, + ConnectorActionContext, + RegisteredConnector, +} from './engine.js'; // Kernel plugin — seeds all built-in nodes; this is the only plugin needed for // a fully-functional automation capability. @@ -18,4 +25,5 @@ export { registerCrudNodes, registerScreenNodes, registerHttpNodes, + registerConnectorNodes, } from './builtin/index.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30472379bd..03c434ec8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -662,6 +662,28 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/connectors/connector-rest: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@objectstack/service-automation': + specifier: workspace:* + version: link:../../services/service-automation + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(happy-dom@20.9.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/console: {} packages/core: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 55f1a3f63a..ea36df85b9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - packages/plugins/* - packages/services/* - packages/adapters/* + - packages/connectors/* - apps/* - examples/*