|
| 1 | +// Cross-target: when a spec declares more than one server, per-call server |
| 2 | +// selection becomes a first-class tool input. The generated tool advertises an |
| 3 | +// optional `server` selector — a `url` enum over the declared servers plus the |
| 4 | +// `{variables}` drawn from the templated one — so an agent can choose where each |
| 5 | +// call goes. Adding the spec with NO baseUrl also proves the base URL is now an |
| 6 | +// optional override: the host is resolved per call from the spec's servers. |
| 7 | +// |
| 8 | +// Entirely through the typed client: addSpec → connection (via a `from` provider |
| 9 | +// reference, so no vault round-trip — works against the cloud stub) → read the |
| 10 | +// tool's schema and assert the `server` input this feature introduces. |
| 11 | +import { randomBytes, randomUUID } from "node:crypto"; |
| 12 | + |
| 13 | +import { expect } from "@effect/vitest"; |
| 14 | +import { Effect } from "effect"; |
| 15 | +import { composePluginApi } from "@executor-js/api/server"; |
| 16 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 17 | +import { |
| 18 | + AuthTemplateSlug, |
| 19 | + ConnectionName, |
| 20 | + IntegrationSlug, |
| 21 | + ProviderItemId, |
| 22 | +} from "@executor-js/sdk/shared"; |
| 23 | + |
| 24 | +import { scenario } from "../src/scenario"; |
| 25 | +import { Api, Target } from "../src/services"; |
| 26 | + |
| 27 | +const api = composePluginApi([openApiHttpPlugin()] as const); |
| 28 | + |
| 29 | +// The operation inherits two document-level servers: a fixed production URL and |
| 30 | +// a templated regional sandbox whose `{region}` is a per-call variable. |
| 31 | +const PROD_SERVER = "https://api.example.test/v1"; |
| 32 | +const SANDBOX_SERVER = "https://{region}.sandbox.example.test/v1"; |
| 33 | + |
| 34 | +const multiServerSpec = (): string => |
| 35 | + JSON.stringify({ |
| 36 | + openapi: "3.0.3", |
| 37 | + info: { title: "Regions API", version: "1.0.0" }, |
| 38 | + servers: [ |
| 39 | + { url: PROD_SERVER, description: "Production" }, |
| 40 | + { |
| 41 | + url: SANDBOX_SERVER, |
| 42 | + description: "Regional sandbox", |
| 43 | + variables: { |
| 44 | + region: { default: "us", enum: ["us", "eu", "ap"], description: "Sandbox region" }, |
| 45 | + }, |
| 46 | + }, |
| 47 | + ], |
| 48 | + paths: { |
| 49 | + "/ping": { |
| 50 | + get: { |
| 51 | + operationId: "ping", |
| 52 | + summary: "Health check", |
| 53 | + responses: { "200": { description: "pong" } }, |
| 54 | + }, |
| 55 | + }, |
| 56 | + }, |
| 57 | + }); |
| 58 | + |
| 59 | +// Minimal structural view of the JSON Schema we assert against. |
| 60 | +type ServerInputSchema = { |
| 61 | + readonly properties?: { |
| 62 | + readonly server?: { |
| 63 | + readonly properties?: { |
| 64 | + readonly url?: { readonly enum?: readonly unknown[]; readonly default?: unknown }; |
| 65 | + readonly variables?: { |
| 66 | + readonly properties?: Record< |
| 67 | + string, |
| 68 | + { readonly enum?: readonly unknown[]; readonly default?: unknown } |
| 69 | + >; |
| 70 | + }; |
| 71 | + }; |
| 72 | + }; |
| 73 | + }; |
| 74 | +}; |
| 75 | + |
| 76 | +scenario( |
| 77 | + "OpenAPI · a multi-server spec advertises a per-call server selector on its tools", |
| 78 | + {}, |
| 79 | + Effect.gen(function* () { |
| 80 | + const target = yield* Target; |
| 81 | + const { client } = yield* Api; |
| 82 | + const identity = yield* target.newIdentity(); |
| 83 | + const apiClient = yield* client(api, identity); |
| 84 | + |
| 85 | + // Unique slug per run: selfhost shares the bootstrap-admin identity, so the |
| 86 | + // prefix keeps parallel/repeated runs out of each other's catalogs. |
| 87 | + const slug = `openapi-scn-servers-${randomBytes(4).toString("hex")}`; |
| 88 | + |
| 89 | + yield* Effect.ensuring( |
| 90 | + Effect.gen(function* () { |
| 91 | + // Add the spec with NO baseUrl — the host is resolved per call from the |
| 92 | + // spec's servers, so a base URL is purely an optional override now. |
| 93 | + const added = yield* apiClient.openapi.addSpec({ |
| 94 | + payload: { |
| 95 | + spec: { kind: "blob", value: multiServerSpec() }, |
| 96 | + slug, |
| 97 | + authenticationTemplate: [ |
| 98 | + { |
| 99 | + slug: "apiKey", |
| 100 | + type: "apiKey", |
| 101 | + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, |
| 102 | + }, |
| 103 | + ], |
| 104 | + }, |
| 105 | + }); |
| 106 | + expect(added.toolCount, "the spec's operation became a tool").toBeGreaterThan(0); |
| 107 | + |
| 108 | + // The catalog stamps tools once a connection exists; a `from` provider |
| 109 | + // reference avoids any vault round-trip. |
| 110 | + const providers = yield* apiClient.providers.list(); |
| 111 | + expect(providers.length, "a credential provider is available").toBeGreaterThan(0); |
| 112 | + yield* apiClient.connections.create({ |
| 113 | + payload: { |
| 114 | + owner: "org", |
| 115 | + name: ConnectionName.make("main"), |
| 116 | + integration: IntegrationSlug.make(slug), |
| 117 | + template: AuthTemplateSlug.make("apiKey"), |
| 118 | + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, |
| 119 | + }, |
| 120 | + }); |
| 121 | + |
| 122 | + // Locate the generated tool and read its full input schema. |
| 123 | + const tools = yield* apiClient.tools.list({ query: {} }); |
| 124 | + const ping = tools.find( |
| 125 | + (tool) => String(tool.integration) === slug && String(tool.name).includes("ping"), |
| 126 | + ); |
| 127 | + expect(ping?.address, "the ping tool is in the catalog").toBeDefined(); |
| 128 | + |
| 129 | + const view = yield* apiClient.tools.schema({ query: { address: ping!.address } }); |
| 130 | + const input = view.inputSchema as ServerInputSchema; |
| 131 | + const serverInput = input.properties?.server?.properties; |
| 132 | + |
| 133 | + // The per-call `server` input exists: `url` is an enum over BOTH declared |
| 134 | + // servers (raw templates), defaulting to the first. |
| 135 | + expect(serverInput?.url?.enum, "server.url enumerates the declared servers").toEqual([ |
| 136 | + PROD_SERVER, |
| 137 | + SANDBOX_SERVER, |
| 138 | + ]); |
| 139 | + expect(serverInput?.url?.default, "server.url defaults to the first server").toBe( |
| 140 | + PROD_SERVER, |
| 141 | + ); |
| 142 | + |
| 143 | + // …and the templated server's `{region}` surfaces as a per-call variable |
| 144 | + // carrying its spec enum and default. |
| 145 | + const region = serverInput?.variables?.properties?.region; |
| 146 | + expect(region?.enum, "server.variables.region carries the spec enum").toEqual([ |
| 147 | + "us", |
| 148 | + "eu", |
| 149 | + "ap", |
| 150 | + ]); |
| 151 | + expect(region?.default, "server.variables.region keeps the spec default").toBe("us"); |
| 152 | + }), |
| 153 | + // Selfhost shares one bootstrap admin, so this scenario must not leak its |
| 154 | + // connection or integration into other scenarios' zero-state assertions. |
| 155 | + Effect.gen(function* () { |
| 156 | + yield* apiClient.connections |
| 157 | + .remove({ |
| 158 | + params: { |
| 159 | + owner: "org", |
| 160 | + integration: IntegrationSlug.make(slug), |
| 161 | + name: ConnectionName.make("main"), |
| 162 | + }, |
| 163 | + }) |
| 164 | + .pipe(Effect.ignore); |
| 165 | + yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); |
| 166 | + }), |
| 167 | + ); |
| 168 | + }), |
| 169 | +); |
0 commit comments