204 lines · 6.8 KB · TypeScript
RawDownload
// Cross-target: updating an OpenAPI spec IN PLACE — the "our API changed,// don't make me remove/re-add the integration" promise. A real 127.0.0.1// server serves the spec; after registration + connection the spec evolves// (one operation removed, one added) and `openapi.updateSpec` re-fetches it.// The tool catalog follows, the response reports the diff, and everything// user-curated (connection, description, auth template) survives.import { randomBytes, randomUUID } from "node:crypto";import { createServer } from "node:http"; import { expect } from "@effect/vitest";import { Effect } from "effect";import { composePluginApi } from "@executor-js/api/server";import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  ProviderItemId,} from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario";import { Api, Target } from "../src/services"; const api = composePluginApi([openApiHttpPlugin()] as const); const specV1 = JSON.stringify({  openapi: "3.0.3",  info: { title: "Evolving API", version: "1.0.0" },  paths: {    "/ping": {      get: {        operationId: "ping",        summary: "Return a pong",        responses: { "200": { description: "pong" } },      },    },    "/legacy": {      get: {        operationId: "legacyOp",        summary: "Soon to be removed",        responses: { "200": { description: "ok" } },      },    },  },}); const specV2 = JSON.stringify({  openapi: "3.0.3",  info: { title: "Evolving API", version: "2.0.0" },  paths: {    "/ping": {      get: {        operationId: "ping",        summary: "Return a pong",        responses: { "200": { description: "pong" } },      },    },    "/widgets": {      get: {        operationId: "listWidgets",        summary: "List widgets",        responses: { "200": { description: "widgets" } },      },    },  },}); /** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */const serveMutableSpec = (initial: string) =>  Effect.acquireRelease(    Effect.callback<{      readonly url: string;      readonly setBody: (body: string) => void;      readonly close: () => void;    }>((resume) => {      let body = initial;      const server = createServer((_request, response) => {        response.writeHead(200, { "content-type": "application/json" });        response.end(body);      });      server.listen(0, "127.0.0.1", () => {        const address = server.address();        const port = typeof address === "object" && address ? address.port : 0;        resume(          Effect.succeed({            url: `http://127.0.0.1:${port}/spec.json`,            setBody: (next: string) => {              body = next;            },            close: () => {              server.close();              server.closeAllConnections();            },          }),        );      });    }),    (server) => Effect.sync(server.close),  ); scenario(  "OpenAPI · updating the spec rebuilds tools without re-adding the integration",  {},  Effect.scoped(    Effect.gen(function* () {      const target = yield* Target;      const { client } = yield* Api;      const identity = yield* target.newIdentity();      const apiClient = yield* client(api, identity);       const slug = `update-spec-${randomBytes(4).toString("hex")}`;      const specServer = yield* serveMutableSpec(specV1);       yield* Effect.ensuring(        Effect.gen(function* () {          const added = yield* apiClient.openapi.addSpec({            payload: {              spec: { kind: "url", url: specServer.url },              slug,              baseUrl: "http://127.0.0.1:59999", // tools are never invoked here              description: "Curated description that must survive the update.",              authenticationTemplate: [                {                  slug: "apiKey",                  type: "apiKey",                  headers: { "x-api-key": [{ type: "variable", name: "token" }] },                },              ],            },          });          expect(added.toolCount, "v1 spec has two operations").toBe(2);           const providers = yield* apiClient.providers.list();          yield* apiClient.connections.create({            payload: {              owner: "org",              name: ConnectionName.make("main"),              integration: IntegrationSlug.make(slug),              template: AuthTemplateSlug.make("apiKey"),              from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },            },          });          const toolNames = (filter: { integration: string }) =>            Effect.map(              apiClient.tools.list({                query: { integration: IntegrationSlug.make(filter.integration) },              }),              (tools) => tools.map((tool) => tool.name).sort(),            );          expect(yield* toolNames({ integration: slug }), "v1 tools are live").toEqual([            "legacy.legacyOp",            "ping.getOperation",          ]);           // The upstream API ships v2: legacyOp is gone, listWidgets appears.          specServer.setBody(specV2);          const updated = yield* apiClient.openapi.updateSpec({            params: { slug },            payload: {},          });           expect(updated.addedTools, "the diff names the new tool").toEqual([            "widgets.listWidgets",          ]);          expect(updated.removedTools, "the diff names the removed tool").toEqual([            "legacy.legacyOp",          ]);           // The connection's catalog follows the new spec — same connection,          // no re-add.          expect(yield* toolNames({ integration: slug }), "v2 tools are live").toEqual([            "ping.getOperation",            "widgets.listWidgets",          ]);          const connections = yield* apiClient.connections.list({            query: { integration: IntegrationSlug.make(slug) },          });          expect(            connections.map((connection) => String(connection.name)),            "the connection survived",          ).toEqual(["main"]);          const integration = yield* apiClient.integrations.get({            params: { slug: IntegrationSlug.make(slug) },          });          expect(integration.description, "the curated description survived").toBe(            "Curated description that must survive the update.",          );        }),        Effect.gen(function* () {          yield* apiClient.connections            .remove({              params: {                owner: "org",                integration: IntegrationSlug.make(slug),                name: ConnectionName.make("main"),              },            })            .pipe(Effect.ignore);          yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);        }),      );    }),  ),);