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 @@
1import { Effect, Option, Schema } from "effect";
2import type { Layer } from "effect";
3import { HttpClient } from "effect/unstable/http";
4
5import {
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
17import {
18 decodeOpenApiIntegrationConfig,
19 renderAuthTemplate,
20 requiredTemplateVariables,
21 type OpenApiIntegrationConfig,
22} from "./config";
23import { OpenApiExtractionError, OpenApiParseError } from "./errors";
24import { extract } from "./extract";
25import { compileToolDefinitions, type ToolDefinition } from "./definitions";
26import { annotationsForOperation, invokeWithLayer } from "./invoke";
27import { parse } from "./parse";
28import { type OpenapiStore, type StoredOperation } from "./store";
29import { OperationBinding } from "./types";
30
31const STRINGIFIED_BODY_CAP = 1024;
32const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
33const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
34const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
35const 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});
44const UpstreamDescriptionBody = Schema.Struct({
45 detail: Schema.optional(Schema.String),
46 title: Schema.optional(Schema.String),
47 description: Schema.optional(Schema.String),
48});
49
50const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
51const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
52const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
53const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
54const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody);
55
56const 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
67const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined =>
68 values.find((value) => value !== undefined && value.length > 0);
69
70export 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
108const 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`. */
139export 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
169const 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
179const 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
186export 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
193export 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
213export 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
230export 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
240export 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
246export 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
268export 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
365export 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
8} from "./google-discovery";8} from "./google-discovery";
9export { extract } from "./extract";9export { extract } from "./extract";
10export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";10export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";
11export {
12 compileOpenApiSpec,
13 extractOpenApiUpstreamMessage,
14 invokeOpenApiBackedTool,
15 loadOpenApiSpecText,
16 normalizeOpenApiRefs,
17 openApiStoredOperationsFromCompiled,
18 openApiToolDefsFromCompiled,
19 resolveOpenApiBackedAnnotations,
20 resolveOpenApiBackedTools,
21 type CompiledOpenApiSpec,
22} from "./backing";
11export {23export {
12 openApiPlugin,24 openApiPlugin,
13 type OpenApiSpecConfig,25 type OpenApiSpecConfig,
packages/plugins/openapi/src/sdk/plugin.ts+31−435
@@ -7,10 +7,7 @@ import { · 6 unmodified lines
7 IntegrationDetectionResult,7 IntegrationDetectionResult,
8 IntegrationNotFoundError,8 IntegrationNotFoundError,
9 IntegrationSlug,9 IntegrationSlug,
10 ToolName,
11 ToolFileJsonSchema,
12 ToolResult,10 ToolResult,
13 authToolFailure,
14 definePlugin,11 definePlugin,
15 mergeAuthTemplates,12 mergeAuthTemplates,
16 sha256Hex,13 sha256Hex,
@@ -20,18 +17,10 @@ import { · 3 unmodified lines
20 type IntegrationConfig,17 type IntegrationConfig,
21 type IntegrationRecord,18 type IntegrationRecord,
22 type PluginCtx,19 type PluginCtx,
23 type ResolveToolsResult,
24 type StorageFailure,20 type StorageFailure,
25 type ToolDef,
26 type ToolInvocationCredential,
27} from "@executor-js/sdk/core";21} from "@executor-js/sdk/core";
28 22
29import {23import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";
30 decodeOpenApiIntegrationConfig,
31 renderAuthTemplate,
32 requiredTemplateVariables,
33 type OpenApiIntegrationConfig,
34} from "./config";
35import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";24import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";
36import { parse, resolveSpecText } from "./parse";25import { parse, resolveSpecText } from "./parse";
37import {26import {
@@ -41,98 +30,20 @@ import { · 3 unmodified lines
41 isGoogleDiscoveryUrl,30 isGoogleDiscoveryUrl,
42} from "./google-discovery";31} from "./google-discovery";
43import { extract } from "./extract";32import { extract } from "./extract";
44import { compileToolDefinitions, type ToolDefinition } from "./definitions";
45import { annotationsForOperation, invokeWithLayer } from "./invoke";
46import { previewSpec, previewSpecText, type SpecPreview } from "./preview";33import { previewSpec, previewSpecText, type SpecPreview } from "./preview";
47import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";34import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
48import { openApiPresets } from "./presets";35import { openApiPresets } from "./presets";
49import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";36import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";
50import type { Authentication } from "./types";37import type { Authentication } from "./types";
51import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";38import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
52import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";39import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
53 40import {
54// ---------------------------------------------------------------------------41 compileOpenApiSpec,
55// Plugin config42 invokeOpenApiBackedTool,
56// ---------------------------------------------------------------------------43 openApiStoredOperationsFromCompiled,
57 44 resolveOpenApiBackedAnnotations,
58const STRINGIFIED_BODY_CAP = 1024;45 resolveOpenApiBackedTools,
59const UpstreamMessageBody = Schema.Struct({ message: Schema.String });46} from "./backing";
60const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
61const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
62const 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});
71const UpstreamDescriptionBody = Schema.Struct({
72 detail: Schema.optional(Schema.String),
73 title: Schema.optional(Schema.String),
74 description: Schema.optional(Schema.String),
75});
76
77const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
78const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
79const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
80const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
81const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody);
82
83const 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
94const 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.
99const 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};
136 47
137// ---------------------------------------------------------------------------48// ---------------------------------------------------------------------------
138// Extension input shapes49// Extension input shapes
@@ -381,38 +292,6 @@ const openApiToolFailure = (code: string, message: string, details?: unknown) => · 242 unmodified lines
381 ...(details === undefined ? {} : { details }),292 ...(details === undefined ? {} : { details }),
382 });293 });
383 294
384const 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
416const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({295const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({
417 title: Option.getOrNull(preview.title),296 title: Option.getOrNull(preview.title),
418 version: Option.getOrNull(preview.version),297 version: Option.getOrNull(preview.version),
@@ -477,58 +356,6 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 58 unmodified lines
477 })),356 })),
478});357});
479 358
480// ---------------------------------------------------------------------------
481// Helpers
482// ---------------------------------------------------------------------------
483
484/** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */
485const 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
515const 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
525const 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
532const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined =>359const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined =>
533 spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined;360 spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined;
534 361
@@ -575,88 +402,6 @@ export const describeOpenApiIntegrationDisplay = ( · 40 unmodified lines
575 return { url: config?.baseUrl ?? config?.sourceUrl };402 return { url: config?.baseUrl ?? config?.sourceUrl };
576};403};
577 404
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
584const 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
594interface 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
602const 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
630const 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
650const 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
660// ---------------------------------------------------------------------------405// ---------------------------------------------------------------------------
661// Plugin factory406// Plugin factory
662// ---------------------------------------------------------------------------407// ---------------------------------------------------------------------------
@@ -752,7 +497,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 89 unmodified lines
752 // Resolve URL → text and parse BEFORE opening a transaction. Holding497 // Resolve URL → text and parse BEFORE opening a transaction. Holding
753 // `BEGIN` across a network fetch is the Hyperdrive deadlock path.498 // `BEGIN` across a network fetch is the Hyperdrive deadlock path.
754 const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);499 const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);
755 const compiled = yield* compileSpec(resolved.specText);500 const compiled = yield* compileOpenApiSpec(resolved.specText);
756 501
757 // Defaults the add page derives from its preview, applied here so502 // Defaults the add page derives from its preview, applied here so
758 // headless callers (MCP, API) get the same integration the UI's503 // headless callers (MCP, API) get the same integration the UI's
@@ -846,7 +591,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 87 unmodified lines
846 });591 });
847 yield* ctx.storage.putOperations(592 yield* ctx.storage.putOperations(
848 config.slug,593 config.slug,
849 storedOperationsFromCompiled(config.slug, compiled),594 openApiStoredOperationsFromCompiled(config.slug, compiled),
850 );595 );
851 }),596 }),
852 );597 );
@@ -890,7 +635,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 37 unmodified lines
890 // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock635 // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock
891 // rule as addSpec: never hold BEGIN across a network fetch).636 // rule as addSpec: never hold BEGIN across a network fetch).
892 const resolved = yield* resolveSpecForInput(specInput, httpClientLayer);637 const resolved = yield* resolveSpecForInput(specInput, httpClientLayer);
893 const compiled = yield* compileSpec(resolved.specText);638 const compiled = yield* compileOpenApiSpec(resolved.specText);
894 639
895 const previousOperations = yield* ctx.storage.listOperations(rawSlug);640 const previousOperations = yield* ctx.storage.listOperations(rawSlug);
896 const previousNames = new Set(previousOperations.map((op) => op.toolName));641 const previousNames = new Set(previousOperations.map((op) => op.toolName));
@@ -921,7 +666,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 24 unmodified lines
921 });666 });
922 yield* ctx.storage.putOperations(667 yield* ctx.storage.putOperations(
923 rawSlug,668 rawSlug,
924 storedOperationsFromCompiled(rawSlug, compiled),669 openApiStoredOperationsFromCompiled(rawSlug, compiled),
925 );670 );
926 }),671 }),
927 );672 );
@@ -1133,173 +878,24 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 205 unmodified lines
1133 // operation bindings invokeTool needs are persisted at addSpec time; this878 // operation bindings invokeTool needs are persisted at addSpec time; this
1134 // hook only shapes the per-connection ToolDefs from the spec blob the879 // hook only shapes the per-connection ToolDefs from the spec blob the
1135 // catalog config points at.880 // catalog config points at.
1136 resolveTools: ({881 resolveTools: ({ config, storage }) => resolveOpenApiBackedTools({ config, storage }),
1137 config,882
1138 storage,883 invokeTool: ({ ctx: invokeCtx, toolRow, credential, args }) => {
1139 }: {884 const httpClientLayer = options?.httpClientLayer ?? invokeCtx.httpClientLayer;
1140 readonly integration: Integration;885 return invokeOpenApiBackedTool({
1141 readonly config: IntegrationConfig;886 ctx: invokeCtx,
1142 readonly storage: OpenapiStore;887 toolRow,
1143 }): Effect.Effect<ResolveToolsResult, StorageFailure> =>888 credential,
1144 Effect.gen(function* () {889 args,
1145 const openApiConfig = decodeOpenApiIntegrationConfig(config);890 httpClientLayer,
1146 if (!openApiConfig) return { tools: [], definitions: {} };891 });
1147 const specText = yield* loadSpecText(storage, openApiConfig);892 },
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 }),
1281 893
1282 resolveAnnotations: ({894 resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) =>
1283 ctx: annotationsCtx,895 resolveOpenApiBackedAnnotations({
1284 integration,896 ctx: annotationsCtx,
1285 toolRows,897 integration: String(integration),
1286 }: {898 toolRows,
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;
1303 }),899 }),
1304 900
1305 removeConnection: () => Effect.void,901 removeConnection: () => Effect.void,