diff --git a/.changeset/config.json b/.changeset/config.json index 87fa3f1c8..ec21fa401 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,11 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", - "changelog": ["@changesets/cli/changelog", { "repo": "objectstack-ai/spec" }], + "changelog": [ + "@changesets/cli/changelog", + { + "repo": "objectstack-ai/spec" + } + ], "commit": false, "fixed": [ [ @@ -71,7 +76,8 @@ "@objectstack/embedder-openai", "@objectstack/account", "create-objectstack", - "objectstack-vscode" + "objectstack-vscode", + "@objectstack/connector-openapi" ] ], "linked": [], diff --git a/.changeset/openapi-to-connector.md b/.changeset/openapi-to-connector.md new file mode 100644 index 000000000..b0e645dd5 --- /dev/null +++ b/.changeset/openapi-to-connector.md @@ -0,0 +1,5 @@ +--- +"@objectstack/connector-openapi": minor +--- + +Add `@objectstack/connector-openapi` — generate an ObjectStack connector from a declarative OpenAPI 3.x document (ADR-0023). One operation becomes one connector action; a single generic handler drives a self-contained static-auth HTTP transport (mirroring `@objectstack/connector-rest`). The generated `type: 'api'` connector registers via `engine.registerConnector(def, handlers)` with no new engine surface, and supports an `include` allowlist for trimming large specs. diff --git a/docs/adr/0023-openapi-to-connector-generator.md b/docs/adr/0023-openapi-to-connector-generator.md index e671e4546..479871088 100644 --- a/docs/adr/0023-openapi-to-connector-generator.md +++ b/docs/adr/0023-openapi-to-connector-generator.md @@ -141,8 +141,8 @@ A spec that declares OAuth2 is still importable open-source — we generate the ## Status & follow-ups -- **This ADR changes no shipped code.** It records the decision to add a generator package on top of the existing connector baseline. -- Follow-up: scaffold `packages/connectors/connector-openapi` mirroring `connector-rest`, with `createOpenApiConnector` + `ConnectorOpenApiPlugin`. -- Follow-up: a small `openapi-to-connector` CLI that emits a reviewable `*.connector.json`. +- ✅ **Implemented:** `packages/connectors/connector-openapi` mirrors `connector-rest` / `connector-mcp`, exporting `createOpenApiConnector` (one operation → one action) + `registerOpenApiConnector`. Each generated action drives one shared static-auth HTTP transport that mirrors `connector-rest` (build URL from base+path+query, apply static auth, JSON-encode the body, normalise the response to `{ status, ok, body }`) — kept inline so the package stays self-contained (depends only on `@objectstack/core` + `@objectstack/spec`, like its sibling connectors), and returns the `{ def, handlers }` shape consumed by `engine.registerConnector`. Input schemas are assembled as `{ path, query, header, body }` from `parameters` + `requestBody`; output schemas from the `200`/`2xx`/`default` JSON response; static auth (`none`/`api-key`/`basic`/`bearer`) is supplied by the caller. Includes an `include` allowlist for trimming large specs. +- Follow-up: a small `openapi-to-connector` CLI that emits a reviewable `*.connector.json` (deferred — needs a multi-entry build, whereas every connector package currently uses the shared single-entry `tsup` config). - Follow-up: a worked example (e.g. GitHub or Stripe public OpenAPI → a handful of allowlisted actions) under `examples/`, paralleling the worked `connector_action` example from ADR-0022. +- Follow-up: YAML spec input and a `$ref` deref pass for specs the caller has not pre-dereferenced. - Cross-reference: see [ADR-0024](./0024-mcp-connectors.md) for the complementary path — wrapping live **MCP servers** as connectors when no OpenAPI spec exists but an MCP server does. diff --git a/packages/connectors/connector-openapi/LICENSE b/packages/connectors/connector-openapi/LICENSE new file mode 100644 index 000000000..93fba8d88 --- /dev/null +++ b/packages/connectors/connector-openapi/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-openapi/package.json b/packages/connectors/connector-openapi/package.json new file mode 100644 index 000000000..88a9bd6f4 --- /dev/null +++ b/packages/connectors/connector-openapi/package.json @@ -0,0 +1,37 @@ +{ + "name": "@objectstack/connector-openapi", + "version": "7.3.0", + "license": "Apache-2.0", + "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", + "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", + "openapi", + "swagger", + "integration", + "api" + ] +} diff --git a/packages/connectors/connector-openapi/src/connector-openapi-plugin.test.ts b/packages/connectors/connector-openapi/src/connector-openapi-plugin.test.ts new file mode 100644 index 000000000..d574ee64c --- /dev/null +++ b/packages/connectors/connector-openapi/src/connector-openapi-plugin.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { registerOpenApiConnector, type ConnectorRegistrySurface } from './connector-openapi-plugin.js'; +import type { OpenApiDocument } from './openapi-connector.js'; + +const doc: OpenApiDocument = { + info: { title: 'Mini' }, + servers: [{ url: 'https://api.mini.example.com' }], + paths: { + '/ping': { get: { operationId: 'ping', responses: { '200': { description: 'ok' } } } }, + }, +}; + +describe('registerOpenApiConnector', () => { + it('registers the generated definition + handlers on the registry', () => { + const registerConnector = vi.fn(); + const registry = { registerConnector, unregisterConnector: vi.fn() } as unknown as ConnectorRegistrySurface; + + const name = registerOpenApiConnector(registry, { document: doc }); + + expect(name).toBe('mini'); + expect(registerConnector).toHaveBeenCalledTimes(1); + const [def, handlers] = registerConnector.mock.calls[0]; + expect(def.name).toBe('mini'); + expect(typeof handlers.ping).toBe('function'); + }); +}); diff --git a/packages/connectors/connector-openapi/src/connector-openapi-plugin.ts b/packages/connectors/connector-openapi/src/connector-openapi-plugin.ts new file mode 100644 index 000000000..4d9dbf4ad --- /dev/null +++ b/packages/connectors/connector-openapi/src/connector-openapi-plugin.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Connector } from '@objectstack/spec/integration'; +import { createOpenApiConnector, type OpenApiConnectorConfig } from './openapi-connector.js'; + +/** + * Minimal surface of the automation engine this helper depends on — the + * connector registry from ADR-0018 §Addendum. Kept structural so callers need + * no runtime dependency on `@objectstack/service-automation` (mirrors + * connector-rest / connector-mcp). + */ +export interface ConnectorRegistrySurface { + registerConnector( + def: Connector, + handlers: Record< + string, + (input: Record, ctx: unknown) => Promise> + >, + ): void; + unregisterConnector(name: string): void; +} + +/** + * Generate an OpenAPI-backed connector and register it on the engine's connector + * registry so the baseline `connector_action` node can dispatch to the generated + * actions (ADR-0023). Returns the registered connector name. + */ +export function registerOpenApiConnector(registry: ConnectorRegistrySurface, config: OpenApiConnectorConfig): string { + const { def, handlers } = createOpenApiConnector(config); + registry.registerConnector(def, handlers); + return def.name; +} diff --git a/packages/connectors/connector-openapi/src/index.ts b/packages/connectors/connector-openapi/src/index.ts new file mode 100644 index 000000000..53cd6eee6 --- /dev/null +++ b/packages/connectors/connector-openapi/src/index.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/connector-openapi + * + * Generate an ObjectStack {@link Connector} from a declarative OpenAPI 3.x + * document (ADR-0023). One operation becomes one connector action; a single + * generic handler drives a self-contained static-auth HTTP transport (mirroring + * `@objectstack/connector-rest`). The generated connector is an ordinary + * `type: 'api'` connector — registered via `engine.registerConnector` with no + * new engine surface. + * + * Open-source scope: static auth only (`none` / `api-key` / `basic` / `bearer`), + * credentials supplied by the caller. Managed OAuth2, credential vaulting, and + * per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022). + */ + +export { + createOpenApiConnector, + type OpenApiConnectorBundle, + type OpenApiConnectorConfig, + type OpenApiDocument, + type OpenApiPathItem, + type OpenApiOperation, + type OpenApiParameter, + type OpenApiRequestBody, + type OpenApiResponse, + type OpenApiSecurityScheme, + type OperationInfo, + type RestAuth, + type JsonSchema, +} from './openapi-connector.js'; +export { + registerOpenApiConnector, + type ConnectorRegistrySurface, +} from './connector-openapi-plugin.js'; diff --git a/packages/connectors/connector-openapi/src/openapi-connector.test.ts b/packages/connectors/connector-openapi/src/openapi-connector.test.ts new file mode 100644 index 000000000..ae4de3f06 --- /dev/null +++ b/packages/connectors/connector-openapi/src/openapi-connector.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { createOpenApiConnector, type OpenApiDocument } from './openapi-connector.js'; + +const doc: OpenApiDocument = { + openapi: '3.0.0', + info: { title: 'Pet Store', description: 'A sample pet API' }, + servers: [{ url: 'https://api.pets.example.com/v1' }], + components: { + securitySchemes: { apiKey: { type: 'apiKey', name: 'X-API-Key', in: 'header' } }, + }, + paths: { + '/pets/{petId}': { + get: { + operationId: 'getPetById', + summary: 'Get a pet by id', + tags: ['pets'], + parameters: [ + { name: 'petId', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'detail', in: 'query', schema: { type: 'string' } }, + ], + responses: { + '200': { content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'integer' } } } } } }, + }, + }, + }, + '/pets': { + post: { + operationId: 'createPet', + summary: 'Create a pet', + tags: ['pets'], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string' } } } } }, + }, + responses: { '201': { description: 'created' } }, + }, + get: { + // no operationId — exercises the slug fallback + summary: 'List pets', + tags: ['admin'], + responses: { '200': { description: 'ok' } }, + }, + }, + }, +}; + +/** A fake fetch that records calls and returns a JSON response (mirrors connector-rest tests). */ +function fakeFetch(payload: unknown) { + const calls: Array<{ url: string; init: { method?: string; body?: unknown; headers?: Record } }> = []; + const fetchImpl = (async (url: string, init: { method?: string; body?: unknown; headers?: Record }) => { + calls.push({ url, init }); + return { + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + json: async () => payload, + text: async () => JSON.stringify(payload), + }; + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +describe('createOpenApiConnector', () => { + it('derives connector metadata from info + servers', () => { + const { def } = createOpenApiConnector({ document: doc }); + expect(def.name).toBe('pet_store'); + expect(def.label).toBe('Pet Store'); + expect(def.description).toBe('A sample pet API'); + expect(def.type).toBe('api'); + }); + + it('maps each operation to an action and falls back to a slug key', () => { + const { def } = createOpenApiConnector({ document: doc }); + const keys = (def.actions ?? []).map((a) => a.key); + expect(keys).toContain('getPetById'); + expect(keys).toContain('createPet'); + expect(keys).toContain('get_pets'); // GET /pets has no operationId + }); + + it('assembles input schema sections from parameters and requestBody', () => { + const { def } = createOpenApiConnector({ document: doc }); + const get = def.actions?.find((a) => a.key === 'getPetById'); + expect(get?.inputSchema).toMatchObject({ + type: 'object', + properties: { + path: { type: 'object', properties: { petId: { type: 'integer' } }, required: ['petId'] }, + query: { type: 'object', properties: { detail: { type: 'string' } } }, + }, + required: ['path'], + }); + + const post = def.actions?.find((a) => a.key === 'createPet'); + expect(post?.inputSchema).toMatchObject({ + properties: { body: { type: 'object', properties: { name: { type: 'string' } } } }, + required: ['body'], + }); + }); + + it('picks the success response schema as the output schema', () => { + const { def } = createOpenApiConnector({ document: doc }); + const get = def.actions?.find((a) => a.key === 'getPetById'); + expect(get?.outputSchema).toMatchObject({ type: 'object', properties: { id: { type: 'integer' } } }); + }); + + it('defaults authentication to none and reflects supplied credentials', () => { + expect(createOpenApiConnector({ document: doc }).def.authentication).toEqual({ type: 'none' }); + const withAuth = createOpenApiConnector({ document: doc, auth: { type: 'bearer', token: 'secret' } }); + expect(withAuth.def.authentication).toEqual({ type: 'bearer', token: 'secret' }); + }); + + it('honors an include allowlist', () => { + const { def } = createOpenApiConnector({ document: doc, include: (op) => (op.tags ?? []).includes('pets') }); + expect((def.actions ?? []).map((a) => a.key)).toEqual(['getPetById', 'createPet']); + }); + + it('handler interpolates path params and forwards query via the REST request', async () => { + const { calls, fetchImpl } = fakeFetch({ id: 42 }); + const { handlers } = createOpenApiConnector({ document: doc, fetchImpl }); + const result = await handlers.getPetById({ path: { petId: 42 }, query: { detail: 'full' } }, {}); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://api.pets.example.com/v1/pets/42?detail=full'); + expect(calls[0].init.method).toBe('GET'); + expect(result).toEqual({ status: 200, ok: true, body: { id: 42 } }); + }); + + it('handler sends a JSON body for write operations', async () => { + const { calls, fetchImpl } = fakeFetch({}); + const { handlers } = createOpenApiConnector({ document: doc, fetchImpl }); + await handlers.createPet({ body: { name: 'Rex' } }, {}); + + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.body).toBe(JSON.stringify({ name: 'Rex' })); + }); + + it('throws when no base URL can be determined', () => { + expect(() => createOpenApiConnector({ document: { info: { title: 'x' }, paths: {} } })).toThrow(/base URL/); + }); +}); diff --git a/packages/connectors/connector-openapi/src/openapi-connector.ts b/packages/connectors/connector-openapi/src/openapi-connector.ts new file mode 100644 index 000000000..a586f08d4 --- /dev/null +++ b/packages/connectors/connector-openapi/src/openapi-connector.ts @@ -0,0 +1,414 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Connector } from '@objectstack/spec/integration'; + +/** + * OpenAPI connector generator — turns a declarative OpenAPI 3.x document into a + * {@link Connector} definition + handler map (ADR-0023). + * + * Each OpenAPI operation maps to one connector action; a single generic handler + * (closing over the operation's method + path template) drives one shared HTTP + * request implementation. That transport mirrors `@objectstack/connector-rest` + * (build URL from base+path+query, apply static auth, JSON-encode the body, + * normalise the response to `{ status, ok, body }`) — kept inline so this package + * stays self-contained, depending only on `@objectstack/core` + `@objectstack/spec` + * like its sibling connectors. The output is an ordinary `type: 'api'` connector, + * registered via `engine.registerConnector(def, handlers)` exactly like a + * hand-written one — the registry, the `connector_action` node, the discovery + * route, and the Studio palette never know it came from OpenAPI. + * + * Open-source scope: **static** auth only (`none` / `api-key` / `basic` / + * `bearer`), with credentials supplied by the caller. Managed OAuth2, credential + * vaulting, and per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022). + */ + +/** Static auth understood by the generated connector (the open-source subset). */ +export type RestAuth = Extract; + +/** An action on a Connector definition (derived to avoid guessing export names). */ +type ConnectorAction = NonNullable[number]; + +/** Handler signature accepted by the connector registry (ADR-0018 §Addendum). */ +type ConnectorHandler = (input: Record, ctx: unknown) => Promise>; + +/** A connector definition paired with its action handlers, ready for registerConnector(). */ +export interface OpenApiConnectorBundle { + def: Connector; + handlers: Record; +} + +/** A free-form JSON Schema fragment (matches ConnectorAction input/outputSchema). */ +export type JsonSchema = Record; + +/** Minimal subset of an OpenAPI 3.x document consumed by the generator. + * The caller is responsible for loading and de-referencing ($ref) the doc. */ +export interface OpenApiDocument { + openapi?: string; + info?: { title?: string; description?: string; version?: string }; + servers?: { url: string }[]; + paths?: Record; + components?: { securitySchemes?: Record }; +} + +export interface OpenApiPathItem { + [method: string]: OpenApiOperation | unknown; +} + +export interface OpenApiOperation { + operationId?: string; + summary?: string; + description?: string; + tags?: string[]; + parameters?: OpenApiParameter[]; + requestBody?: OpenApiRequestBody; + responses?: Record; +} + +export interface OpenApiParameter { + name: string; + in: 'path' | 'query' | 'header' | 'cookie'; + required?: boolean; + description?: string; + schema?: JsonSchema; +} + +export interface OpenApiRequestBody { + required?: boolean; + description?: string; + content?: Record; +} + +export interface OpenApiResponse { + description?: string; + content?: Record; +} + +export interface OpenApiSecurityScheme { + type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect'; + name?: string; + in?: 'header' | 'query' | 'cookie'; + scheme?: string; +} + +/** Flattened view of a single operation, passed to the `include` predicate. */ +export interface OperationInfo { + operationId?: string; + method: string; + path: string; + tags?: string[]; + summary?: string; + description?: string; +} + +/** Configuration for {@link createOpenApiConnector}. */ +export interface OpenApiConnectorConfig { + /** Connector machine name (snake_case). Defaults to a slug of info.title. */ + name?: string; + /** Human-friendly label. Defaults to info.title (then name). */ + label?: string; + /** Description. Defaults to info.description. */ + description?: string; + /** Icon identifier for the Studio palette. Defaults to `globe`. */ + icon?: string; + /** The parsed OpenAPI 3.x document (caller loads/derefs it). */ + document: OpenApiDocument; + /** Override the base URL (else servers[0].url). */ + baseUrl?: string; + /** Static auth with credentials. Defaults to `{ type: 'none' }`. */ + auth?: RestAuth; + /** Headers merged into every request (request-level headers win). */ + defaultHeaders?: Record; + /** Only include operations for which this predicate returns true (allowlist). */ + include?: (op: OperationInfo) => boolean; + /** Injected fetch implementation (defaults to global `fetch`). */ + fetchImpl?: typeof fetch; +} + +/** OpenAPI HTTP method keys, in a deterministic order. */ +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'] as const; + +/** Input passed to the shared request transport. */ +interface RequestInput { + method: string; + path: string; + headers?: Record; + query?: Record; + body?: unknown; +} + +/** + * Build an OpenAPI connector definition and its handler map. + * + * @returns the `Connector` definition (`def`) and a `handlers` record keyed by + * action key, suitable for `engine.registerConnector(def, handlers)`. + */ +export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiConnectorBundle { + const { document, include } = config; + const auth: RestAuth = config.auth ?? { type: 'none' }; + const doFetch = config.fetchImpl ?? fetch; + const name = config.name ?? slug(document.info?.title ?? 'openapi_connector'); + const label = config.label ?? document.info?.title ?? titleize(name); + const description = config.description ?? document.info?.description; + const baseUrl = config.baseUrl ?? document.servers?.[0]?.url; + if (!baseUrl) { + throw new Error('createOpenApiConnector: no base URL — provide config.baseUrl or document.servers[0].url'); + } + + // One shared transport (mirrors connector-rest) reused by every action handler. + async function request(input: RequestInput): Promise> { + const method = input.method.toUpperCase(); + const headers: Record = { ...config.defaultHeaders, ...input.headers }; + const query: Record = { ...input.query }; + applyAuth(auth, headers, query); + + const url = buildUrl(baseUrl as string, input.path, query); + const hasBody = input.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(input.body) : undefined, + }); + + 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 }; + } + + const actions: ConnectorAction[] = []; + const handlers: Record = {}; + const seenKeys = new Set(); + + for (const op of collectOperations(document)) { + if (include && !include(toInfo(op))) continue; + const key = uniqueKey(op.operationId ?? slug(`${op.method}_${op.path}`), seenKeys); + + actions.push({ + key, + label: op.summary ?? titleize(key), + description: op.description, + inputSchema: buildInputSchema(op), + outputSchema: buildOutputSchema(op), + }); + + handlers[key] = async (input: Record) => { + const req = input as { path?: unknown; query?: unknown; header?: unknown; body?: unknown }; + return request({ + method: op.method, + path: interpolatePath(op.path, asRecord(req.path)), + query: stringifyValues(asRecord(req.query)), + headers: stringifyValues(asRecord(req.header)), + body: req.body, + }); + }; + } + + const def: Connector = { + name, + label, + type: 'api', + description, + icon: config.icon ?? 'globe', + authentication: auth, + // Defaulted by ConnectorSchema; set explicitly so the literal satisfies + // the (post-parse) Connector output type (mirrors connector-rest/mcp). + status: 'active', + enabled: true, + connectionTimeoutMs: 30000, + requestTimeoutMs: 30000, + actions, + }; + + return { def, handlers }; +} + +interface Op extends OpenApiOperation { + method: string; + path: string; +} + +/** Flatten paths × methods into a deterministic list of operations. */ +function collectOperations(doc: OpenApiDocument): Op[] { + const ops: Op[] = []; + for (const [path, item] of Object.entries(doc.paths ?? {})) { + if (!item || typeof item !== 'object') continue; + const record = item as Record; + for (const method of HTTP_METHODS) { + const operation = record[method] as OpenApiOperation | undefined; + if (!operation || typeof operation !== 'object') continue; + ops.push({ ...operation, method, path }); + } + } + return ops; +} + +function toInfo(op: Op): OperationInfo { + return { + operationId: op.operationId, + method: op.method, + path: op.path, + tags: op.tags, + summary: op.summary, + description: op.description, + }; +} + +/** + * Assemble the action inputSchema from an operation's parameters + requestBody. + * Produces { type: 'object', properties: { path, query, header, body }, required } + * where only non-empty sections are emitted. + */ +function buildInputSchema(op: OpenApiOperation): JsonSchema | undefined { + const sections: Record<'path' | 'query' | 'header', { props: Record; required: string[] }> = { + path: { props: {}, required: [] }, + query: { props: {}, required: [] }, + header: { props: {}, required: [] }, + }; + + for (const p of op.parameters ?? []) { + if (!p || typeof p !== 'object' || '$ref' in p) continue; + if (p.in !== 'path' && p.in !== 'query' && p.in !== 'header') continue; + const sec = sections[p.in]; + sec.props[p.name] = p.schema ?? (p.description ? { type: 'string', description: p.description } : { type: 'string' }); + if (p.required) sec.required.push(p.name); + } + + const properties: Record = {}; + const required: string[] = []; + for (const where of ['path', 'query', 'header'] as const) { + const sec = sections[where]; + if (Object.keys(sec.props).length === 0) continue; + const schema: JsonSchema = { type: 'object', properties: sec.props }; + if (sec.required.length) schema.required = sec.required; + properties[where] = schema; + // Path params are always required when present; others only if any are. + if (where === 'path' || sec.required.length) required.push(where); + } + + const bodySchema = extractRequestBodySchema(op.requestBody); + if (bodySchema) { + properties.body = bodySchema; + if (op.requestBody && !('$ref' in op.requestBody) && op.requestBody.required) required.push('body'); + } + + if (Object.keys(properties).length === 0) return undefined; + const schema: JsonSchema = { type: 'object', properties }; + if (required.length) schema.required = required; + return schema; +} + +/** Pick the success response's JSON schema (200 → first 2xx → default). */ +function buildOutputSchema(op: OpenApiOperation): JsonSchema | undefined { + const responses = op.responses; + if (!responses) return undefined; + let code: string | undefined; + if (responses['200']) code = '200'; + else code = Object.keys(responses).find((c) => /^2\d\d$/.test(c)); + if (!code && responses['default']) code = 'default'; + if (!code) return undefined; + const resp = responses[code]; + if (!resp || typeof resp !== 'object' || '$ref' in resp) return undefined; + return pickJsonSchema(resp.content); +} + +/** Extract the requestBody JSON schema (prefers application/json). */ +function extractRequestBodySchema(rb: OpenApiRequestBody | undefined): JsonSchema | undefined { + if (!rb || typeof rb !== 'object' || '$ref' in rb) return undefined; + return pickJsonSchema(rb.content); +} + +/** Choose the application/json schema, falling back to the first content type. */ +function pickJsonSchema(content: Record | undefined): JsonSchema | undefined { + if (!content) return undefined; + const chosen = content['application/json'] ?? Object.values(content)[0]; + return chosen?.schema; +} + +/** Build the request URL from base + path + query, encoding query params. */ +function buildUrl(baseUrl: string, path: string, query: Record): string { + const base = baseUrl.replace(/\/+$/, ''); + const suffix = path ? (path.startsWith('/') ? path : `/${path}`) : ''; + const url = new URL(base + suffix); + 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 (mirrors connector-rest). */ +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; + } +} + +/** Interpolate {name} path templates with encoded values from the input. */ +function interpolatePath(template: string, pathParams: Record): string { + return template.replace(/\{([^}]+)\}/g, (_match, key: string) => { + const value = pathParams[key]; + return value === undefined || value === null ? `{${key}}` : encodeURIComponent(String(value)); + }); +} + +/** Coerce a record of mixed values into string values, dropping null/undefined. */ +function stringifyValues(rec: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(rec)) { + if (v === undefined || v === null) continue; + out[k] = String(v); + } + return out; +} + +/** Return v if it is a plain object, else an empty record. */ +function asRecord(v: unknown): Record { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; +} + +/** Ensure a deterministically unique action key within the connector. */ +function uniqueKey(base: string, seen: Set): string { + let candidate = base; + if (seen.has(candidate)) { + let i = 2; + while (seen.has(`${base}_${i}`)) i++; + candidate = `${base}_${i}`; + } + seen.add(candidate); + return candidate; +} + +/** Slugify a string into a snake_case machine name (`/^[a-z_][a-z0-9_]*$/`). */ +function slug(s: string): string { + const out = s + .normalize('NFKD') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase(); + if (!out) return 'connector'; + return /^[a-z_]/.test(out) ? out : `op_${out}`; +} + +/** Title-case a snake_case key for a default label (`get_pets` → `Get Pets`). */ +function titleize(name: string): string { + return name + .split('_') + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} diff --git a/packages/connectors/connector-openapi/tsconfig.json b/packages/connectors/connector-openapi/tsconfig.json new file mode 100644 index 000000000..385be7ea8 --- /dev/null +++ b/packages/connectors/connector-openapi/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/pnpm-lock.yaml b/pnpm-lock.yaml index 9a0784a7e..bd9a7dbea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,6 +687,28 @@ importers: 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/connectors/connector-openapi: + 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/connectors/connector-rest: dependencies: '@objectstack/core':