Skip to content

Commit 3eb8d67

Browse files
committed
Add an e2e scenario for per-call OpenAPI server selection
A multi-server spec, added through the typed product API, must turn into a tool that advertises the per-call `server` selector: a `url` enum over the declared servers plus the `{variables}` drawn from the templated one. The scenario also pins the base URL as an optional override — addSpec succeeds with no baseUrl, the host being resolved per call from the spec's servers. Cross-target (cloud + selfhost), driven entirely through the typed client.
1 parent 12be591 commit 3eb8d67

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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

Comments
 (0)