History

Extract OpenAPI backed plugin helpers

Rhys Sullivan committed 4 months ago

Commit f80115d1 parent 86a2d4a3 files · +425 −435Browse files

Unified · Split

3 files
  1. packages/plugins/openapi/src/sdk/backing.ts +382 −0
  2. packages/plugins/openapi/src/sdk/index.ts +12 −0
  3. packages/plugins/openapi/src/sdk/plugin.ts +31 −435
packages/plugins/openapi/src/sdk/backing.ts+382−0
@@ -0,0 +1,382 @@
1+import { Effect, Option, Schema } from "effect";
2+import type { Layer } from "effect";
3+import { HttpClient } from "effect/unstable/http";
4+
5+import {
6+ ToolFileJsonSchema,
7+ ToolName,
8+ ToolResult,
9+ authToolFailure,
10+ type PluginCtx,
11+ type ResolveToolsResult,
12+ type StorageFailure,
13+ type ToolDef,
14+ type ToolInvocationCredential,
15+} from "@executor-js/sdk/core";
16+
17+import {
18+ decodeOpenApiIntegrationConfig,
19+ renderAuthTemplate,
20+ requiredTemplateVariables,
21+ type OpenApiIntegrationConfig,
22+} from "./config";
23+import { OpenApiExtractionError, OpenApiParseError } from "./errors";
24+import { extract } from "./extract";
25+import { compileToolDefinitions, type ToolDefinition } from "./definitions";
26+import { annotationsForOperation, invokeWithLayer } from "./invoke";
27+import { parse } from "./parse";
28+import { type OpenapiStore, type StoredOperation } from "./store";
29+import { OperationBinding } from "./types";
30+
31+const STRINGIFIED_BODY_CAP = 1024;
32+const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
33+const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
34+const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
35+const UpstreamErrorsArrayBody = Schema.Struct({
36+ errors: Schema.Array(
37+ Schema.Struct({
38+ detail: Schema.optional(Schema.String),
39+ message: Schema.optional(Schema.String),
40+ title: Schema.optional(Schema.String),
41+ }),
42+ ),
43+});
44+const UpstreamDescriptionBody = Schema.Struct({
45+ detail: Schema.optional(Schema.String),
46+ title: Schema.optional(Schema.String),
47+ description: Schema.optional(Schema.String),
48+});
49+
50+const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
51+const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
52+const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
53+const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
54+const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody);
55+
56+const clampedStringify = (value: unknown): string => {
57+ let s: string;
58+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text
59+ try {
60+ s = JSON.stringify(value);
61+ } catch {
62+ s = String(value);
63+ }
64+ return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s;
65+};
66+
67+const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined =>
68+ values.find((value) => value !== undefined && value.length > 0);
69+
70+export const extractOpenApiUpstreamMessage = (body: unknown, status: number): string => {
71+ if (typeof body === "string") {
72+ return body.length > 0 ? body : `Upstream returned HTTP ${status}`;
73+ }
74+ const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body));
75+ const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body));
76+ const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body));
77+ const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body));
78+ const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body));
79+ const arrayMessage = errorsBody?.errors
80+ .map(
81+ ({
82+ detail,
83+ message: upstreamMessage,
84+ title,
85+ }: {
86+ detail?: string;
87+ message?: string;
88+ title?: string;
89+ }) => firstNonEmpty(detail, upstreamMessage, title),
90+ )
91+ .find((message: string | undefined) => message !== undefined);
92+ const message = firstNonEmpty(
93+ nested?.error.message,
94+ messageBody?.message,
95+ errorMessageBody?.errorMessage,
96+ arrayMessage,
97+ descriptionBody?.detail,
98+ descriptionBody?.title,
99+ descriptionBody?.description,
100+ );
101+ if (message !== undefined) return message;
102+ if (body !== null && typeof body === "object") {
103+ return clampedStringify(body);
104+ }
105+ return `Upstream returned HTTP ${status}`;
106+};
107+
108+const openApiAuthToolFailure = (failure: {
109+ readonly code: string;
110+ readonly message: string;
111+ readonly owner: "org" | "user";
112+ readonly integration: string;
113+ readonly connection: string;
114+ readonly credentialKind: "secret" | "oauth" | "upstream";
115+ readonly credentialLabel?: string;
116+ readonly status?: number;
117+ readonly details?: unknown;
118+}) =>
119+ authToolFailure({
120+ code: failure.code as Parameters<typeof authToolFailure>[0]["code"],
121+ message: failure.message,
122+ source: { id: failure.integration, scope: failure.owner },
123+ credential: {
124+ kind: failure.credentialKind,
125+ ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}),
126+ },
127+ ...(failure.status !== undefined ? { status: failure.status } : {}),
128+ ...(failure.details !== undefined
129+ ? {
130+ upstream: {
131+ ...(failure.status !== undefined ? { status: failure.status } : {}),
132+ details: failure.details,
133+ },
134+ }
135+ : {}),
136+ });
137+
138+/** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */
139+export const normalizeOpenApiRefs = (node: unknown): unknown => {
140+ if (node == null || typeof node !== "object") return node;
141+ if (Array.isArray(node)) {
142+ let changed = false;
143+ const out = node.map((item) => {
144+ const n = normalizeOpenApiRefs(item);
145+ if (n !== item) changed = true;
146+ return n;
147+ });
148+ return changed ? out : node;
149+ }
150+
151+ const obj = node as Record<string, unknown>;
152+
153+ if (typeof obj.$ref === "string") {
154+ const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/);
155+ if (match) return { ...obj, $ref: `#/$defs/${match[1]}` };
156+ return obj;
157+ }
158+
159+ let changed = false;
160+ const result: Record<string, unknown> = {};
161+ for (const [k, v] of Object.entries(obj)) {
162+ const n = normalizeOpenApiRefs(v);
163+ if (n !== v) changed = true;
164+ result[k] = n;
165+ }
166+ return changed ? result : obj;
167+};
168+
169+const toBinding = (def: ToolDefinition): OperationBinding =>
170+ OperationBinding.make({
171+ method: def.operation.method,
172+ servers: def.operation.servers,
173+ pathTemplate: def.operation.pathTemplate,
174+ parameters: [...def.operation.parameters],
175+ requestBody: def.operation.requestBody,
176+ responseBody: def.operation.responseBody,
177+ });
178+
179+const descriptionFor = (def: ToolDefinition): string => {
180+ const op = def.operation;
181+ return Option.getOrElse(op.description, () =>
182+ Option.getOrElse(op.summary, () => `${op.method.toUpperCase()} ${op.pathTemplate}`),
183+ );
184+};
185+
186+export interface CompiledOpenApiSpec {
187+ readonly definitions: readonly ToolDefinition[];
188+ readonly hoistedDefs: Record<string, unknown>;
189+ readonly title: string | undefined;
190+ readonly description: string | undefined;
191+}
192+
193+export const compileOpenApiSpec = (
194+ specText: string,
195+): Effect.Effect<CompiledOpenApiSpec, OpenApiParseError | OpenApiExtractionError> =>
196+ Effect.gen(function* () {
197+ const doc = yield* parse(specText);
198+ const result = yield* extract(doc);
199+ const hoistedDefs: Record<string, unknown> = {};
200+ if (doc.components?.schemas) {
201+ for (const [k, v] of Object.entries(doc.components.schemas)) {
202+ hoistedDefs[k] = normalizeOpenApiRefs(v);
203+ }
204+ }
205+ return {
206+ definitions: compileToolDefinitions(result.operations),
207+ hoistedDefs,
208+ title: Option.getOrUndefined(result.title),
209+ description: Option.getOrUndefined(result.description),
210+ };
211+ });
212+
213+export const openApiToolDefsFromCompiled = (compiled: CompiledOpenApiSpec): readonly ToolDef[] =>
214+ compiled.definitions.map(
215+ (def): ToolDef => ({
216+ name: ToolName.make(def.toolPath),
217+ description: descriptionFor(def),
218+ inputSchema: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.inputSchema)),
219+ outputSchema: Option.match(def.operation.responseBody, {
220+ onNone: () => normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)),
221+ onSome: (responseBody) =>
222+ Option.isSome(responseBody.fileHint)
223+ ? ToolFileJsonSchema
224+ : normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)),
225+ }),
226+ annotations: annotationsForOperation(def.operation.method, def.operation.pathTemplate),
227+ }),
228+ );
229+
230+export const openApiStoredOperationsFromCompiled = (
231+ integration: string,
232+ compiled: CompiledOpenApiSpec,
233+): readonly StoredOperation[] =>
234+ compiled.definitions.map((def) => ({
235+ integration,
236+ toolName: def.toolPath,
237+ binding: toBinding(def),
238+ }));
239+
240+export const loadOpenApiSpecText = (
241+ storage: OpenapiStore,
242+ config: OpenApiIntegrationConfig,
243+): Effect.Effect<string | null, StorageFailure> =>
244+ config.specHash != null ? storage.getSpec(config.specHash) : Effect.succeed(null);
245+
246+export const resolveOpenApiBackedTools = ({
247+ config,
248+ storage,
249+}: {
250+ readonly config: unknown;
251+ readonly storage: OpenapiStore;
252+}): Effect.Effect<ResolveToolsResult, StorageFailure> =>
253+ Effect.gen(function* () {
254+ const openApiConfig = decodeOpenApiIntegrationConfig(config);
255+ if (!openApiConfig) return { tools: [], definitions: {} };
256+ const specText = yield* loadOpenApiSpecText(storage, openApiConfig);
257+ if (specText == null) return { tools: [], definitions: {} };
258+ const compiled = yield* compileOpenApiSpec(specText).pipe(
259+ Effect.catch(() => Effect.succeed(null)),
260+ );
261+ if (!compiled) return { tools: [], definitions: {} };
262+ return {
263+ tools: openApiToolDefsFromCompiled(compiled),
264+ definitions: compiled.hoistedDefs,
265+ };
266+ });
267+
268+export const invokeOpenApiBackedTool = (input: {
269+ readonly ctx: PluginCtx<OpenapiStore>;
270+ readonly toolRow: { readonly integration: string; readonly name: string };
271+ readonly credential: ToolInvocationCredential;
272+ readonly args: unknown;
273+ readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;
274+}) =>
275+ Effect.gen(function* () {
276+ const integration = input.toolRow.integration;
277+ const config = decodeOpenApiIntegrationConfig(input.credential.config);
278+
279+ let binding = (yield* input.ctx.storage.getOperation(integration, input.toolRow.name))?.binding;
280+ if ((!binding || Option.isNone(binding.responseBody)) && config) {
281+ const specText = yield* loadOpenApiSpecText(input.ctx.storage, config).pipe(
282+ Effect.catch(() => Effect.succeed(null)),
283+ );
284+ const compiled =
285+ specText == null
286+ ? null
287+ : yield* compileOpenApiSpec(specText).pipe(Effect.catch(() => Effect.succeed(null)));
288+ binding = compiled
289+ ? openApiStoredOperationsFromCompiled(integration, compiled).find(
290+ (op) => op.toolName === input.toolRow.name,
291+ )?.binding
292+ : undefined;
293+ }
294+ if (!binding) {
295+ return yield* new OpenApiExtractionError({
296+ message: `No OpenAPI operation found for tool "${input.toolRow.name}" on "${integration}"`,
297+ });
298+ }
299+
300+ const headers: Record<string, string> = { ...(config?.headers ?? {}) };
301+ const queryParams: Record<string, string> = {
302+ ...(config?.queryParams ?? {}),
303+ };
304+
305+ const template = (config?.authenticationTemplate ?? []).find(
306+ (entry) => String(entry.slug) === String(input.credential.template),
307+ );
308+ if (template) {
309+ const missing = requiredTemplateVariables(template).filter((name) => {
310+ const value = input.credential.values[name];
311+ return value == null || value === "";
312+ });
313+ if (missing.length > 0) {
314+ return openApiAuthToolFailure({
315+ code:
316+ template.kind === "oauth2" ? "oauth_connection_missing" : "connection_value_missing",
317+ message: `Connection "${input.credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,
318+ owner: input.credential.owner,
319+ integration,
320+ connection: String(input.credential.connection),
321+ credentialKind: template.kind === "oauth2" ? "oauth" : "secret",
322+ });
323+ }
324+ const rendered = renderAuthTemplate(template, input.credential.values);
325+ Object.assign(headers, rendered.headers);
326+ Object.assign(queryParams, rendered.queryParams);
327+ }
328+
329+ const result = yield* invokeWithLayer(
330+ binding,
331+ (input.args ?? {}) as Record<string, unknown>,
332+ config?.baseUrl ?? "",
333+ headers,
334+ queryParams,
335+ input.httpClientLayer,
336+ );
337+
338+ const ok = result.status >= 200 && result.status < 300;
339+ if (!ok) {
340+ if (result.status === 401 || result.status === 403) {
341+ return openApiAuthToolFailure({
342+ code: "connection_rejected",
343+ status: result.status,
344+ message: `Upstream rejected credentials for "${integration}" with HTTP ${result.status}. Re-authenticate or update the connection "${input.credential.connection}" before retrying this tool.`,
345+ owner: input.credential.owner,
346+ integration,
347+ connection: String(input.credential.connection),
348+ credentialKind: "upstream",
349+ credentialLabel: "Upstream authorization",
350+ details: result.error,
351+ });
352+ }
353+ return ToolResult.fail({
354+ code: "upstream_http_error",
355+ status: result.status,
356+ message: extractOpenApiUpstreamMessage(result.error, result.status),
357+ details: result.error,
358+ });
359+ }
360+ return ToolResult.ok(result.data, {
361+ http: { status: result.status, headers: result.headers },
362+ });
363+ });
364+
365+export const resolveOpenApiBackedAnnotations = (input: {
366+ readonly ctx: PluginCtx<OpenapiStore>;
367+ readonly integration: string;
368+ readonly toolRows: readonly { readonly name: string }[];
369+}) =>
370+ Effect.gen(function* () {
371+ const ops = yield* input.ctx.storage.listOperations(String(input.integration));
372+ const byName = new Map<string, OperationBinding>();
373+ for (const op of ops) byName.set(op.toolName, op.binding);
374+ const out: Record<string, ReturnType<typeof annotationsForOperation>> = {};
375+ for (const row of input.toolRows) {
376+ const binding = byName.get(row.name);
377+ if (binding) {
378+ out[row.name] = annotationsForOperation(binding.method, binding.pathTemplate);
379+ }
380+ }
381+ return out;
382+ });
packages/plugins/openapi/src/sdk/index.ts+12−0
@@ -8,6 +8,18 @@ export { · 7 unmodified lines
88 } from "./google-discovery";
99 export { extract } from "./extract";
1010 export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";
11+export {
12+ compileOpenApiSpec,
13+ extractOpenApiUpstreamMessage,
14+ invokeOpenApiBackedTool,
15+ loadOpenApiSpecText,
16+ normalizeOpenApiRefs,
17+ openApiStoredOperationsFromCompiled,
18+ openApiToolDefsFromCompiled,
19+ resolveOpenApiBackedAnnotations,
20+ resolveOpenApiBackedTools,
21+ type CompiledOpenApiSpec,
22+} from "./backing";
1123 export {
1224 openApiPlugin,
1325 type OpenApiSpecConfig,
packages/plugins/openapi/src/sdk/plugin.ts+31−435
@@ -7,10 +7,7 @@ import { · 6 unmodified lines
77 IntegrationDetectionResult,
88 IntegrationNotFoundError,
99 IntegrationSlug,
10− ToolName,
11− ToolFileJsonSchema,
1210 ToolResult,
13− authToolFailure,
1411 definePlugin,
1512 mergeAuthTemplates,
1613 sha256Hex,
@@ -20,18 +17,10 @@ import { · 3 unmodified lines
2017 type IntegrationConfig,
2118 type IntegrationRecord,
2219 type PluginCtx,
23− type ResolveToolsResult,
2420 type StorageFailure,
25− type ToolDef,
26− type ToolInvocationCredential,
2721 } from "@executor-js/sdk/core";
2822
29−import {
30− decodeOpenApiIntegrationConfig,
31− renderAuthTemplate,
32− requiredTemplateVariables,
33− type OpenApiIntegrationConfig,
34−} from "./config";
23+import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";
3524 import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";
3625 import { parse, resolveSpecText } from "./parse";
3726 import {
@@ -41,98 +30,20 @@ import { · 3 unmodified lines
4130 isGoogleDiscoveryUrl,
4231 } from "./google-discovery";
4332 import { extract } from "./extract";
44−import { compileToolDefinitions, type ToolDefinition } from "./definitions";
45−import { annotationsForOperation, invokeWithLayer } from "./invoke";
4633 import { previewSpec, previewSpecText, type SpecPreview } from "./preview";
4734 import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
4835 import { openApiPresets } from "./presets";
49−import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
36+import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";
5037 import type { Authentication } from "./types";
51−import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
38+import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
5239 import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
53−
54−// ---------------------------------------------------------------------------
55−// Plugin config
56−// ---------------------------------------------------------------------------
57−
58−const STRINGIFIED_BODY_CAP = 1024;
59−const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
60−const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
61−const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
62−const UpstreamErrorsArrayBody = Schema.Struct({
63− errors: Schema.Array(
64− Schema.Struct({
65− detail: Schema.optional(Schema.String),
66− message: Schema.optional(Schema.String),
67− title: Schema.optional(Schema.String),
68− }),
69− ),
70−});
71−const UpstreamDescriptionBody = Schema.Struct({
72− detail: Schema.optional(Schema.String),
73− title: Schema.optional(Schema.String),
74− description: Schema.optional(Schema.String),
75−});
76−
77−const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
78−const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
79−const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
80−const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
81−const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody);
82−
83−const clampedStringify = (value: unknown): string => {
84− let s: string;
85− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text
86− try {
87− s = JSON.stringify(value);
88− } catch {
89− s = String(value);
90− }
91− return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s;
92−};
93−
94−const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined =>
95− values.find((value) => value !== undefined && value.length > 0);
96−
97−// Walk known upstream error-body shapes so ToolError.message stays concise
98−// while ToolError.details preserves the original body.
99−const extractUpstreamMessage = (body: unknown, status: number): string => {
100− if (typeof body === "string") {
101− return body.length > 0 ? body : `Upstream returned HTTP ${status}`;
102− }
103− const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body));
104− const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body));
105− const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body));
106− const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body));
107− const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body));
108− const arrayMessage = errorsBody?.errors
109− .map(
110− ({
111− detail,
112− message: upstreamMessage,
113− title,
114− }: {
115− detail?: string;
116− message?: string;
117− title?: string;
118− }) => firstNonEmpty(detail, upstreamMessage, title),
119− )
120− .find((message: string | undefined) => message !== undefined);
121− const message = firstNonEmpty(
122− nested?.error.message,
123− messageBody?.message,
124− errorMessageBody?.errorMessage,
125− arrayMessage,
126− descriptionBody?.detail,
127− descriptionBody?.title,
128− descriptionBody?.description,
129− );
130− if (message !== undefined) return message;
131− if (body !== null && typeof body === "object") {
132− return clampedStringify(body);
133− }
134− return `Upstream returned HTTP ${status}`;
135−};
40+import {
41+ compileOpenApiSpec,
42+ invokeOpenApiBackedTool,
43+ openApiStoredOperationsFromCompiled,
44+ resolveOpenApiBackedAnnotations,
45+ resolveOpenApiBackedTools,
46+} from "./backing";
13647
13748 // ---------------------------------------------------------------------------
13849 // Extension input shapes
@@ -381,38 +292,6 @@ const openApiToolFailure = (code: string, message: string, details?: unknown) => · 242 unmodified lines
381292 ...(details === undefined ? {} : { details }),
382293 });
383294
384−const openApiAuthToolFailure = (failure: {
385− readonly code: string;
386− readonly message: string;
387− readonly owner: "org" | "user";
388− readonly integration: string;
389− readonly connection: string;
390− readonly credentialKind: "secret" | "oauth" | "upstream";
391− readonly credentialLabel?: string;
392− readonly status?: number;
393− readonly details?: unknown;
394−}) =>
395− authToolFailure({
396− // The auth-tool-failure helper's code set is shared with v1; keep the
397− // string but reference the connection rather than v1's source/slot.
398− code: failure.code as Parameters<typeof authToolFailure>[0]["code"],
399− message: failure.message,
400− source: { id: failure.integration, scope: failure.owner },
401− credential: {
402− kind: failure.credentialKind,
403− ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}),
404− },
405− ...(failure.status !== undefined ? { status: failure.status } : {}),
406− ...(failure.details !== undefined
407− ? {
408− upstream: {
409− ...(failure.status !== undefined ? { status: failure.status } : {}),
410− details: failure.details,
411− },
412− }
413− : {}),
414− });
415−
416295 const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({
417296 title: Option.getOrNull(preview.title),
418297 version: Option.getOrNull(preview.version),
@@ -477,58 +356,6 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 58 unmodified lines
477356 })),
478357 });
479358
480−// ---------------------------------------------------------------------------
481−// Helpers
482−// ---------------------------------------------------------------------------
483−
484−/** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */
485−const normalizeOpenApiRefs = (node: unknown): unknown => {
486− if (node == null || typeof node !== "object") return node;
487− if (Array.isArray(node)) {
488− let changed = false;
489− const out = node.map((item) => {
490− const n = normalizeOpenApiRefs(item);
491− if (n !== item) changed = true;
492− return n;
493− });
494− return changed ? out : node;
495− }
496−
497− const obj = node as Record<string, unknown>;
498−
499− if (typeof obj.$ref === "string") {
500− const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/);
501− if (match) return { ...obj, $ref: `#/$defs/${match[1]}` };
502− return obj;
503− }
504−
505− let changed = false;
506− const result: Record<string, unknown> = {};
507− for (const [k, v] of Object.entries(obj)) {
508− const n = normalizeOpenApiRefs(v);
509− if (n !== v) changed = true;
510− result[k] = n;
511− }
512− return changed ? result : obj;
513−};
514−
515−const toBinding = (def: ToolDefinition): OperationBinding =>
516− OperationBinding.make({
517− method: def.operation.method,
518− servers: def.operation.servers,
519− pathTemplate: def.operation.pathTemplate,
520− parameters: [...def.operation.parameters],
521− requestBody: def.operation.requestBody,
522− responseBody: def.operation.responseBody,
523− });
524−
525−const descriptionFor = (def: ToolDefinition): string => {
526− const op = def.operation;
527− return Option.getOrElse(op.description, () =>
528− Option.getOrElse(op.summary, () => `${op.method.toUpperCase()} ${op.pathTemplate}`),
529− );
530−};
531−
532359 const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined =>
533360 spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined;
534361
@@ -575,88 +402,6 @@ export const describeOpenApiIntegrationDisplay = ( · 40 unmodified lines
575402 return { url: config?.baseUrl ?? config?.sourceUrl };
576403 };
577404
578−// ---------------------------------------------------------------------------
579−// Spec text resolution — the stored config carries the spec's content hash
580−// (`specHash` → blob `spec/<hash>`). Pre-blob rows that inlined the text are
581−// rewritten by the spec-to-blob migrations before this code reads them.
582−// ---------------------------------------------------------------------------
583−
584−const loadSpecText = (
585− storage: OpenapiStore,
586− config: OpenApiIntegrationConfig,
587−): Effect.Effect<string | null, StorageFailure> =>
588− config.specHash != null ? storage.getSpec(config.specHash) : Effect.succeed(null);
589−
590−// ---------------------------------------------------------------------------
591−// Spec → tool definitions (shared by addSpec, resolveTools, and detect)
592−// ---------------------------------------------------------------------------
593−
594−interface CompiledSpec {
595− readonly definitions: readonly ToolDefinition[];
596− readonly hoistedDefs: Record<string, unknown>;
597− readonly title: string | undefined;
598− /** The spec's `info.description`. */
599− readonly description: string | undefined;
600−}
601−
602−const compileSpec = (
603− specText: string,
604−): Effect.Effect<CompiledSpec, OpenApiParseError | OpenApiExtractionError> =>
605− Effect.gen(function* () {
606− const doc = yield* parse(specText);
607− const result = yield* extract(doc);
608− const hoistedDefs: Record<string, unknown> = {};
609− if (doc.components?.schemas) {
610− for (const [k, v] of Object.entries(doc.components.schemas)) {
611− hoistedDefs[k] = normalizeOpenApiRefs(v);
612− }
613− }
614− return {
615− definitions: compileToolDefinitions(result.operations),
616− hoistedDefs,
617− title: Option.getOrUndefined(result.title),
618− description: Option.getOrUndefined(result.description),
619− };
620− });
621−
622−// A tool's name carries its structured `group.leaf` path verbatim (e.g.
623−// `aliases.deleteAlias`). The address grammar
624−// `tools.<integration>.<owner>.<connection>.<tool>` treats `<tool>` as the
625−// trailing remainder (see parseToolAddress), so the dotted path needs no
626−// flattening — it nests naturally as
627−// `tools.<integration>.<owner>.<connection>.aliases.deleteAlias`, matching how
628−// the sandbox `tools` proxy joins property access.
629−
630−const toolDefsFromCompiled = (compiled: CompiledSpec): readonly ToolDef[] =>
631− compiled.definitions.map(
632− (def): ToolDef => ({
633− name: ToolName.make(def.toolPath),
634− description: descriptionFor(def),
635− inputSchema: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.inputSchema)),
636− // The output schema is the upstream response body only — transport
637− // status/headers live in the ToolResult `http` side channel, not the
638− // payload (see the invoke handler).
639− outputSchema: Option.match(def.operation.responseBody, {
640− onNone: () => normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)),
641− onSome: (responseBody) =>
642− Option.isSome(responseBody.fileHint)
643− ? ToolFileJsonSchema
644− : normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)),
645− }),
646− annotations: annotationsForOperation(def.operation.method, def.operation.pathTemplate),
647− }),
648− );
649−
650−const storedOperationsFromCompiled = (
651− integration: string,
652− compiled: CompiledSpec,
653−): readonly StoredOperation[] =>
654− compiled.definitions.map((def) => ({
655− integration,
656− toolName: def.toolPath,
657− binding: toBinding(def),
658− }));
659−
660405 // ---------------------------------------------------------------------------
661406 // Plugin factory
662407 // ---------------------------------------------------------------------------
@@ -752,7 +497,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 89 unmodified lines
752497 // Resolve URL → text and parse BEFORE opening a transaction. Holding
753498 // `BEGIN` across a network fetch is the Hyperdrive deadlock path.
754499 const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);
755− const compiled = yield* compileSpec(resolved.specText);
500+ const compiled = yield* compileOpenApiSpec(resolved.specText);
756501
757502 // Defaults the add page derives from its preview, applied here so
758503 // headless callers (MCP, API) get the same integration the UI's
@@ -846,7 +591,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 87 unmodified lines
846591 });
847592 yield* ctx.storage.putOperations(
848593 config.slug,
849− storedOperationsFromCompiled(config.slug, compiled),
594+ openApiStoredOperationsFromCompiled(config.slug, compiled),
850595 );
851596 }),
852597 );
@@ -890,7 +635,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 37 unmodified lines
890635 // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock
891636 // rule as addSpec: never hold BEGIN across a network fetch).
892637 const resolved = yield* resolveSpecForInput(specInput, httpClientLayer);
893− const compiled = yield* compileSpec(resolved.specText);
638+ const compiled = yield* compileOpenApiSpec(resolved.specText);
894639
895640 const previousOperations = yield* ctx.storage.listOperations(rawSlug);
896641 const previousNames = new Set(previousOperations.map((op) => op.toolName));
@@ -921,7 +666,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 24 unmodified lines
921666 });
922667 yield* ctx.storage.putOperations(
923668 rawSlug,
924− storedOperationsFromCompiled(rawSlug, compiled),
669+ openApiStoredOperationsFromCompiled(rawSlug, compiled),
925670 );
926671 }),
927672 );
@@ -1133,173 +878,24 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 205 unmodified lines
1133878 // operation bindings invokeTool needs are persisted at addSpec time; this
1134879 // hook only shapes the per-connection ToolDefs from the spec blob the
1135880 // catalog config points at.
1136− resolveTools: ({
1137− config,
1138− storage,
1139− }: {
1140− readonly integration: Integration;
1141− readonly config: IntegrationConfig;
1142− readonly storage: OpenapiStore;
1143− }): Effect.Effect<ResolveToolsResult, StorageFailure> =>
1144− Effect.gen(function* () {
1145− const openApiConfig = decodeOpenApiIntegrationConfig(config);
1146− if (!openApiConfig) return { tools: [], definitions: {} };
1147− const specText = yield* loadSpecText(storage, openApiConfig);
1148− if (specText == null) return { tools: [], definitions: {} };
1149− const compiled = yield* compileSpec(specText).pipe(
1150− Effect.catch(() => Effect.succeed(null)),
1151− );
1152− if (!compiled) return { tools: [], definitions: {} };
1153− return {
1154− tools: toolDefsFromCompiled(compiled),
1155− definitions: compiled.hoistedDefs,
1156− };
1157− }),
1158−
1159− invokeTool: ({
1160− ctx: invokeCtx,
1161− toolRow,
1162− credential,
1163− args,
1164− }: {
1165− readonly ctx: PluginCtx<OpenapiStore>;
1166− readonly toolRow: { readonly integration: string; readonly name: string };
1167− readonly credential: ToolInvocationCredential;
1168− readonly args: unknown;
1169− }) =>
1170− Effect.gen(function* () {
1171− const httpClientLayer = options?.httpClientLayer ?? invokeCtx.httpClientLayer;
1172− const integration = toolRow.integration;
1173− const config = decodeOpenApiIntegrationConfig(credential.config);
1174−
1175− // Resolve the operation binding from the plugin store; fall back to
1176− // re-deriving it from the spec blob when the store has no row (e.g. an
1177− // integration registered without going through addSpec). The fallback
1178− // is the ONLY invoke-path spec read — the hot path never loads it.
1179− const bindingFromSpec = config
1180− ? Effect.gen(function* () {
1181− const specText = yield* loadSpecText(invokeCtx.storage, config).pipe(
1182− Effect.catch(() => Effect.succeed(null)),
1183− );
1184− const compiled =
1185− specText == null
1186− ? null
1187− : yield* compileSpec(specText).pipe(Effect.catch(() => Effect.succeed(null)));
1188− return compiled
1189− ? storedOperationsFromCompiled(integration, compiled).find(
1190− (op) => op.toolName === toolRow.name,
1191− )?.binding
1192− : undefined;
1193− })
1194− : Effect.succeed(undefined);
1195−
1196− let binding = (yield* invokeCtx.storage.getOperation(integration, toolRow.name))?.binding;
1197− if ((!binding || Option.isNone(binding.responseBody)) && config) {
1198− binding = (yield* bindingFromSpec) ?? binding;
1199− }
1200− if (!binding) {
1201− return yield* new OpenApiExtractionError({
1202− message: `No OpenAPI operation found for tool "${toolRow.name}" on "${integration}"`,
1203− });
1204− }
1205−
1206− const headers: Record<string, string> = { ...(config?.headers ?? {}) };
1207− const queryParams: Record<string, string> = {
1208− ...(config?.queryParams ?? {}),
1209− };
1210−
1211− // Apply the auth template (D11): render the connection's resolved inputs
1212− // into the matching template's header/query slots (apiKey) or as a bearer
1213− // Authorization header (oauth). A method with two distinct inputs (e.g.
1214− // Datadog) renders each from its own value.
1215− const template = (config?.authenticationTemplate ?? []).find(
1216− (entry) => String(entry.slug) === String(credential.template),
1217− );
1218− if (template) {
1219− // Fail if ANY input the template requires is unresolved — not just the
1220− // primary `token` (a Datadog connection has no `token`, only its keys).
1221− const missing = requiredTemplateVariables(template).filter((name) => {
1222− const value = credential.values[name];
1223− return value == null || value === "";
1224− });
1225− if (missing.length > 0) {
1226− return openApiAuthToolFailure({
1227− code:
1228− template.kind === "oauth2"
1229− ? "oauth_connection_missing"
1230− : "connection_value_missing",
1231− message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,
1232− owner: credential.owner,
1233− integration,
1234− connection: String(credential.connection),
1235− credentialKind: template.kind === "oauth2" ? "oauth" : "secret",
1236− });
1237− }
1238− const rendered = renderAuthTemplate(template, credential.values);
1239− Object.assign(headers, rendered.headers);
1240− Object.assign(queryParams, rendered.queryParams);
1241− }
1242−
1243− const result = yield* invokeWithLayer(
1244− binding,
1245− (args ?? {}) as Record<string, unknown>,
1246− config?.baseUrl ?? "",
1247− headers,
1248− queryParams,
1249− httpClientLayer,
1250− );
1251−
1252− const ok = result.status >= 200 && result.status < 300;
1253− if (!ok) {
1254− if (result.status === 401 || result.status === 403) {
1255− return openApiAuthToolFailure({
1256− code: "connection_rejected",
1257− status: result.status,
1258− message: `Upstream rejected credentials for "${integration}" with HTTP ${result.status}. Re-authenticate or update the connection "${credential.connection}" before retrying this tool.`,
1259− owner: credential.owner,
1260− integration,
1261− connection: String(credential.connection),
1262− credentialKind: "upstream",
1263− credentialLabel: "Upstream authorization",
1264− details: result.error,
1265− });
1266− }
1267− return ToolResult.fail({
1268− code: "upstream_http_error",
1269− status: result.status,
1270− message: extractUpstreamMessage(result.error, result.status),
1271− details: result.error,
1272− });
1273− }
1274− // Payload-first: `data` is the upstream response body (matching the
1275− // graphql/mcp plugins); transport facts ride in the `http` side
1276− // channel so pagination/rate-limit headers stay reachable.
1277− return ToolResult.ok(result.data, {
1278− http: { status: result.status, headers: result.headers },
1279− });
1280− }),
881+ resolveTools: ({ config, storage }) => resolveOpenApiBackedTools({ config, storage }),
882+
883+ invokeTool: ({ ctx: invokeCtx, toolRow, credential, args }) => {
884+ const httpClientLayer = options?.httpClientLayer ?? invokeCtx.httpClientLayer;
885+ return invokeOpenApiBackedTool({
886+ ctx: invokeCtx,
887+ toolRow,
888+ credential,
889+ args,
890+ httpClientLayer,
891+ });
892+ },
1281893
1282− resolveAnnotations: ({
1283− ctx: annotationsCtx,
1284− integration,
1285− toolRows,
1286− }: {
1287− readonly ctx: PluginCtx<OpenapiStore>;
1288− readonly integration: string;
1289− readonly toolRows: readonly { readonly name: string }[];
1290− }) =>
1291− Effect.gen(function* () {
1292− const ops = yield* annotationsCtx.storage.listOperations(String(integration));
1293− const byName = new Map<string, OperationBinding>();
1294− for (const op of ops) byName.set(op.toolName, op.binding);
1295− const out: Record<string, ReturnType<typeof annotationsForOperation>> = {};
1296− for (const row of toolRows) {
1297− const binding = byName.get(row.name);
1298− if (binding) {
1299− out[row.name] = annotationsForOperation(binding.method, binding.pathTemplate);
1300− }
1301− }
1302− return out;
894+ resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) =>
895+ resolveOpenApiBackedAnnotations({
896+ ctx: annotationsCtx,
897+ integration: String(integration),
898+ toolRows,
1303899 }),
1304900
1305901 removeConnection: () => Effect.void,