History

Support per-call server selection for OpenAPI tools (#952)

* Support per-call server selection for OpenAPI tools Resolve OpenAPI servers per tool call instead of baking a single base URL at add time. Operations can declare multiple servers (document, path, or operation level) and templated URLs with {variables}; each is now selectable when invoking the tool. - Each tool exposes an optional `server` input ({ url?, variables? }), shown only when there is something to choose: `url` becomes an enum when more than one server applies, and `variables` are drawn from the applicable servers. - Host resolution: a connection base URL override wins when set; otherwise the call's chosen server (server.url or the first applicable) is resolved with its {variables} (call values, else spec defaults); otherwise no host is prepended. - The connection base URL becomes an optional override (off by default), required only when the spec declares no servers. The form shows a combobox for multiple top-level servers and a plain input otherwise, with the first server's resolved URL as the placeholder. - Drop connection-level serverVariables in favor of per-call variables. - Bindings persisted before this change keep working: a missing `servers` field is treated as empty and falls back to the connection base URL. * 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. * Add a browser e2e scenario for the multi-server add form Records the UI side of per-call server selection: pasting a spec that declares more than one server turns the Add OpenAPI source form's Base URL field into a picker over those servers and relabels it an optional override, with a hint that the host is otherwise chosen per tool call. The session video and step screenshots are the artifact; the scenario skips on targets without a browser surface. ---------

Alexis Rico committed 5 months ago

Commit 757cc961 parent 9b8baa116 files · +786 −125Browse files

Unified · Split

16 files
  1. e2e/scenarios/openapi-server-selection-ui.test.ts +72 −0
  2. e2e/scenarios/openapi-server-selection.test.ts +169 −0
  3. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +37 −17
  4. packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx +11 −7
  5. packages/plugins/openapi/src/sdk/config.ts +1 −1
  6. packages/plugins/openapi/src/sdk/derive-auth.ts +4 −2
  7. packages/plugins/openapi/src/sdk/extract.ts +68 −13
  8. packages/plugins/openapi/src/sdk/google-discovery.test.ts +2 −2
  9. packages/plugins/openapi/src/sdk/index.ts +1 −1
  10. packages/plugins/openapi/src/sdk/invoke.ts +30 −3
  11. packages/plugins/openapi/src/sdk/openapi-utils.ts +12 −42
  12. packages/plugins/openapi/src/sdk/plugin.test.ts +18 −14
  13. packages/plugins/openapi/src/sdk/plugin.ts +9 −4
  14. packages/plugins/openapi/src/sdk/query-serialization.test.ts +83 −3
  15. packages/plugins/openapi/src/sdk/server-url-resolution.test.ts +253 −0
  16. packages/plugins/openapi/src/sdk/types.ts +16 −16
e2e/scenarios/openapi-server-selection-ui.test.ts+72−0
@@ -0,0 +1,72 @@
1// Cross-target (browser): the UI side of per-call server selection. When a
2// pasted spec declares more than one server, the Add OpenAPI source form turns
3// the Base URL field into a picker over those servers and relabels it an
4// optional override — the host is otherwise resolved per tool call. The session
5// video + per-step screenshots are the artifact; this scenario skips on targets
6// without a browser surface (selfhost today).
7import { expect } from "@effect/vitest";
8import { Effect } from "effect";
9
10import { scenario } from "../src/scenario";
11import { Browser, Target } from "../src/services";
12
13// Two servers — production and staging — so the form offers a base-URL picker
14// instead of the single locked input a one-server spec gets.
15const multiServerSpec = JSON.stringify({
16 openapi: "3.0.3",
17 info: { title: "Regions API", version: "1.0.0" },
18 servers: [
19 { url: "https://api.example.com", description: "Production" },
20 { url: "https://staging.example.com", description: "Staging" },
21 ],
22 paths: {
23 "/ping": { get: { operationId: "ping", responses: { "200": { description: "pong" } } } },
24 },
25});
26
27scenario(
28 "OpenAPI · the add form offers a server picker and an optional base URL for a multi-server spec",
29 {},
30 Effect.scoped(
31 Effect.gen(function* () {
32 const target = yield* Target;
33 const browser = yield* Browser;
34 const identity = yield* target.newIdentity();
35
36 yield* browser.session(identity, async ({ page, step }) => {
37 await step("Open the Add OpenAPI source form", async () => {
38 await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
39 await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor();
40 });
41
42 await step("Paste a spec that declares two servers", async () => {
43 await page.getByPlaceholder("https://api.example.com/openapi.json").fill(multiServerSpec);
44 // The form auto-analyzes (debounced) and renders the preview details,
45 // where the base URL is now an OPTIONAL override.
46 await page.getByText("Base URL override (optional)").waitFor({ timeout: 20_000 });
47 });
48
49 await step("The base URL is optional — the server is chosen per call", async () => {
50 // The hint spells out that leaving it empty defers the host (and its
51 // variables) to each tool call: the heart of per-call selection.
52 await page.getByText(/leave empty to choose the server.*per tool call/i).waitFor();
53 });
54
55 await step("The field is a picker over the spec's two servers", async () => {
56 // Opening the combobox reveals both declared servers as choices.
57 await page.getByPlaceholder("https://api.example.com", { exact: true }).click();
58 const options = page.getByRole("option");
59 await options.filter({ hasText: "staging.example.com" }).waitFor({ timeout: 10_000 });
60 const labels = await options.allInnerTexts();
61 expect(
62 labels.join(" | "),
63 "both declared servers are offered as base-URL choices",
64 ).toEqual(expect.stringContaining("https://staging.example.com"));
65 expect(labels.join(" | "), "production is offered too").toEqual(
66 expect.stringContaining("https://api.example.com"),
67 );
68 });
69 });
70 }),
71 ),
72);
e2e/scenarios/openapi-server-selection.test.ts+169−0
@@ -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.
11import { randomBytes, randomUUID } from "node:crypto";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15import { composePluginApi } from "@executor-js/api/server";
16import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
17import {
18 AuthTemplateSlug,
19 ConnectionName,
20 IntegrationSlug,
21 ProviderItemId,
22} from "@executor-js/sdk/shared";
23
24import { scenario } from "../src/scenario";
25import { Api, Target } from "../src/services";
26
27const 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.
31const PROD_SERVER = "https://api.example.test/v1";
32const SANDBOX_SERVER = "https://{region}.sandbox.example.test/v1";
33
34const 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.
60type 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
76scenario(
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);
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+37−17
@@ -45,11 +45,10 @@ import { · 44 unmodified lines
45 type GoogleOpenApiPreset,45 type GoogleOpenApiPreset,
46} from "../sdk/google-presets";46} from "../sdk/google-presets";
47import type { SpecPreview } from "../sdk/preview";47import type { SpecPreview } from "../sdk/preview";
48import { type Authentication, type ServerInfo } from "../sdk/types";48import { type Authentication } from "../sdk/types";
49import { expandServerUrlOptions } from "../sdk/openapi-utils";49import { resolveServerUrl } from "../sdk/openapi-utils";
50import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth";50import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
51 51
52const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
53const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";52const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
54 53
55// The bundle picker opens with the featured Google APIs pre-checked.54// The bundle picker opens with the featured Google APIs pre-checked.
@@ -103,9 +102,6 @@ const specInputForAdd = (input: string) => { · 47 unmodified lines
103 : { kind: "blob" as const, value };102 : { kind: "blob" as const, value };
104};103};
105 104
106const expandServerOptions = (server: ServerInfo) =>
107 expandServerUrlOptions(server).map((value) => ({ value, label: value }));
108
109// ---------------------------------------------------------------------------105// ---------------------------------------------------------------------------
110// Component — single progressive form. Post-redesign: preview → addSpec106// Component — single progressive form. Post-redesign: preview → addSpec
111// (register the integration catalog entry with ALL detected auth methods) →107// (register the integration catalog entry with ALL detected auth methods) →
@@ -136,7 +132,7 @@ export default function AddOpenApiSource(props: { · 24 unmodified lines
136 132
137 // After analysis133 // After analysis
138 const [preview, setPreview] = useState<SpecPreview | null>(null);134 const [preview, setPreview] = useState<SpecPreview | null>(null);
139 const [baseUrl, setBaseUrl] = useState(isGoogleBundlePreset ? GOOGLE_BUNDLE_BASE_URL : "");135 const [baseUrl, setBaseUrl] = useState("");
140 const identityFallbackName = isGoogleBundlePreset136 const identityFallbackName = isGoogleBundlePreset
141 ? "Google"137 ? "Google"
142 : preview138 : preview
@@ -198,10 +194,20 @@ export default function AddOpenApiSource(props: { · 55 unmodified lines
198 194
199 // ---- Derived state ----195 // ---- Derived state ----
200 196
201 const servers: readonly ServerInfo[] = preview?.servers ?? [];197 const previewHasNoServers = preview !== null && preview.servers.length === 0;
202 const baseUrlOptions = Array.from(198 // Offer the spec's servers (resolved with defaults) as base-URL choices when
203 new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),199 // there's more than one; a single/no server uses a plain input.
204 );200 const baseUrlOptions =
201 preview && preview.servers.length > 1
202 ? preview.servers.map((server) => {
203 const url = resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {});
204 return { value: url, label: url };
205 })
206 : undefined;
207 const firstServer = preview?.servers[0];
208 const firstServerUrl = firstServer
209 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
210 : "";
205 const previewPresetIcon =211 const previewPresetIcon =
206 openApiPresets.find(212 openApiPresets.find(
207 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),213 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
@@ -275,7 +281,12 @@ export default function AddOpenApiSource(props: { · 67 unmodified lines
275 const hasPreviewOrBundle = isGoogleBundlePreset281 const hasPreviewOrBundle = isGoogleBundlePreset
276 ? bundleDiscoveryUrls.length > 0282 ? bundleDiscoveryUrls.length > 0
277 : preview !== null;283 : preview !== null;
278 const canAdd = hasPreviewOrBundle && resolvedBaseUrl.length > 0 && !slugAlreadyExists;284 // The base URL is optional when the spec declares servers (resolved per call);
285 // required only when it doesn't.
286 const canAdd =
287 hasPreviewOrBundle &&
288 !slugAlreadyExists &&
289 (!previewHasNoServers || resolvedBaseUrl.length > 0);
279 290
280 // ---- Handlers ----291 // ---- Handlers ----
281 292
@@ -291,7 +302,7 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
291 }302 }
292 const result = exit.value;303 const result = exit.value;
293 setPreview(result);304 setPreview(result);
294 setBaseUrl(firstBaseUrlForPreview(result));305 setBaseUrl("");
295 setAnalyzing(false);306 setAnalyzing(false);
296 };307 };
297 308
@@ -423,9 +434,9 @@ export default function AddOpenApiSource(props: { · 125 unmodified lines
423 identity={identity}434 identity={identity}
424 baseUrl={resolvedBaseUrl}435 baseUrl={resolvedBaseUrl}
425 onBaseUrlChange={setBaseUrl}436 onBaseUrlChange={setBaseUrl}
437 baseUrlLabel="Base URL override (optional)"
426 faviconIcon={GOOGLE_BUNDLE_FAVICON}438 faviconIcon={GOOGLE_BUNDLE_FAVICON}
427 faviconUrl={resolvedBaseUrl}439 faviconUrl={resolvedBaseUrl}
428 baseUrlMissingMessage="A base URL is required to make requests."
429 />440 />
430 ) : preview ? (441 ) : preview ? (
431 <OpenApiSourceDetailsFields442 <OpenApiSourceDetailsFields
@@ -441,6 +452,16 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
441 baseUrl={resolvedBaseUrl}452 baseUrl={resolvedBaseUrl}
442 onBaseUrlChange={setBaseUrl}453 onBaseUrlChange={setBaseUrl}
443 baseUrlOptions={baseUrlOptions}454 baseUrlOptions={baseUrlOptions}
455 baseUrlLabel={previewHasNoServers ? "Base URL" : "Base URL override (optional)"}
456 baseUrlPlaceholder={firstServerUrl || "https://api.example.com"}
457 baseUrlHint={
458 previewHasNoServers
459 ? undefined
460 : "Overrides the spec's servers; leave empty to choose the server (and variables) per tool call."
461 }
462 baseUrlMissingMessage={
463 previewHasNoServers ? "This spec declares no servers — enter a base URL." : undefined
464 }
444 specUrl={specUrl}465 specUrl={specUrl}
445 onSpecUrlChange={(value) => {466 onSpecUrlChange={(value) => {
446 setSpecUrl(value);467 setSpecUrl(value);
@@ -448,8 +469,7 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
448 setBaseUrl("");469 setBaseUrl("");
449 }}470 }}
450 faviconIcon={previewPresetIcon}471 faviconIcon={previewPresetIcon}
451 faviconUrl={resolvedBaseUrl}472 faviconUrl={resolvedBaseUrl || firstServerUrl}
452 baseUrlMissingMessage="A base URL is required to make requests."
453 />473 />
454 ) : null}474 ) : null}
455 475
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+11−7
@@ -25,6 +25,9 @@ export function OpenApiSourceDetailsFields(props: { · 24 unmodified lines
25 readonly baseUrl: string;25 readonly baseUrl: string;
26 readonly onBaseUrlChange: (value: string) => void;26 readonly onBaseUrlChange: (value: string) => void;
27 readonly baseUrlOptions?: readonly FreeformComboboxOption[];27 readonly baseUrlOptions?: readonly FreeformComboboxOption[];
28 readonly baseUrlLabel?: string;
29 readonly baseUrlPlaceholder?: string;
30 readonly baseUrlHint?: string;
28 readonly specUrl?: string;31 readonly specUrl?: string;
29 readonly onSpecUrlChange?: (value: string) => void;32 readonly onSpecUrlChange?: (value: string) => void;
30 readonly faviconIcon?: string | null;33 readonly faviconIcon?: string | null;
@@ -35,8 +38,6 @@ export function OpenApiSourceDetailsFields(props: { · 4 unmodified lines
35 readonly baseUrlMissingMessage?: string;38 readonly baseUrlMissingMessage?: string;
36 readonly footer?: string;39 readonly footer?: string;
37}) {40}) {
38 const baseUrlOptions = props.baseUrlOptions ?? [];
39
40 return (41 return (
41 <CardStack>42 <CardStack>
42 <CardStackContent className="border-t-0">43 <CardStackContent className="border-t-0">
@@ -61,13 +62,13 @@ export function OpenApiSourceDetailsFields(props: { · 18 unmodified lines
61 namespaceReadOnly={props.namespaceReadOnly}62 namespaceReadOnly={props.namespaceReadOnly}
62 />63 />
63 <div className="grid grid-cols-1 md:grid-cols-2">64 <div className="grid grid-cols-1 md:grid-cols-2">
64 <CardStackEntryField label="Base URL">65 <CardStackEntryField label={props.baseUrlLabel ?? "Base URL"}>
65 {baseUrlOptions.length > 0 ? (66 {props.baseUrlOptions && props.baseUrlOptions.length > 0 ? (
66 <FreeformCombobox67 <FreeformCombobox
67 value={props.baseUrl}68 value={props.baseUrl}
68 onValueChange={props.onBaseUrlChange}69 onValueChange={props.onBaseUrlChange}
69 options={baseUrlOptions}70 options={props.baseUrlOptions}
70 placeholder="https://api.example.com"71 placeholder={props.baseUrlPlaceholder ?? "https://api.example.com"}
71 className="w-full"72 className="w-full"
72 inputClassName="font-mono text-sm"73 inputClassName="font-mono text-sm"
73 />74 />
@@ -75,7 +76,7 @@ export function OpenApiSourceDetailsFields(props: { · 1 unmodified line
75 <Input76 <Input
76 value={props.baseUrl}77 value={props.baseUrl}
77 onChange={(e) => props.onBaseUrlChange((e.target as HTMLInputElement).value)}78 onChange={(e) => props.onBaseUrlChange((e.target as HTMLInputElement).value)}
78 placeholder="https://api.example.com"79 placeholder={props.baseUrlPlaceholder ?? "https://api.example.com"}
79 className="font-mono text-sm"80 className="font-mono text-sm"
80 />81 />
81 )}82 )}
@@ -85,6 +86,9 @@ export function OpenApiSourceDetailsFields(props: { · 3 unmodified lines
85 {props.baseUrlMissingMessage}86 {props.baseUrlMissingMessage}
86 </p>87 </p>
87 )}88 )}
89 {props.baseUrlHint && (
90 <p className="text-[11px] text-muted-foreground">{props.baseUrlHint}</p>
91 )}
88 </CardStackEntryField>92 </CardStackEntryField>
89 {props.specUrl !== undefined && props.onSpecUrlChange && (93 {props.specUrl !== undefined && props.onSpecUrlChange && (
90 <CardStackEntryField label="Spec URL">94 <CardStackEntryField label="Spec URL">
packages/plugins/openapi/src/sdk/config.ts+1−1
@@ -45,7 +45,7 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 44 unmodified lines
45 sourceUrl: Schema.optional(Schema.String),45 sourceUrl: Schema.optional(Schema.String),
46 /** Google Discovery bundle URLs, when the spec came from a Google bundle. */46 /** Google Discovery bundle URLs, when the spec came from a Google bundle. */
47 googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),47 googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
48 /** Base URL override; falls back to the spec's first server. */48 /** Optional base URL override. */
49 baseUrl: Schema.optional(Schema.String),49 baseUrl: Schema.optional(Schema.String),
50 /** Static headers applied to every request (no secret material). */50 /** Static headers applied to every request (no secret material). */
51 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),51 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
packages/plugins/openapi/src/sdk/derive-auth.ts+4−2
@@ -11,7 +11,7 @@ import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/sha · 10 unmodified lines
11 11
12import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview";12import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview";
13import type { APIKeyAuthentication, Authentication } from "./types";13import type { APIKeyAuthentication, Authentication } from "./types";
14import { expandServerUrlOptions } from "./openapi-utils";14import { resolveServerUrl } from "./openapi-utils";
15 15
16// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
17// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve17// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
@@ -134,7 +134,9 @@ export const detectedAuthenticationTemplates = ( · 116 unmodified lines
134 134
135export const firstBaseUrlForPreview = (preview: SpecPreview): string => {135export const firstBaseUrlForPreview = (preview: SpecPreview): string => {
136 const firstServer = preview.servers[0];136 const firstServer = preview.servers[0];
137 return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";137 return firstServer
138 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
139 : "";
138};140};
139 141
140/** The fallback `addSpec` uses when no explicit template was passed: every142/** The fallback `addSpec` uses when no explicit template was passed: every
packages/plugins/openapi/src/sdk/extract.ts+68−13
@@ -6,7 +6,6 @@ import { · 5 unmodified lines
6 declaredContents,6 declaredContents,
7 DocResolver,7 DocResolver,
8 preferredResponseContent,8 preferredResponseContent,
9 resolveBaseUrl,
10 type OperationObject,9 type OperationObject,
11 type ParameterObject,10 type ParameterObject,
12 type PathItemObject,11 type PathItemObject,
@@ -169,9 +168,60 @@ const extractOutputSchema = (operation: OperationObject, r: DocResolver): unknow · 156 unmodified lines
169// Input schema builder168// Input schema builder
170// ---------------------------------------------------------------------------169// ---------------------------------------------------------------------------
171 170
171// Optional `server` input — host selection + server-URL variables. Undefined
172// when there's nothing to configure (a single server with no variables).
173const buildServerInputProperty = (
174 servers: readonly ServerInfo[],
175): Record<string, unknown> | undefined => {
176 const variableDefs: Record<string, ServerVariable> = {};
177 for (const server of servers) {
178 for (const [name, v] of Object.entries(Option.getOrUndefined(server.variables) ?? {})) {
179 if (!(name in variableDefs)) variableDefs[name] = v;
180 }
181 }
182 const hasMultiple = servers.length > 1;
183 const variableNames = Object.keys(variableDefs);
184 if (!hasMultiple && variableNames.length === 0) return undefined;
185
186 const properties: Record<string, unknown> = {};
187 if (hasMultiple) {
188 properties.url = {
189 type: "string",
190 enum: servers.map((server) => server.url),
191 default: servers[0]!.url,
192 description: "Which of the spec's servers to send the request to.",
193 };
194 }
195 if (variableNames.length > 0) {
196 properties.variables = {
197 type: "object",
198 additionalProperties: false,
199 properties: Object.fromEntries(
200 Object.entries(variableDefs).map(([name, v]) => [
201 name,
202 {
203 type: "string",
204 default: v.default,
205 ...(Option.isSome(v.enum) ? { enum: v.enum.value } : {}),
206 ...(Option.isSome(v.description) ? { description: v.description.value } : {}),
207 },
208 ]),
209 ),
210 description: "Values for the server URL `{variables}`; spec defaults apply when omitted.",
211 };
212 }
213 return {
214 type: "object",
215 additionalProperties: false,
216 properties,
217 description: "Optional host selection and server-URL variables for this request.",
218 };
219};
220
172const buildInputSchema = (221const buildInputSchema = (
173 parameters: readonly OperationParameter[],222 parameters: readonly OperationParameter[],
174 requestBody: OperationRequestBody | undefined,223 requestBody: OperationRequestBody | undefined,
224 servers: readonly ServerInfo[],
175): Record<string, unknown> | undefined => {225): Record<string, unknown> | undefined => {
176 const properties: Record<string, unknown> = {};226 const properties: Record<string, unknown> = {};
177 const required: string[] = [];227 const required: string[] = [];
@@ -181,6 +231,10 @@ const buildInputSchema = ( · 3 unmodified lines
181 if (param.required) required.push(param.name);231 if (param.required) required.push(param.name);
182 }232 }
183 233
234 // A path/query parameter named `server` takes precedence over the host input.
235 const serverProperty = buildServerInputProperty(servers);
236 if (serverProperty && !("server" in properties)) properties.server = serverProperty;
237
184 if (requestBody) {238 if (requestBody) {
185 properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" }));239 properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" }));
186 if (requestBody.required) required.push("body");240 if (requestBody.required) required.push("body");
@@ -283,17 +337,16 @@ const extractServerList = (servers: readonly ServerObject[] | undefined): Server · 96 unmodified lines
283 337
284const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers);338const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers);
285 339
286const extractOperationBaseUrl = (340const operationServers = (
287 pathItem: PathItemObject,341 pathItem: PathItemObject,
288 operation: OperationObject,342 operation: OperationObject,
289): string | undefined => {343 docServers: readonly ServerInfo[],
290 const operationServers = extractServerList(operation.servers);344): readonly ServerInfo[] => {
291 if (operationServers.length > 0) return resolveBaseUrl(operationServers);345 const operationLevel = extractServerList(operation.servers);
292 346 if (operationLevel.length > 0) return operationLevel;
293 const pathServers = extractServerList(pathItem.servers);347 const pathLevel = extractServerList(pathItem.servers);
294 if (pathServers.length > 0) return resolveBaseUrl(pathServers);348 if (pathLevel.length > 0) return pathLevel;
295 349 return docServers;
296 return undefined;
297};350};
298 351
299// ---------------------------------------------------------------------------352// ---------------------------------------------------------------------------
@@ -310,6 +363,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 10 unmodified lines
310 }363 }
311 364
312 const r = new DocResolver(doc);365 const r = new DocResolver(doc);
366 const docServers = extractServers(doc);
313 const operations: ExtractedOperation[] = [];367 const operations: ExtractedOperation[] = [];
314 368
315 for (const [pathTemplate, pathItem] of Object.entries(paths).sort(([a], [b]) =>369 for (const [pathTemplate, pathItem] of Object.entries(paths).sort(([a], [b]) =>
@@ -323,7 +377,8 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 7 unmodified lines
323 377
324 const parameters = extractParameters(pathItem, operation, r);378 const parameters = extractParameters(pathItem, operation, r);
325 const requestBody = extractRequestBody(operation, r);379 const requestBody = extractRequestBody(operation, r);
326 const inputSchema = buildInputSchema(parameters, requestBody);380 const servers = operationServers(pathItem, operation, docServers);
381 const inputSchema = buildInputSchema(parameters, requestBody, servers);
327 const outputSchema = extractOutputSchema(operation, r);382 const outputSchema = extractOutputSchema(operation, r);
328 const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);383 const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);
329 const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate;384 const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate;
@@ -333,7 +388,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 3 unmodified lines
333 operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),388 operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),
334 toolPath: Option.fromNullishOr(explicitToolPath(operation)),389 toolPath: Option.fromNullishOr(explicitToolPath(operation)),
335 method,390 method,
336 baseUrl: extractOperationBaseUrl(pathItem, operation),391 servers,
337 pathTemplate: operationPathTemplate,392 pathTemplate: operationPathTemplate,
338 summary: Option.fromNullishOr(operation.summary),393 summary: Option.fromNullishOr(operation.summary),
339 description: Option.fromNullishOr(operation.description),394 description: Option.fromNullishOr(operation.description),
@@ -351,7 +406,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 11 unmodified lines
351 return ExtractionResult.make({406 return ExtractionResult.make({
352 title: Option.fromNullishOr(doc.info?.title),407 title: Option.fromNullishOr(doc.info?.title),
353 version: Option.fromNullishOr(doc.info?.version),408 version: Option.fromNullishOr(doc.info?.version),
354 servers: extractServers(doc),409 servers: docServers,
355 operations,410 operations,
356 });411 });
357});412});
packages/plugins/openapi/src/sdk/google-discovery.test.ts+2−2
@@ -436,9 +436,9 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( · 435 unmodified lines
436 const extractedChatMessage = extracted.operations.find(436 const extractedChatMessage = extracted.operations.find(
437 (candidate) => candidate.operationId === "chat.spaces.messages.get",437 (candidate) => candidate.operationId === "chat.spaces.messages.get",
438 );438 );
439 expect(extractedGmail?.baseUrl).toBe("https://gmail.googleapis.com/");439 expect(extractedGmail?.servers[0]?.url).toBe("https://gmail.googleapis.com/");
440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441 expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");441 expect(extractedChatMessage?.servers[0]?.url).toBe("https://chat.googleapis.com/");
442 // v2: the bundled oauth scopes are carried on the oauth auth template.442 // v2: the bundled oauth scopes are carried on the oauth auth template.
443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444 expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([444 expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
packages/plugins/openapi/src/sdk/index.ts+1−1
@@ -40,7 +40,7 @@ export { · 39 unmodified lines
40} from "./preview";40} from "./preview";
41export {41export {
42 DocResolver,42 DocResolver,
43 resolveBaseUrl,43 resolveServerUrl,
44 substituteUrlVariables,44 substituteUrlVariables,
45 preferredContent,45 preferredContent,
46} from "./openapi-utils";46} from "./openapi-utils";
packages/plugins/openapi/src/sdk/invoke.ts+30−3
@@ -2,12 +2,14 @@ import { Effect, Layer, Option } from "effect"; · 1 unmodified line
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3 3
4import { OpenApiInvocationError } from "./errors";4import { OpenApiInvocationError } from "./errors";
5import { resolveServerUrl } from "./openapi-utils";
5import {6import {
6 type EncodingObject,7 type EncodingObject,
7 type OperationBinding,8 type OperationBinding,
8 InvocationResult,9 InvocationResult,
9 type MediaBinding,10 type MediaBinding,
10 type OperationParameter,11 type OperationParameter,
12 type ServerInfo,
11} from "./types";13} from "./types";
12 14
13// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
@@ -572,8 +574,34 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 558 unmodified lines
572 });574 });
573});575});
574 576
577// Connection `baseUrl` wins; otherwise the call's chosen server (`server.url`, or
578// the first) resolved with its `{variables}` (call values, else spec defaults).
579const resolveRequestHost = (
580 servers: readonly ServerInfo[],
581 serverArg: unknown,
582 baseUrl: string,
583): string => {
584 if (baseUrl) return baseUrl;
585 if (servers.length === 0) return "";
586
587 const arg = (
588 typeof serverArg === "object" && serverArg !== null && !Array.isArray(serverArg)
589 ? serverArg
590 : {}
591 ) as { url?: unknown; variables?: unknown };
592 const chosen = servers.find((server) => server.url === arg.url) ?? servers[0]!;
593
594 const overrides: Record<string, string> = {};
595 if (typeof arg.variables === "object" && arg.variables !== null) {
596 for (const [name, value] of Object.entries(arg.variables as Record<string, unknown>)) {
597 if (value != null && value !== "") overrides[name] = String(value);
598 }
599 }
600 return resolveServerUrl(chosen.url, Option.getOrUndefined(chosen.variables), overrides);
601};
602
575// ---------------------------------------------------------------------------603// ---------------------------------------------------------------------------
576// Invoke with a provided HttpClient layer + optional baseUrl prefix604// Invoke with a provided HttpClient layer + per-call host resolution
577// ---------------------------------------------------------------------------605// ---------------------------------------------------------------------------
578 606
579export const invokeWithLayer = (607export const invokeWithLayer = (
@@ -584,8 +612,7 @@ export const invokeWithLayer = ( · 4 unmodified lines
584 sourceQueryParams: Record<string, string>,612 sourceQueryParams: Record<string, string>,
585 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,613 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
586) => {614) => {
587 const operationBaseUrl = operation.baseUrl;615 const effectiveBaseUrl = resolveRequestHost(operation.servers ?? [], args.server, baseUrl);
588 const effectiveBaseUrl = operationBaseUrl ?? baseUrl;
589 const clientWithBaseUrl = effectiveBaseUrl616 const clientWithBaseUrl = effectiveBaseUrl
590 ? Layer.effect(617 ? Layer.effect(
591 HttpClient.HttpClient,618 HttpClient.HttpClient,
packages/plugins/openapi/src/sdk/openapi-utils.ts+12−42
@@ -4,7 +4,6 @@ · 3 unmodified lines
4// Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution.4// Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution.
5// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
6 6
7import { Option } from "effect";
8import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";7import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
9import type { ParsedDocument } from "./parse";8import type { ParsedDocument } from "./parse";
10import type { ServerVariable } from "./types";9import type { ServerVariable } from "./types";
@@ -65,49 +64,20 @@ export const substituteUrlVariables = (url: string, values: Record<string, strin · 54 unmodified lines
65 return out;64 return out;
66};65};
67 66
68export const OPENAPI_MAX_SERVER_VARIABLE_OPTIONS = 64;67/** Resolve a templated server URL, filling each `{var}` from `overrides` when
69 68 * non-empty, otherwise the variable's spec default. URLs without placeholders
70type ServerLike = {69 * pass through unchanged. */
71 url: string;70export const resolveServerUrl = (
72 variables: import("effect/Option").Option<Record<string, ServerVariable | string>>;71 templateUrl: string,
73};72 variables: Record<string, ServerVariable> | undefined,
74 73 overrides: Record<string, string>,
75export const expandServerUrlOptions = (74): string => {
76 server: ServerLike,
77 limit = OPENAPI_MAX_SERVER_VARIABLE_OPTIONS,
78): readonly string[] => {
79 if (!Option.isSome(server.variables)) return [server.url];
80 let urls: readonly string[] = [server.url];
81 for (const [name, variable] of Object.entries(server.variables.value)) {
82 const enumValues =
83 typeof variable === "string" ? [] : Option.getOrElse(variable.enum, () => []);
84 const values =
85 enumValues.length > 0
86 ? enumValues
87 : [typeof variable === "string" ? variable : variable.default];
88 const next: string[] = [];
89 for (const url of urls) {
90 for (const value of values) {
91 next.push(url.replaceAll(`{${name}}`, value));
92 if (next.length >= limit) return next;
93 }
94 }
95 urls = next;
96 }
97 return urls;
98};
99
100export const resolveBaseUrl = (servers: readonly ServerLike[]): string => {
101 const server = servers[0];
102 if (!server) return "";
103
104 if (!Option.isSome(server.variables)) return server.url;
105
106 const values: Record<string, string> = {};75 const values: Record<string, string> = {};
107 for (const [name, v] of Object.entries(server.variables.value)) {76 for (const [name, v] of Object.entries(variables ?? {})) values[name] = v.default;
108 values[name] = typeof v === "string" ? v : v.default;77 for (const [name, value] of Object.entries(overrides)) {
78 if (value) values[name] = value;
109 }79 }
110 return substituteUrlVariables(server.url, values);80 return substituteUrlVariables(templateUrl, values);
111};81};
112 82
113// ---------------------------------------------------------------------------83// ---------------------------------------------------------------------------
packages/plugins/openapi/src/sdk/plugin.test.ts+18−14
@@ -559,28 +559,32 @@ describe("OpenAPI Plugin", () => { · 558 unmodified lines
559 ),559 ),
560 );560 );
561 561
562 it.effect("addSpec defaults baseUrl to the spec's first server when omitted", () =>562 it.effect("addSpec omits baseUrl and resolves the host per call from the spec's servers", () =>
563 Effect.scoped(563 Effect.scoped(
564 Effect.gen(function* () {564 Effect.gen(function* () {
565 const server = yield* servePluginTestApi();565 const server = yield* servePluginTestApi();
566 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));566 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
567 567
568 // No baseUrl passed — the spec's own servers entry must fill it, or568 // No baseUrl override: the spec declares `servers`, so the host is
569 // every invocation on the integration fails with no host to call.569 // resolved per call from the operation's servers rather than baked into
570 // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON570 // the connection config. `baseUrl: null` suppresses the test helper's
571 const spec = JSON.parse(server.specJson) as Record<string, unknown>;571 // default connection-level override.
572 const specWithServer = JSON.stringify({572 const conn = yield* addOpenApiTestConnection(executor, server, {
573 ...spec,573 slug: "per_call_host",
574 servers: [{ url: server.baseUrl }],574 baseUrl: null,
575 });575 });
576 576
577 yield* executor.openapi.addSpec({577 // The override is absent…
578 spec: { kind: "blob", value: specWithServer },578 const config = yield* executor.openapi.getConfig("per_call_host");
579 slug: "derived_base_api",579 expect(config?.baseUrl).toBeUndefined();
580 });
581 580
582 const config = yield* executor.openapi.getConfig("derived_base_api");581 // …yet the integration is still invocable: the request reaches the
583 expect(config?.baseUrl).toBe(server.baseUrl);582 // spec's server host with no baked baseUrl.
583 const result = unwrapInvocation(
584 yield* executor.execute(conn.address("items.listItems"), {}),
585 );
586 expect(result.error).toBeNull();
587 expect(result.data).toEqual(ITEMS);
584 }),588 }),
585 ),589 ),
586 );590 );
packages/plugins/openapi/src/sdk/plugin.ts+9−4
@@ -481,7 +481,7 @@ const normalizeOpenApiRefs = (node: unknown): unknown => { · 480 unmodified lines
481const toBinding = (def: ToolDefinition): OperationBinding =>481const toBinding = (def: ToolDefinition): OperationBinding =>
482 OperationBinding.make({482 OperationBinding.make({
483 method: def.operation.method,483 method: def.operation.method,
484 baseUrl: def.operation.baseUrl,484 servers: def.operation.servers,
485 pathTemplate: def.operation.pathTemplate,485 pathTemplate: def.operation.pathTemplate,
486 parameters: [...def.operation.parameters],486 parameters: [...def.operation.parameters],
487 requestBody: def.operation.requestBody,487 requestBody: def.operation.requestBody,
@@ -713,8 +713,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 225 unmodified lines
713 // Defaults the add page derives from its preview, applied here so713 // Defaults the add page derives from its preview, applied here so
714 // headless callers (MCP, API) get the same integration the UI's714 // headless callers (MCP, API) get the same integration the UI's
715 // add flow would produce — see e2e/scenarios/connect-handoff.test.ts:715 // add flow would produce — see e2e/scenarios/connect-handoff.test.ts:
716 // - baseUrl: the spec's first server (else tools have no host to716 // - effectiveBaseUrl: the spec's first server, used to anchor the
717 // call and every invocation fails with "HTTP request failed")717 // derived auth template's absolute URLs. It is NOT stored as the
718 // connection baseUrl — the request host is resolved per call from
719 // the operation's extracted `servers`.
718 // - authenticationTemplate: the spec's declared security schemes720 // - authenticationTemplate: the spec's declared security schemes
719 // (else the Add-connection modal is a dead "No authentication"721 // (else the Add-connection modal is a dead "No authentication"
720 // end with nowhere to paste a credential)722 // end with nowhere to paste a credential)
@@ -758,7 +760,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 37 unmodified lines
758 ...(specInputToGoogleBundle(config.spec) !== undefined760 ...(specInputToGoogleBundle(config.spec) !== undefined
759 ? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }761 ? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }
760 : {}),762 : {}),
761 ...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}),763 // baseUrl is an optional override only. The host is otherwise
764 // resolved per call from the operation's `servers` (extracted from
765 // the spec), so we never bake a derived base URL into the config.
766 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
762 ...(config.headers ? { headers: config.headers } : {}),767 ...(config.headers ? { headers: config.headers } : {}),
763 ...(config.queryParams ? { queryParams: config.queryParams } : {}),768 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
764 // Prefer the caller's explicit template; otherwise adopt the one the769 // Prefer the caller's explicit template; otherwise adopt the one the
packages/plugins/openapi/src/sdk/query-serialization.test.ts+83−3
@@ -4,7 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"; · 3 unmodified lines
4import { createServer, type Server } from "node:http";4import { createServer, type Server } from "node:http";
5 5
6import { invokeWithLayer } from "./invoke";6import { invokeWithLayer } from "./invoke";
7import { OperationBinding, OperationParameter } from "./types";7import { OperationBinding, OperationParameter, ServerInfo } from "./types";
8 8
9const withServer = <A>(9const withServer = <A>(
10 f: (input: { readonly baseUrl: string; readonly requests: string[] }) => Promise<A>,10 f: (input: { readonly baseUrl: string; readonly requests: string[] }) => Promise<A>,
@@ -37,6 +37,7 @@ it.effect("serializes form-exploded query arrays as repeated parameters", () => · 26 unmodified lines
37 withServer(async ({ baseUrl, requests }) => {37 withServer(async ({ baseUrl, requests }) => {
38 const operation = OperationBinding.make({38 const operation = OperationBinding.make({
39 method: "get",39 method: "get",
40 servers: [],
40 pathTemplate: "/messages/{id}",41 pathTemplate: "/messages/{id}",
41 requestBody: Option.none(),42 requestBody: Option.none(),
42 parameters: [43 parameters: [
@@ -101,7 +102,9 @@ it.effect("uses operation base URL and preserves reserved path expansion when al · 58 unmodified lines
101 withServer(async ({ baseUrl, requests }) => {102 withServer(async ({ baseUrl, requests }) => {
102 const operation = OperationBinding.make({103 const operation = OperationBinding.make({
103 method: "get",104 method: "get",
104 baseUrl,105 servers: [
106 ServerInfo.make({ url: baseUrl, description: Option.none(), variables: Option.none() }),
107 ],
105 pathTemplate: "/v1/{+name}",108 pathTemplate: "/v1/{+name}",
106 requestBody: Option.none(),109 requestBody: Option.none(),
107 parameters: [110 parameters: [
@@ -124,7 +127,8 @@ it.effect("uses operation base URL and preserves reserved path expansion when al · 16 unmodified lines
124 {127 {
125 name: "spaces/AAA/messages/BBB",128 name: "spaces/AAA/messages/BBB",
126 },129 },
127 "https://unused.example",130 // No connection override → the request targets the operation's server.
131 "",
128 {},132 {},
129 {},133 {},
130 FetchHttpClient.layer,134 FetchHttpClient.layer,
@@ -136,3 +140,79 @@ it.effect("uses operation base URL and preserves reserved path expansion when al · 5 unmodified lines
136 }),140 }),
137 ),141 ),
138);142);
143
144it.effect("targets the server chosen by the call's `server.url`", () =>
145 Effect.promise(() =>
146 withServer(async ({ baseUrl, requests }) => {
147 const operation = OperationBinding.make({
148 method: "get",
149 // First server is a dead host; the call must select the live one.
150 servers: [
151 ServerInfo.make({
152 url: "https://unused.example",
153 description: Option.none(),
154 variables: Option.none(),
155 }),
156 ServerInfo.make({ url: baseUrl, description: Option.none(), variables: Option.none() }),
157 ],
158 pathTemplate: "/ping",
159 requestBody: Option.none(),
160 parameters: [],
161 });
162
163 await Effect.runPromise(
164 invokeWithLayer(operation, { server: { url: baseUrl } }, "", {}, {}, FetchHttpClient.layer),
165 );
166
167 const url = new URL(requests[0]!, "http://executor.test");
168 expect(url.pathname).toBe("/ping");
169 }),
170 ),
171);
172
173it.effect("a connection base URL overrides the operation's servers", () =>
174 Effect.promise(() =>
175 withServer(async ({ baseUrl, requests }) => {
176 const operation = OperationBinding.make({
177 method: "get",
178 servers: [
179 ServerInfo.make({
180 url: "https://unused.example",
181 description: Option.none(),
182 variables: Option.none(),
183 }),
184 ],
185 pathTemplate: "/ping",
186 requestBody: Option.none(),
187 parameters: [],
188 });
189
190 // The live server is the connection override; it wins over the spec server.
191 await Effect.runPromise(
192 invokeWithLayer(operation, {}, baseUrl, {}, {}, FetchHttpClient.layer),
193 );
194
195 expect(new URL(requests[0]!, "http://executor.test").pathname).toBe("/ping");
196 }),
197 ),
198);
199
200it.effect("falls back to the base URL for bindings persisted without servers", () =>
201 Effect.promise(() =>
202 withServer(async ({ baseUrl, requests }) => {
203 // Old binding shape: no `servers` field at all.
204 const operation = OperationBinding.make({
205 method: "get",
206 pathTemplate: "/ping",
207 requestBody: Option.none(),
208 parameters: [],
209 });
210
211 await Effect.runPromise(
212 invokeWithLayer(operation, {}, baseUrl, {}, {}, FetchHttpClient.layer),
213 );
214
215 expect(new URL(requests[0]!, "http://executor.test").pathname).toBe("/ping");
216 }),
217 ),
218);
packages/plugins/openapi/src/sdk/server-url-resolution.test.ts+253−0
@@ -0,0 +1,253 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Option } from "effect";
3import { FetchHttpClient } from "effect/unstable/http";
4
5import { extract } from "./extract";
6import { resolveServerUrl } from "./openapi-utils";
7import { parse } from "./parse";
8import { previewSpec as previewSpecRaw } from "./preview";
9import { ServerVariable } from "./types";
10
11const previewSpec = (input: string) =>
12 previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer));
13
14const serverVariable = (defaultValue: string): ServerVariable =>
15 ServerVariable.make({ default: defaultValue, enum: Option.none(), description: Option.none() });
16
17// ---------------------------------------------------------------------------
18// resolveServerUrl — invoke-time template resolution
19// ---------------------------------------------------------------------------
20
21describe("resolveServerUrl", () => {
22 const vars = {
23 tenant: serverVariable("default-tenant"),
24 region: serverVariable("us-east-1"),
25 };
26
27 it("fills placeholders from variable defaults when no override is given", () => {
28 expect(resolveServerUrl("https://{tenant}.{region}.api.example.com", vars, {})).toBe(
29 "https://default-tenant.us-east-1.api.example.com",
30 );
31 });
32
33 it("prefers connection overrides over defaults", () => {
34 expect(
35 resolveServerUrl("https://{tenant}.{region}.api.example.com", vars, {
36 tenant: "acme",
37 region: "eu-west-1",
38 }),
39 ).toBe("https://acme.eu-west-1.api.example.com");
40 });
41
42 it("ignores empty overrides and keeps the default", () => {
43 expect(
44 resolveServerUrl("https://{tenant}.{region}.api.example.com", vars, { tenant: "" }),
45 ).toBe("https://default-tenant.us-east-1.api.example.com");
46 });
47
48 it("returns a URL with no placeholders unchanged", () => {
49 expect(resolveServerUrl("https://api.example.com", undefined, { tenant: "acme" })).toBe(
50 "https://api.example.com",
51 );
52 });
53});
54
55// ---------------------------------------------------------------------------
56// Operation applicable servers — operation/path override else document servers
57// ---------------------------------------------------------------------------
58
59// Raw JSON so the path-level `servers` override is easy to express.
60const specWithPathOverride = {
61 openapi: "3.0.0",
62 info: { title: "Example", version: "1.0" },
63 servers: [{ url: "https://api.example.com" }],
64 paths: {
65 "/items": {
66 get: { operationId: "listItems", responses: { "200": { description: "ok" } } },
67 },
68 "/query": {
69 servers: [
70 {
71 url: "https://{tenant}.{region}.api.example.com",
72 variables: { tenant: { default: "default-tenant" }, region: { default: "us-east-1" } },
73 },
74 ],
75 post: { operationId: "runQuery", responses: { "200": { description: "ok" } } },
76 },
77 },
78};
79
80describe("extract — operation applicable servers", () => {
81 it.effect("inherits the document servers when there is no override", () =>
82 Effect.gen(function* () {
83 // @effect-diagnostics-next-line preferSchemaOverJson:off
84 const doc = yield* parse(JSON.stringify(specWithPathOverride));
85 const result = yield* extract(doc);
86
87 const listItems = result.operations.find((op) => op.operationId === "listItems")!;
88 expect(listItems.servers.map((s) => s.url)).toEqual(["https://api.example.com"]);
89 }),
90 );
91
92 it.effect("carries a path-level override's servers as templates with variables", () =>
93 Effect.gen(function* () {
94 // @effect-diagnostics-next-line preferSchemaOverJson:off
95 const doc = yield* parse(JSON.stringify(specWithPathOverride));
96 const result = yield* extract(doc);
97
98 const runQuery = result.operations.find((op) => op.operationId === "runQuery")!;
99 expect(runQuery.servers.map((s) => s.url)).toEqual([
100 "https://{tenant}.{region}.api.example.com",
101 ]);
102 const vars = Option.getOrThrow(runQuery.servers[0]!.variables);
103 expect(vars.tenant?.default).toBe("default-tenant");
104 expect(vars.region?.default).toBe("us-east-1");
105
106 // The host resolves per call: defaults, or call-supplied overrides.
107 const server = runQuery.servers[0]!;
108 expect(resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {})).toBe(
109 "https://default-tenant.us-east-1.api.example.com",
110 );
111 expect(
112 resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {
113 tenant: "acme",
114 region: "eu-west-1",
115 }),
116 ).toBe("https://acme.eu-west-1.api.example.com");
117 }),
118 );
119});
120
121// ---------------------------------------------------------------------------
122// Preview surfaces each top-level server with its own variables
123// ---------------------------------------------------------------------------
124
125describe("previewSpec — server variables", () => {
126 it.effect("carries variables per top-level server, excluding operation overrides", () =>
127 Effect.gen(function* () {
128 const spec = {
129 openapi: "3.0.0",
130 info: { title: "Example", version: "1.0" },
131 servers: [
132 { url: "https://api.example.com", description: "Control plane" },
133 {
134 url: "https://{branch}.{region}.example.com",
135 variables: { branch: { default: "main" }, region: { default: "us-east-1" } },
136 },
137 ],
138 paths: {
139 "/query": {
140 servers: [
141 {
142 url: "https://{tenant}.gw.example.com",
143 variables: { tenant: { default: "default-tenant" } },
144 },
145 ],
146 post: { operationId: "runQuery", responses: { "200": { description: "ok" } } },
147 },
148 },
149 };
150 // @effect-diagnostics-next-line preferSchemaOverJson:off
151 const preview = yield* previewSpec(JSON.stringify(spec));
152
153 // The plain control-plane server has no variables.
154 const control = preview.servers.find((s) => s.url === "https://api.example.com")!;
155 expect(Option.getOrNull(control.variables)).toBeNull();
156
157 // The gateway server carries only its own branch/region.
158 const gateway = preview.servers.find((s) => s.url.startsWith("https://{branch}"))!;
159 expect(Object.keys(Option.getOrThrow(gateway.variables))).toEqual(["branch", "region"]);
160
161 // The operation override's `tenant` never appears in the top-level servers.
162 const names = preview.servers.flatMap((s) =>
163 Object.keys(Option.getOrElse(s.variables, () => ({}))),
164 );
165 expect(names).not.toContain("tenant");
166 }),
167 );
168});
169
170// ---------------------------------------------------------------------------
171// The `server` input property exposes per-call host selection + variables
172// ---------------------------------------------------------------------------
173
174type ServerInputSchema = {
175 readonly required?: readonly string[];
176 readonly properties: {
177 readonly server?: {
178 readonly properties: {
179 readonly url?: { readonly enum: readonly string[]; readonly default: string };
180 readonly variables?: {
181 readonly properties: Record<
182 string,
183 { readonly default?: string; readonly enum?: readonly string[] }
184 >;
185 };
186 };
187 };
188 };
189};
190
191describe("buildInputSchema — server property", () => {
192 it.effect("exposes a server picker and variables for multiple/templated servers", () =>
193 Effect.gen(function* () {
194 const spec = {
195 openapi: "3.0.0",
196 info: { title: "Example", version: "1.0" },
197 servers: [
198 { url: "https://api.example.com" },
199 {
200 url: "https://{branch}.{region}.example.com",
201 variables: {
202 branch: { default: "main" },
203 region: { default: "us", enum: ["us", "eu"] },
204 },
205 },
206 ],
207 paths: {
208 "/items": {
209 get: { operationId: "listItems", responses: { "200": { description: "ok" } } },
210 },
211 },
212 };
213 // @effect-diagnostics-next-line preferSchemaOverJson:off
214 const result = yield* extract(yield* parse(JSON.stringify(spec)));
215 const op = result.operations.find((o) => o.operationId === "listItems")!;
216 const schema = Option.getOrThrow(op.inputSchema) as ServerInputSchema;
217
218 expect(schema.properties.server?.properties.url?.enum).toEqual([
219 "https://api.example.com",
220 "https://{branch}.{region}.example.com",
221 ]);
222 const vars = schema.properties.server?.properties.variables?.properties;
223 expect(vars?.branch?.default).toBe("main");
224 expect(vars?.region?.enum).toEqual(["us", "eu"]);
225 // The host stays optional — defaults apply when the call omits `server`.
226 expect(schema.required ?? []).not.toContain("server");
227 }),
228 );
229
230 it.effect("omits the server property for a single concrete server", () =>
231 Effect.gen(function* () {
232 const spec = {
233 openapi: "3.0.0",
234 info: { title: "Example", version: "1.0" },
235 servers: [{ url: "https://api.example.com" }],
236 paths: {
237 "/items/{id}": {
238 get: {
239 operationId: "getItem",
240 parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
241 responses: { "200": { description: "ok" } },
242 },
243 },
244 },
245 };
246 // @effect-diagnostics-next-line preferSchemaOverJson:off
247 const result = yield* extract(yield* parse(JSON.stringify(spec)));
248 const op = result.operations.find((o) => o.operationId === "getItem")!;
249 const schema = Option.getOrThrow(op.inputSchema) as ServerInputSchema;
250 expect(schema.properties.server).toBeUndefined();
251 }),
252 );
253});
packages/plugins/openapi/src/sdk/types.ts+16−16
@@ -135,11 +135,25 @@ export const OperationRequestBody = Schema.Struct({ · 134 unmodified lines
135});135});
136export type OperationRequestBody = typeof OperationRequestBody.Type;136export type OperationRequestBody = typeof OperationRequestBody.Type;
137 137
138export const ServerVariable = Schema.Struct({
139 default: Schema.String,
140 enum: Schema.OptionFromOptional(Schema.Array(Schema.String)),
141 description: Schema.OptionFromOptional(Schema.String),
142});
143export type ServerVariable = typeof ServerVariable.Type;
144
145export const ServerInfo = Schema.Struct({
146 url: Schema.String,
147 description: Schema.OptionFromOptional(Schema.String),
148 variables: Schema.OptionFromOptional(Schema.Record(Schema.String, ServerVariable)),
149});
150export type ServerInfo = typeof ServerInfo.Type;
151
138export const ExtractedOperation = Schema.Struct({152export const ExtractedOperation = Schema.Struct({
139 operationId: OperationId,153 operationId: OperationId,
140 toolPath: Schema.OptionFromOptional(Schema.String),154 toolPath: Schema.OptionFromOptional(Schema.String),
141 method: HttpMethod,155 method: HttpMethod,
142 baseUrl: Schema.optional(Schema.String),156 servers: Schema.Array(ServerInfo),
143 pathTemplate: Schema.String,157 pathTemplate: Schema.String,
144 summary: Schema.OptionFromOptional(Schema.String),158 summary: Schema.OptionFromOptional(Schema.String),
145 description: Schema.OptionFromOptional(Schema.String),159 description: Schema.OptionFromOptional(Schema.String),
@@ -152,20 +166,6 @@ export const ExtractedOperation = Schema.Struct({ · 6 unmodified lines
152});166});
153export type ExtractedOperation = typeof ExtractedOperation.Type;167export type ExtractedOperation = typeof ExtractedOperation.Type;
154 168
155export const ServerVariable = Schema.Struct({
156 default: Schema.String,
157 enum: Schema.OptionFromOptional(Schema.Array(Schema.String)),
158 description: Schema.OptionFromOptional(Schema.String),
159});
160export type ServerVariable = typeof ServerVariable.Type;
161
162export const ServerInfo = Schema.Struct({
163 url: Schema.String,
164 description: Schema.OptionFromOptional(Schema.String),
165 variables: Schema.OptionFromOptional(Schema.Record(Schema.String, ServerVariable)),
166});
167export type ServerInfo = typeof ServerInfo.Type;
168
169export const ExtractionResult = Schema.Struct({169export const ExtractionResult = Schema.Struct({
170 title: Schema.OptionFromOptional(Schema.String),170 title: Schema.OptionFromOptional(Schema.String),
171 version: Schema.OptionFromOptional(Schema.String),171 version: Schema.OptionFromOptional(Schema.String),
@@ -180,7 +180,7 @@ export type ExtractionResult = typeof ExtractionResult.Type; · 8 unmodified lines
180 180
181export const OperationBinding = Schema.Struct({181export const OperationBinding = Schema.Struct({
182 method: HttpMethod,182 method: HttpMethod,
183 baseUrl: Schema.optional(Schema.String),183 servers: Schema.optional(Schema.Array(ServerInfo)),
184 pathTemplate: Schema.String,184 pathTemplate: Schema.String,
185 parameters: Schema.Array(OperationParameter),185 parameters: Schema.Array(OperationParameter),
186 requestBody: Schema.OptionFromOptional(OperationRequestBody),186 requestBody: Schema.OptionFromOptional(OperationRequestBody),