History

feat: add OpenAPI execution vertical slice

Add an OpenAPI provider and a minimal local JS runner that dispatches tools through the provider registry, plus an integration test that processes an OpenAPI spec and executes code against the resulting tool descriptor. Update engine package exports/docs/scripts to support this flow.

Rhys Sullivan committed 8 months ago

Commit be73cb11 parent 769754c7 files · +682 −1Browse files

Unified · Split

7 files
  1. v2/bun.lock +4 −0
  2. v2/packages/engine/README.md +3 −1
  3. v2/packages/engine/package.json +5 −0
  4. v2/packages/engine/src/index.ts +10 −0
  5. v2/packages/engine/src/local-runner.ts +120 −0
  6. v2/packages/engine/src/openapi-execution.test.ts +192 −0
  7. v2/packages/engine/src/openapi-provider.ts +348 −0
v2/bun.lock+4−0
@@ -62,9 +62,13 @@ · 61 unmodified lines
62 "packages/engine": {62 "packages/engine": {
63 "name": "@executor-v2/engine",63 "name": "@executor-v2/engine",
64 "dependencies": {64 "dependencies": {
65 "@executor-v2/persistence-ports": "workspace:*",
65 "@executor-v2/schema": "workspace:*",66 "@executor-v2/schema": "workspace:*",
66 "effect": "^3.19.8",67 "effect": "^3.19.8",
67 },68 },
69 "devDependencies": {
70 "@executor-v2/source-manager": "workspace:*",
71 },
68 },72 },
69 "packages/mcp-gateway": {73 "packages/mcp-gateway": {
70 "name": "@executor-v2/mcp-gateway",74 "name": "@executor-v2/mcp-gateway",
v2/packages/engine/README.md+3−1
@@ -5,4 +5,6 @@ Runtime execution engine package for Executor v2. · 4 unmodified lines
5Current scaffold includes:5Current scaffold includes:
6- provider contracts (`ToolProvider`) and canonical tool descriptor model6- provider contracts (`ToolProvider`) and canonical tool descriptor model
7- provider registry service (`ToolProviderRegistryService`) with `discover`/`invoke` routing7- provider registry service (`ToolProviderRegistryService`) with `discover`/`invoke` routing
8- live layer constructor for registry wiring8- OpenAPI provider invocation and manifest-to-descriptor conversion helpers
9- minimal local JavaScript runner with `tools.*` proxy dispatch into provider registry
10- vertical integration test covering OpenAPI extraction -> descriptor conversion -> code execution -> HTTP call
v2/packages/engine/package.json+5−0
@@ -6,10 +6,15 @@ · 5 unmodified lines
6 ".": "./src/index.ts"6 ".": "./src/index.ts"
7 },7 },
8 "scripts": {8 "scripts": {
9 "test": "bun test src/*.test.ts",
9 "typecheck": "bunx tsc --noEmit -p tsconfig.json"10 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
10 },11 },
11 "dependencies": {12 "dependencies": {
13 "@executor-v2/persistence-ports": "workspace:*",
12 "@executor-v2/schema": "workspace:*",14 "@executor-v2/schema": "workspace:*",
13 "effect": "^3.19.8"15 "effect": "^3.19.8"
16 },
17 "devDependencies": {
18 "@executor-v2/source-manager": "workspace:*"
14 }19 }
15}20}
v2/packages/engine/src/index.ts+10−0
@@ -1,3 +1,13 @@
1export {
2 executeJavaScriptWithTools,
3 LocalCodeRunnerError,
4 type ExecuteJavaScriptInput,
5 type RunnableTool,
6} from "./local-runner";
7export {
8 makeOpenApiToolProvider,
9 openApiToolDescriptorsFromManifest,
10} from "./openapi-provider";
1export {11export {
2 ToolProviderRegistryLive,12 ToolProviderRegistryLive,
3 ToolProviderRegistryError,13 ToolProviderRegistryError,
v2/packages/engine/src/local-runner.ts+120−0
@@ -0,0 +1,120 @@
1import type { Source } from "@executor-v2/schema";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Runtime from "effect/Runtime";
5
6import {
7 ToolProviderRegistryError,
8 ToolProviderRegistryService,
9 type CanonicalToolDescriptor,
10 type ToolProviderError,
11} from "./tool-providers";
12
13export class LocalCodeRunnerError extends Data.TaggedError("LocalCodeRunnerError")<{
14 operation: string;
15 message: string;
16 details: string | null;
17}> {}
18
19export type RunnableTool = {
20 descriptor: CanonicalToolDescriptor;
21 source: Source | null;
22};
23
24export type ExecuteJavaScriptInput = {
25 code: string;
26 tools: ReadonlyArray<RunnableTool>;
27};
28
29const duplicateToolIdError = (toolId: string): LocalCodeRunnerError =>
30 new LocalCodeRunnerError({
31 operation: "build_tools",
32 message: `Duplicate tool id in run context: ${toolId}`,
33 details: null,
34 });
35
36const toolCallFailedError = (toolId: string): LocalCodeRunnerError =>
37 new LocalCodeRunnerError({
38 operation: "invoke_tool",
39 message: `Tool call returned error: ${toolId}`,
40 details: null,
41 });
42
43const toExecutionError = (cause: unknown): LocalCodeRunnerError =>
44 new LocalCodeRunnerError({
45 operation: "execute",
46 message: "JavaScript execution failed",
47 details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
48 });
49
50const buildToolBindings = (
51 tools: ReadonlyArray<RunnableTool>,
52): Effect.Effect<Map<string, RunnableTool>, LocalCodeRunnerError> =>
53 Effect.gen(function* () {
54 const byToolId = new Map<string, RunnableTool>();
55
56 for (const tool of tools) {
57 const toolId = tool.descriptor.toolId;
58 if (byToolId.has(toolId)) {
59 return yield* duplicateToolIdError(toolId);
60 }
61 byToolId.set(toolId, tool);
62 }
63
64 return byToolId;
65 });
66
67const runJavaScript = (
68 code: string,
69 toolsObject: Record<string, (args: unknown) => Promise<unknown>>,
70): Effect.Effect<unknown, LocalCodeRunnerError> =>
71 Effect.tryPromise({
72 try: async () => {
73 const execute = new Function(
74 "tools",
75 `"use strict"; return (async () => {\n${code}\n})();`,
76 ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) =>
77 Promise<unknown>;
78
79 return await execute(toolsObject);
80 },
81 catch: toExecutionError,
82 });
83
84export const executeJavaScriptWithTools = (
85 input: ExecuteJavaScriptInput,
86): Effect.Effect<
87 unknown,
88 LocalCodeRunnerError | ToolProviderRegistryError | ToolProviderError,
89 ToolProviderRegistryService
90> =>
91 Effect.gen(function* () {
92 const registry = yield* ToolProviderRegistryService;
93 const runtime = yield* Effect.runtime<never>();
94 const runPromise = Runtime.runPromise(runtime);
95 const toolBindings = yield* buildToolBindings(input.tools);
96
97 const toolsObject: Record<string, (args: unknown) => Promise<unknown>> =
98 Object.create(null);
99
100 for (const [toolId, binding] of toolBindings.entries()) {
101 toolsObject[toolId] = (args: unknown) =>
102 runPromise(
103 registry
104 .invoke({
105 source: binding.source,
106 tool: binding.descriptor,
107 args,
108 })
109 .pipe(
110 Effect.flatMap((result) =>
111 result.isError
112 ? toolCallFailedError(toolId)
113 : Effect.succeed(result.output),
114 ),
115 ),
116 );
117 }
118
119 return yield* runJavaScript(input.code, toolsObject);
120 });
v2/packages/engine/src/openapi-execution.test.ts+192−0
@@ -0,0 +1,192 @@
1import {
2 type ToolArtifactStore,
3} from "@executor-v2/persistence-ports";
4import { type Source, SourceSchema, type ToolArtifact } from "@executor-v2/schema";
5import { makeSourceManagerService } from "@executor-v2/source-manager";
6import { describe, expect, test } from "bun:test";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10
11import { executeJavaScriptWithTools } from "./local-runner";
12import {
13 makeOpenApiToolProvider,
14 openApiToolDescriptorsFromManifest,
15} from "./openapi-provider";
16import {
17 makeToolProviderRegistry,
18 ToolProviderRegistryService,
19} from "./tool-providers";
20
21const decodeSource = Schema.decodeUnknownSync(SourceSchema);
22
23type TestServer = {
24 baseUrl: string;
25 requests: Array<{
26 path: string;
27 query: string;
28 apiKey: string | null;
29 }>;
30 server: Bun.Server<unknown>;
31};
32
33const makeTestServer = Effect.acquireRelease(
34 Effect.sync<TestServer>(() => {
35 const requests: TestServer["requests"] = [];
36
37 const server = Bun.serve({
38 port: 0,
39 fetch: async (request) => {
40 const url = new URL(request.url);
41
42 if (url.pathname === "/users/u123") {
43 requests.push({
44 path: url.pathname,
45 query: url.search,
46 apiKey: request.headers.get("x-api-key"),
47 });
48
49 return Response.json({
50 id: "u123",
51 verbose: url.searchParams.get("verbose") === "true",
52 apiKey: request.headers.get("x-api-key"),
53 });
54 }
55
56 return Response.json({ error: "not found" }, { status: 404 });
57 },
58 });
59
60 return {
61 baseUrl: `http://127.0.0.1:${server.port}`,
62 requests,
63 server,
64 };
65 }),
66 ({ server }) =>
67 Effect.sync(() => {
68 server.stop(true);
69 }),
70);
71
72describe("OpenAPI execution vertical slice", () => {
73 test("extracts OpenAPI tools and executes code against provider", async () => {
74 const program = Effect.gen(function* () {
75 const testServer = yield* makeTestServer;
76
77 const openApiSpec = {
78 openapi: "3.1.0",
79 paths: {
80 "/users/{userId}": {
81 get: {
82 operationId: "getUser",
83 parameters: [
84 {
85 name: "userId",
86 in: "path",
87 required: true,
88 },
89 {
90 name: "verbose",
91 in: "query",
92 },
93 {
94 name: "x-api-key",
95 in: "header",
96 required: true,
97 },
98 ],
99 },
100 },
101 },
102 };
103
104 const source: Source = decodeSource({
105 id: "src_openapi",
106 workspaceId: "ws_local",
107 name: "local-openapi",
108 kind: "openapi",
109 endpoint: testServer.baseUrl,
110 status: "connected",
111 enabled: true,
112 configJson: "{}",
113 sourceHash: null,
114 lastError: null,
115 createdAt: Date.now(),
116 updatedAt: Date.now(),
117 });
118
119 const artifactsByKey = new Map<string, ToolArtifact>();
120 const artifactStore: ToolArtifactStore = {
121 getBySource: (workspaceId: Source["workspaceId"], sourceId: Source["id"]) =>
122 Effect.succeed(
123 Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)),
124 ),
125 upsert: (artifact: ToolArtifact) =>
126 Effect.sync(() => {
127 artifactsByKey.set(
128 `${artifact.workspaceId}:${artifact.sourceId}`,
129 artifact,
130 );
131 }),
132 };
133 const sourceManager = makeSourceManagerService(artifactStore);
134
135 const refreshResult = yield* sourceManager.refreshOpenApiArtifact({
136 source,
137 openApiSpec,
138 });
139
140 const tools = yield* openApiToolDescriptorsFromManifest(
141 source,
142 refreshResult.artifact.manifestJson,
143 );
144
145 const getUserTool = tools.find((tool) => tool.toolId === "getUser");
146 if (!getUserTool) {
147 throw new Error("expected getUser tool");
148 }
149
150 const registry = makeToolProviderRegistry([makeOpenApiToolProvider()]);
151
152 const executionResult = yield* executeJavaScriptWithTools({
153 code: `
154return await tools.getUser({
155 userId: "u123",
156 verbose: "true",
157 "x-api-key": "sk_test"
158});
159`,
160 tools: [
161 {
162 descriptor: getUserTool,
163 source,
164 },
165 ],
166 }).pipe(
167 Effect.provideService(ToolProviderRegistryService, registry),
168 );
169
170 const output = executionResult as {
171 status: number;
172 body: {
173 id: string;
174 verbose: boolean;
175 apiKey: string | null;
176 };
177 };
178
179 expect(output.status).toBe(200);
180 expect(output.body.id).toBe("u123");
181 expect(output.body.verbose).toBe(true);
182 expect(output.body.apiKey).toBe("sk_test");
183
184 expect(testServer.requests).toHaveLength(1);
185 expect(testServer.requests[0]?.path).toBe("/users/u123");
186 expect(testServer.requests[0]?.query).toBe("?verbose=true");
187 expect(testServer.requests[0]?.apiKey).toBe("sk_test");
188 });
189
190 await Effect.runPromise(program.pipe(Effect.scoped));
191 });
192});
v2/packages/engine/src/openapi-provider.ts+348−0
@@ -0,0 +1,348 @@
1import type { Source } from "@executor-v2/schema";
2import * as Effect from "effect/Effect";
3import { pipe } from "effect/Function";
4import * as ParseResult from "effect/ParseResult";
5import * as Schema from "effect/Schema";
6
7import {
8 ToolProviderError,
9 type CanonicalToolDescriptor,
10 type ToolProvider,
11} from "./tool-providers";
12
13const OPEN_API_PARAMETER_LOCATIONS = [
14 "path",
15 "query",
16 "header",
17 "cookie",
18] as const;
19
20const OPEN_API_HTTP_METHODS = [
21 "get",
22 "put",
23 "post",
24 "delete",
25 "patch",
26 "head",
27 "options",
28 "trace",
29] as const;
30
31const OpenApiInvocationPayloadSchema = Schema.Struct({
32 method: Schema.Literal(...OPEN_API_HTTP_METHODS),
33 pathTemplate: Schema.String,
34 parameters: Schema.Array(
35 Schema.Struct({
36 name: Schema.String,
37 location: Schema.Literal(...OPEN_API_PARAMETER_LOCATIONS),
38 required: Schema.Boolean,
39 }),
40 ),
41 requestBody: Schema.NullOr(
42 Schema.Struct({
43 required: Schema.Boolean,
44 contentTypes: Schema.Array(Schema.String),
45 }),
46 ),
47});
48
49type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
50
51const OpenApiManifestSchema = Schema.Struct({
52 version: Schema.Literal(1),
53 sourceHash: Schema.String,
54 tools: Schema.Array(
55 Schema.Struct({
56 toolId: Schema.String,
57 name: Schema.String,
58 description: Schema.NullOr(Schema.String),
59 invocation: OpenApiInvocationPayloadSchema,
60 }),
61 ),
62});
63
64type OpenApiManifest = typeof OpenApiManifestSchema.Type;
65
66const OpenApiToolArgsSchema = Schema.Record({
67 key: Schema.String,
68 value: Schema.Unknown,
69});
70
71type OpenApiToolArgs = typeof OpenApiToolArgsSchema.Type;
72
73const decodeOpenApiManifestJson = Schema.decodeUnknown(
74 Schema.parseJson(OpenApiManifestSchema),
75);
76const decodeOpenApiInvocationPayload = Schema.decodeUnknown(
77 OpenApiInvocationPayloadSchema,
78);
79const decodeOpenApiToolArgs = Schema.decodeUnknown(OpenApiToolArgsSchema);
80
81const toOpenApiProviderError = (
82 operation: string,
83 message: string,
84 cause: unknown,
85): ToolProviderError =>
86 new ToolProviderError({
87 operation,
88 providerKind: "openapi",
89 message,
90 details: ParseResult.isParseError(cause)
91 ? ParseResult.TreeFormatter.formatErrorSync(cause)
92 : String(cause),
93 });
94
95const encodePathSegment = (value: unknown): string =>
96 encodeURIComponent(String(value));
97
98const argsValueToString = (value: unknown): string => {
99 if (typeof value === "string") {
100 return value;
101 }
102
103 if (
104 typeof value === "number" ||
105 typeof value === "boolean" ||
106 typeof value === "bigint"
107 ) {
108 return String(value);
109 }
110
111 return JSON.stringify(value);
112};
113
114const replacePathTemplate = (
115 pathTemplate: string,
116 args: OpenApiToolArgs,
117 payload: OpenApiInvocationPayload,
118): Effect.Effect<string, ToolProviderError> =>
119 Effect.gen(function* () {
120 let resolvedPath = pathTemplate;
121
122 for (const parameter of payload.parameters) {
123 if (parameter.location !== "path") {
124 continue;
125 }
126
127 const parameterValue = args[parameter.name];
128 if (parameterValue === undefined || parameterValue === null) {
129 if (parameter.required) {
130 return yield* new ToolProviderError({
131 operation: "invoke.resolve_path",
132 providerKind: "openapi",
133 message: `Missing required path parameter: ${parameter.name}`,
134 details: pathTemplate,
135 });
136 }
137 continue;
138 }
139
140 resolvedPath = resolvedPath.replaceAll(
141 `{${parameter.name}}`,
142 encodePathSegment(parameterValue),
143 );
144 }
145
146 return resolvedPath;
147 });
148
149const hasRequestBody = (
150 args: OpenApiToolArgs,
151): args is OpenApiToolArgs & { body: unknown } =>
152 Object.prototype.hasOwnProperty.call(args, "body") && args.body !== undefined;
153
154type BuiltFetchRequest = {
155 url: URL;
156 init: RequestInit;
157};
158
159const buildFetchRequest = (
160 source: Source,
161 payload: OpenApiInvocationPayload,
162 args: OpenApiToolArgs,
163): Effect.Effect<BuiltFetchRequest, ToolProviderError> =>
164 Effect.gen(function* () {
165 const resolvedPath = yield* replacePathTemplate(payload.pathTemplate, args, payload);
166 const url = new URL(resolvedPath, source.endpoint);
167
168 const headers = new Headers();
169 const cookieParts: Array<string> = [];
170
171 for (const parameter of payload.parameters) {
172 if (parameter.location === "path") {
173 continue;
174 }
175
176 const parameterValue = args[parameter.name];
177 if (parameterValue === undefined || parameterValue === null) {
178 if (parameter.required) {
179 return yield* new ToolProviderError({
180 operation: "invoke.validate_args",
181 providerKind: "openapi",
182 message: `Missing required ${parameter.location} parameter: ${parameter.name}`,
183 details: payload.pathTemplate,
184 });
185 }
186 continue;
187 }
188
189 const encoded = argsValueToString(parameterValue);
190
191 if (parameter.location === "query") {
192 url.searchParams.set(parameter.name, encoded);
193 } else if (parameter.location === "header") {
194 headers.set(parameter.name, encoded);
195 } else if (parameter.location === "cookie") {
196 cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`);
197 }
198 }
199
200 if (cookieParts.length > 0) {
201 headers.set("cookie", cookieParts.join("; "));
202 }
203
204 let body: string | undefined;
205
206 if (payload.requestBody !== null) {
207 if (!hasRequestBody(args)) {
208 if (payload.requestBody.required) {
209 return yield* new ToolProviderError({
210 operation: "invoke.validate_args",
211 providerKind: "openapi",
212 message: "Missing required request body at args.body",
213 details: payload.pathTemplate,
214 });
215 }
216 } else {
217 body = JSON.stringify(args.body);
218
219 const preferredContentType = payload.requestBody.contentTypes[0];
220 if (preferredContentType) {
221 headers.set("content-type", preferredContentType);
222 } else if (!headers.has("content-type")) {
223 headers.set("content-type", "application/json");
224 }
225 }
226 }
227
228 return {
229 url,
230 init: {
231 method: payload.method.toUpperCase(),
232 headers,
233 body,
234 },
235 };
236 });
237
238const decodeFetchResponseBody = (
239 response: Response,
240): Effect.Effect<unknown, ToolProviderError> =>
241 Effect.tryPromise({
242 try: async () => {
243 const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
244 if (contentType.includes("application/json")) {
245 return await response.json();
246 }
247
248 return await response.text();
249 },
250 catch: (cause) =>
251 new ToolProviderError({
252 operation: "invoke.decode_response",
253 providerKind: "openapi",
254 message: "Failed to decode OpenAPI response body",
255 details: cause instanceof Error ? cause.message : String(cause),
256 }),
257 });
258
259export const openApiToolDescriptorsFromManifest = (
260 source: Source,
261 manifestJson: string,
262): Effect.Effect<ReadonlyArray<CanonicalToolDescriptor>, ToolProviderError> =>
263 pipe(
264 decodeOpenApiManifestJson(manifestJson),
265 Effect.mapError((cause) =>
266 toOpenApiProviderError(
267 "discover.decode_manifest",
268 "Failed to decode OpenAPI manifest JSON",
269 cause,
270 ),
271 ),
272 Effect.map((manifest: OpenApiManifest) =>
273 manifest.tools.map(
274 (tool): CanonicalToolDescriptor => ({
275 providerKind: "openapi",
276 sourceId: source.id,
277 workspaceId: source.workspaceId,
278 toolId: tool.toolId,
279 name: tool.name,
280 description: tool.description,
281 invocationMode: "http",
282 availability: "remote_capable",
283 providerPayload: tool.invocation,
284 }),
285 ),
286 ),
287 );
288
289export const makeOpenApiToolProvider = (): ToolProvider => ({
290 kind: "openapi",
291
292 invoke: (input) =>
293 Effect.gen(function* () {
294 if (!input.source) {
295 return yield* new ToolProviderError({
296 operation: "invoke.validate_source",
297 providerKind: "openapi",
298 message: "OpenAPI provider requires a source",
299 details: null,
300 });
301 }
302
303 const payload = yield* pipe(
304 decodeOpenApiInvocationPayload(input.tool.providerPayload),
305 Effect.mapError((cause) =>
306 toOpenApiProviderError(
307 "invoke.decode_payload",
308 `Invalid provider payload for tool: ${input.tool.toolId}`,
309 cause,
310 ),
311 ),
312 );
313
314 const args = yield* pipe(
315 decodeOpenApiToolArgs(input.args),
316 Effect.mapError((cause) =>
317 toOpenApiProviderError(
318 "invoke.decode_args",
319 `Invalid tool args for tool: ${input.tool.toolId}`,
320 cause,
321 ),
322 ),
323 );
324
325 const request = yield* buildFetchRequest(input.source, payload, args);
326
327 const response = yield* Effect.tryPromise({
328 try: () => fetch(request.url, request.init),
329 catch: (cause) =>
330 toOpenApiProviderError(
331 "invoke.http_request",
332 `OpenAPI request failed for tool: ${input.tool.toolId}`,
333 cause,
334 ),
335 });
336
337 const body = yield* decodeFetchResponseBody(response);
338
339 return {
340 output: {
341 status: response.status,
342 headers: Object.fromEntries(response.headers.entries()),
343 body,
344 },
345 isError: response.status >= 400,
346 };
347 }),
348});