1543 lines · 57.6 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, Option, Predicate, Schema } from "effect";import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import {  createExecutor,  AuthTemplateSlug,  ConnectionName,  IntegrationAlreadyExistsError,  IntegrationSlug,  ToolAddress,  makeInMemoryBlobStore,} from "@executor-js/sdk";import { mkdtempSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path"; import {  makeTestConfig,  makeTestWorkspaceHarness,  memoryCredentialsPlugin,  serveTestHttpApp,  typeCheckOutputTypeScript,} from "@executor-js/sdk/testing"; import { openApiPlugin } from "./plugin";import { type AuthenticationInput } from "./types";import {  addOpenApiTestConnection,  makeOpenApiHttpApiTestIntegrationConfig,  makeOpenApiTestSpecJson,  serveMutableOpenApiSpecTestServer,  serveOpenApiEchoTestServer,  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; const recordingBlobStore = () => {  const base = makeInMemoryBlobStore();  let writes = 0;  return {    store: {      ...base,      put: (namespace: string, key: string, value: string) =>        Effect.sync(() => {          writes += 1;        }).pipe(Effect.andThen(base.put(namespace, key, value))),    },    writeCount: () => writes,  };}; // ---------------------------------------------------------------------------// 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 = makeOpenApiHttpApiTestIntegrationConfig(TestApi, {}).spec;  if (spec.kind === "blob") return spec.value;  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,  }); const recordValue = (value: unknown): Record<string, unknown> | undefined =>  typeof value === "object" && value !== null && !Array.isArray(value)    ? Object.fromEntries(Object.entries(value))    : undefined; const withEchoCookieParameter = (spec: Record<string, unknown>): Record<string, unknown> => {  const paths = recordValue(spec.paths);  const pathItem = recordValue(paths?.["/echo-headers"]);  const get = recordValue(pathItem?.get);  if (!paths || !pathItem || !get) return spec;  const parameters = Array.isArray(get.parameters) ? get.parameters : [];  return {    ...spec,    paths: {      ...paths,      "/echo-headers": {        ...pathItem,        get: {          ...get,          parameters: [            ...parameters,            {              name: "sessionId",              in: "cookie",              required: true,              schema: { type: "string" },            },          ],        },      },    },  };}; // 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"],}; const serveOAuthDiscoverableOpenApiSpec = () =>  Effect.gen(function* () {    let baseUrl = "";    const server = yield* serveTestHttpApp((request) => {      if (request.url.includes("/api/schema")) {        return Effect.succeed(          HttpServerResponse.jsonUnsafe({            openapi: "3.0.0",            info: { title: "PostHog-like API", version: "1.0.0" },            paths: {              "/api/projects/": {                get: {                  operationId: "projects_list",                  security: [{ PersonalAPIKeyAuth: ["project:read", "wizard_session:write"] }],                  responses: { "200": { description: "OK" } },                },              },            },            components: {              securitySchemes: {                PersonalAPIKeyAuth: { type: "http", scheme: "bearer" },              },            },          }),        );      }      if (request.url.includes("/.well-known/oauth-protected-resource")) {        return Effect.succeed(          HttpServerResponse.jsonUnsafe({            resource: baseUrl,            authorization_servers: [baseUrl],            scopes_supported: ["project:read"],          }),        );      }      if (request.url.includes("/.well-known/oauth-authorization-server")) {        return Effect.succeed(          HttpServerResponse.jsonUnsafe({            issuer: baseUrl,            authorization_endpoint: `${baseUrl}/oauth/authorize/`,            token_endpoint: `${baseUrl}/oauth/token/`,            registration_endpoint: `${baseUrl}/oauth/register/`,            client_id_metadata_document_supported: true,            response_types_supported: ["code"],            grant_types_supported: ["authorization_code"],            code_challenge_methods_supported: ["S256"],            token_endpoint_auth_methods_supported: ["none"],            scopes_supported: ["project:read"],          }),        );      }      return Effect.succeed(HttpServerResponse.text("not found", { status: 404 }));    });    baseUrl = server.baseUrl;    return server;  }); // ---------------------------------------------------------------------------// 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("previewSpec discovers OAuth metadata from a URL-hosted bearer spec", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthDiscoverableOpenApiSpec();        const executor = yield* createExecutor(          makeTestConfig({ plugins: testPlugins(server.httpClientLayer) }),        );         const preview = yield* executor.openapi.previewSpec(server.url("/api/schema/"));         expect(preview.headerPresets.map((preset) => preset.label)).toContain("Bearer Token");        expect(preview.oauth2Presets).toHaveLength(1);        const oauth = preview.oauth2Presets[0]!;        expect(oauth.securitySchemeName).toBe("DiscoveredOAuth2");        expect(oauth.flow).toBe("authorizationCode");        expect(oauth.tokenUrl).toBe(`${server.baseUrl}/oauth/token/`);        expect(Option.getOrNull(oauth.resource)).toBe(server.baseUrl);        expect(oauth.supportsClientIdMetadataDocument).toBe(true);        expect(oauth.scopes).toEqual({ "project:read": "" });         yield* executor.openapi.addSpec({          spec: { kind: "url", url: server.url("/api/schema/") },          slug: "posthog_like",          baseUrl: server.baseUrl,        });        const config = yield* executor.openapi.getConfig("posthog_like");        expect(          (config?.authenticationTemplate ?? []).map((template) => ({            slug: String(template.slug),            kind: template.kind,            ...(template.kind === "oauth2"              ? {                  resource: template.resource ?? null,                  supportsClientIdMetadataDocument:                    template.supportsClientIdMetadataDocument === true,                }              : {}),          })),        ).toEqual([          { slug: "apikey-0", kind: "apikey" },          {            slug: "oauth-DiscoveredOAuth2",            kind: "oauth2",            resource: server.baseUrl,            supportsClientIdMetadataDocument: true,          },        ]);      }),    ),  );   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(

Lines 1–500 of 1543.