1087 lines · 40.8 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// OpenAPI plugin — v2 behaviour.//// Ported from the v1 suite to the v2 data model. The v1-only coverage (scope// shadowing, secret-backed credential slots, sources.configure binding// lifecycle, OAuth2 source-config slots, usagesForSecret, configFile mirroring)// is removed — those surfaces no longer exist in v2. See the inline// `// removed:` notes. The behaviours that survive (preview, static control// tools, addSpec → per-connection tools, invoke + transport envelope, auth// template rendering, removeSpec) are exercised against the v2 surface:// addSpec registers an integration, a connection produces the tools, and the// full `tools.<integration>.<owner>.<connection>.<tool>` address is executed.// --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate, Schema } from "effect";import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http"; import {  createExecutor,  AuthTemplateSlug,  ConnectionName,  IntegrationAlreadyExistsError,  IntegrationSlug,  ToolAddress,} from "@executor-js/sdk";import { mkdtempSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path"; import {  makeTestConfig,  makeTestWorkspaceHarness,  memoryCredentialsPlugin,  typeCheckOutputTypeScript,} from "@executor-js/sdk/testing"; import { openApiPlugin } from "./plugin";import { type AuthenticationInput } from "./types";import {  addOpenApiTestConnection,  makeOpenApiHttpApiTestSourceConfig,  serveMutableOpenApiSpecTestServer,  serveOpenApiHttpApiTestServer,  unwrapInvocation,} from "../testing"; const TOOL_ERROR_TYPESCRIPT =  "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>  [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; // ---------------------------------------------------------------------------// Define a test API with Effect HttpApi// --------------------------------------------------------------------------- const Item = Schema.Struct({  id: Schema.Number,  name: Schema.String,}); const EchoHeaders = Schema.Struct({  authorization: Schema.optional(Schema.String),  "x-api-key": Schema.optional(Schema.String),}); class QueryValidationError extends Schema.TaggedErrorClass<QueryValidationError>()(  "QueryValidationError",  {    message: Schema.String,  },) {} const ItemsGroup = HttpApiGroup.make("items")  .add(HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(Item) }))  .add(    HttpApiEndpoint.post("createItem", "/items", {      payload: Schema.Struct({ name: Schema.String }),      success: Item,    }),  )  .add(    HttpApiEndpoint.get("getItem", "/items/:itemId", {      params: Schema.Struct({ itemId: Schema.NumberFromString }),      success: Item,    }),  )  .add(    HttpApiEndpoint.get("echoHeaders", "/echo-headers", {      success: EchoHeaders,    }),  )  .add(    HttpApiEndpoint.get("queryRows", "/records/rows/:entryTypeId", {      params: Schema.Struct({ entryTypeId: Schema.String }),      success: Schema.Unknown,      error: QueryValidationError,    }),  ); const TestApi = HttpApi.make("testApi").add(ItemsGroup); const testApiSpecText = () => {  const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;  if (spec.kind === "blob") return spec.value;  if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? "";  return spec.url;}; const MICROSOFT_GRAPH_V1_OPERATION_COUNT = 16_548; const microsoftGraphScaleSpecText = () => {  const paths: Record<string, unknown> = {};  for (let index = 0; index < MICROSOFT_GRAPH_V1_OPERATION_COUNT; index += 1) {    paths[`/users/{userId}/messages/${index}`] = {      get: {        operationId: `users_messages_list_${index}`,        tags: [`Graph category ${index % 37}`],        summary: `List synthetic Graph messages ${index}`,        parameters: [          {            name: "userId",            in: "path",            required: true,            schema: { type: "string" },          },          {            name: "$top",            in: "query",            schema: { type: "integer", format: "int32" },          },          {            name: "$select",            in: "query",            style: "form",            explode: false,            schema: { type: "array", items: { type: "string" } },          },        ],        responses: {          "200": {            description: "OK",            content: {              "application/json": {                schema: { $ref: "#/components/schemas/MessageCollectionResponse" },              },            },          },        },      },    };  }   return JSON.stringify({    openapi: "3.0.0",    info: {      title: "Microsoft Graph Scale Fixture",      version: "v1.0",      description: "Synthetic Graph-scale fixture for generic OpenAPI imports.",    },    servers: [{ url: "https://graph.microsoft.com/v1.0" }],    security: [{ MicrosoftGraph: ["User.Read", "Mail.Read"] }],    components: {      securitySchemes: {        MicrosoftGraph: {          type: "oauth2",          flows: {            authorizationCode: {              authorizationUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",              tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",              scopes: {                "User.Read": "Read user profile",                "Mail.Read": "Read user mail",              },            },            clientCredentials: {              tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",              scopes: {                ".default": "Application permissions",              },            },          },        },      },      schemas: {        Message: {          type: "object",          properties: {            id: { type: "string" },            subject: { type: "string" },            receivedDateTime: { type: "string", format: "date-time" },          },        },        MessageCollectionResponse: {          type: "object",          properties: {            value: {              type: "array",              items: { $ref: "#/components/schemas/Message" },            },          },        },      },    },    paths,  });}; // ---------------------------------------------------------------------------// Implement handlers// --------------------------------------------------------------------------- const ITEMS = [  { id: 1, name: "Widget" },  { id: 2, name: "Gadget" },  { id: 3, name: "Doohickey" },]; const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) =>  handlers    .handle("listItems", () => Effect.succeed(ITEMS.map((item) => Item.make(item))))    .handle("createItem", (req) =>      Effect.succeed(Item.make({ id: ITEMS.length + 1, name: req.payload.name })),    )    .handle("getItem", (req) =>      Effect.succeed(        Item.make(          ITEMS.find((i) => i.id === req.params.itemId) ?? {            id: 0,            name: "Unknown",          },        ),      ),    )    .handle("echoHeaders", () =>      Effect.gen(function* () {        const req = yield* HttpServerRequest.HttpServerRequest;        return EchoHeaders.make({          authorization: req.headers["authorization"],          "x-api-key": req.headers["x-api-key"],        });      }),    )    .handle("queryRows", () =>      Effect.fail(        new QueryValidationError({          message: 'Field with name "DisplayName" does not exist',        }),      ),    ),); const servePluginTestApi = () =>  serveOpenApiHttpApiTestServer({    api: TestApi,    handlersLayer: ItemsGroupLive,  }); // An apiKey auth template that places the connection value into `x-api-key`.const apiKeyTemplate: AuthenticationInput = {  slug: AuthTemplateSlug.make("apiKey"),  type: "apiKey",  headers: { "x-api-key": [{ type: "variable" as const, name: "token" }] },}; // An oauth template — the connection value renders as a bearer token.const oauthTemplate: AuthenticationInput = {  slug: AuthTemplateSlug.make("oauth"),  kind: "oauth2",  authorizationUrl: "https://auth.example.test/authorize",  tokenUrl: "https://auth.example.test/token",  scopes: ["read"],}; // ---------------------------------------------------------------------------// Tests// --------------------------------------------------------------------------- describe("OpenAPI Plugin", () => {  it.effect("previewSpec returns metadata and header presets", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* servePluginTestApi();         const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));         const preview = yield* executor.openapi.previewSpec(server.specJson);         expect(preview.operationCount).toBeGreaterThanOrEqual(2);        expect(preview.servers).toBeDefined();      }),    ),  );   it.effect("exposes static openapi executor control tools via execute", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));       // v2: static control tools are NOT part of `tools.list()` (that's the      // persisted per-connection catalog) and aren't `tools.schema()`-resolvable;      // they're dispatched by `execute`. Their presence is observable by a      // successful invocation rather than a catalog listing.      // removed: tools.list() / getSource / configureSource assertions — those      // listed v1 static source rows and credential-slot control tools.      const preview = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {        spec: testApiSpecText(),      });      expect(preview).toMatchObject({ ok: true });    }),  );   it.effect("invokes static previewSpec through executor.execute", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));       const preview = unwrapInvocation(        yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {          spec: testApiSpecText(),        }),      ).data as { operationCount: number; operations?: unknown };       expect(preview.operationCount).toBeGreaterThanOrEqual(2);      expect(preview.operations).toBeUndefined();    }),  );   // removed: "describes static previewSpec / addSpec output from Standard Schema"  // — `tools.schema(address)` only resolves persisted per-connection tool rows  // in v2 (the address must parse to the 5-segment  // `tools.<integration>.<owner>.<connection>.<tool>` form). Static control  // tools live outside the catalog and have no schema-view surface, so these  // schema-introspection assertions no longer apply.   it.effect("invokes static addSpec through executor.execute", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));       const result = unwrapInvocation(        yield* executor.execute(ToolAddress.make("executor.openapi.addSpec"), {          spec: { kind: "blob", value: testApiSpecText() },          slug: "runtime",        }),      ).data as { slug: string; toolCount: number };       expect(result.slug).toBe("runtime");      expect(result.toolCount).toBeGreaterThanOrEqual(2);       const integration = yield* executor.openapi.getIntegration("runtime");      expect(integration?.slug).toBe(IntegrationSlug.make("runtime"));      expect((yield* executor.integrations.list()).map((i) => String(i.slug))).toContain("runtime");    }),  );   it.effect("static previewSpec returns actionable tool failures", () =>    Effect.gen(function* () {      const config = makeTestConfig({ plugins: [openApiPlugin()] as const });      const executor = yield* createExecutor(config);       const result = yield* executor.execute(ToolAddress.make("executor.openapi.previewSpec"), {        spec: "not openapi",      });       expect(result).toMatchObject({        ok: false,        error: {          code: "openapi_parse_failed",        },      });       yield* executor.close();      yield* Effect.promise(() => config.testDb.close());    }),  );   it.effect("requires approval before adding an integration through the runtime tool", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(        makeTestConfig({ plugins: [openApiPlugin()] as const }),      );       const declined = yield* executor        .execute(          ToolAddress.make("executor.openapi.addSpec"),          { spec: { kind: "blob", value: testApiSpecText() }, slug: "runtime_declined" },          { onElicitation: () => Effect.succeed({ action: "decline" as const }) },        )        .pipe(Effect.flip);       expect(Predicate.isTagged(declined, "ElicitationDeclinedError")).toBe(true);      expect(yield* executor.openapi.getIntegration("runtime_declined")).toBeNull();    }),  );   it.effect("registers tools from an OpenAPI spec on connection create", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* servePluginTestApi();        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));         const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });         const tools = yield* executor.tools.list();        const names = tools.map((t) => String(t.name));        // dots in the structured path flatten to `__` in the address segment.        expect(names).toContain("items.listItems");        expect(names).toContain("items.getItem");        expect(String(conn.address("items.listItems"))).toBe("tools.test.org.main.items.listItems");      }),    ),  );   it.effect("invokes listItems", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* servePluginTestApi();        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));         const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });         const result = unwrapInvocation(          yield* executor.execute(conn.address("items.listItems"), {}),        );        expect(result.error).toBeNull();        expect(result.data).toEqual(ITEMS);      }),    ),  );   it.effect("requires approval for POST operation annotations", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* servePluginTestApi();        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));         const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });        const calls = { count: 0 };        const result = unwrapInvocation(          yield* executor.execute(            conn.address("items.createItem"),            { body: { name: "New item" } },            {              onElicitation: () =>                Effect.sync(() => {                  calls.count++;                  return { action: "accept" as const, content: {} };                }),            },          ),        );         expect(calls.count).toBe(1);        expect(result.error).toBeNull();        expect(result.data).toEqual({ id: 4, name: "New item" });      }),    ),  );   it.effect("describes OpenAPI invocation results payload-first with http meta beside data", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* servePluginTestApi();        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));         const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });         // The persisted output schema is the upstream response body only —        // no {status, headers, data} transport envelope around it.        const schema = yield* executor.tools.schema(conn.address("items.listItems"));        expect(schema?.outputTypeScript).not.toContain("headers:");        expect(schema?.outputTypeScript).toContain("name");         const result = yield* executor.execute(conn.address("items.listItems"), {});        const diagnostics = typeCheckOutputTypeScript(          {            outputTypeScript: `{ ok: true; data: ${schema?.outputTypeScript ?? "unknown"}; http?: { status: number; headers: { [k: string]: string; } } } | { ok: false; error: ToolError }`,            typeScriptDefinitions: {              ...(schema?.typeScriptDefinitions ?? {}),              ToolError: TOOL_ERROR_TYPESCRIPT,            },          },          result,          {            consumerSource: [              "if (invokedOutput.ok) {",              "  const items = invokedOutput.data;",              "  items.map((item) => item.name);",              "  const status: number | undefined = invokedOutput.http?.status;",              "  const link: string | undefined = invokedOutput.http?.headers['link'];",              "}",            ].join("\n"),          },        );         expect(diagnostics).toEqual([]);      }),    ),  ); 

Lines 1–500 of 1087.