* 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. ---------
Unified · Split
| @@ -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). | |
| 7 | +import { expect } from "@effect/vitest"; | |
| 8 | +import { Effect } from "effect"; | |
| 9 | + | |
| 10 | +import { scenario } from "../src/scenario"; | |
| 11 | +import { 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. | |
| 15 | +const 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 | + | |
| 27 | +scenario( | |
| 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 | +); | |
| @@ -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 | +); | |
| @@ -45,11 +45,10 @@ import { · 44 unmodified lines | ||
| 45 | 45 | type GoogleOpenApiPreset, |
| 46 | 46 | } from "../sdk/google-presets"; |
| 47 | 47 | import type { SpecPreview } from "../sdk/preview"; |
| 48 | −import { type Authentication, type ServerInfo } from "../sdk/types"; | |
| 49 | −import { expandServerUrlOptions } from "../sdk/openapi-utils"; | |
| 50 | −import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth"; | |
| 48 | +import { type Authentication } from "../sdk/types"; | |
| 49 | +import { resolveServerUrl } from "../sdk/openapi-utils"; | |
| 50 | +import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; | |
| 51 | 51 | |
| 52 | −const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | |
| 53 | 52 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; |
| 54 | 53 | |
| 55 | 54 | // The bundle picker opens with the featured Google APIs pre-checked. |
| @@ -103,9 +102,6 @@ const specInputForAdd = (input: string) => { · 47 unmodified lines | ||
| 103 | 102 | : { kind: "blob" as const, value }; |
| 104 | 103 | }; |
| 105 | 104 | |
| 106 | −const expandServerOptions = (server: ServerInfo) => | |
| 107 | − expandServerUrlOptions(server).map((value) => ({ value, label: value })); | |
| 108 | − | |
| 109 | 105 | // --------------------------------------------------------------------------- |
| 110 | 106 | // Component — single progressive form. Post-redesign: preview → addSpec |
| 111 | 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 | 133 | // After analysis |
| 138 | 134 | const [preview, setPreview] = useState<SpecPreview | null>(null); |
| 139 | − const [baseUrl, setBaseUrl] = useState(isGoogleBundlePreset ? GOOGLE_BUNDLE_BASE_URL : ""); | |
| 135 | + const [baseUrl, setBaseUrl] = useState(""); | |
| 140 | 136 | const identityFallbackName = isGoogleBundlePreset |
| 141 | 137 | ? "Google" |
| 142 | 138 | : preview |
| @@ -198,10 +194,20 @@ export default function AddOpenApiSource(props: { · 55 unmodified lines | ||
| 198 | 194 | |
| 199 | 195 | // ---- Derived state ---- |
| 200 | 196 | |
| 201 | − const servers: readonly ServerInfo[] = preview?.servers ?? []; | |
| 202 | − const baseUrlOptions = Array.from( | |
| 203 | − new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), | |
| 204 | − ); | |
| 197 | + const previewHasNoServers = preview !== null && preview.servers.length === 0; | |
| 198 | + // Offer the spec's servers (resolved with defaults) as base-URL choices when | |
| 199 | + // there's more than one; a single/no server uses a plain input. | |
| 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 | 211 | const previewPresetIcon = |
| 206 | 212 | openApiPresets.find( |
| 207 | 213 | (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), |
| @@ -275,7 +281,12 @@ export default function AddOpenApiSource(props: { · 67 unmodified lines | ||
| 275 | 281 | const hasPreviewOrBundle = isGoogleBundlePreset |
| 276 | 282 | ? bundleDiscoveryUrls.length > 0 |
| 277 | 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 | 291 | // ---- Handlers ---- |
| 281 | 292 | |
| @@ -291,7 +302,7 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines | ||
| 291 | 302 | } |
| 292 | 303 | const result = exit.value; |
| 293 | 304 | setPreview(result); |
| 294 | − setBaseUrl(firstBaseUrlForPreview(result)); | |
| 305 | + setBaseUrl(""); | |
| 295 | 306 | setAnalyzing(false); |
| 296 | 307 | }; |
| 297 | 308 | |
| @@ -423,9 +434,9 @@ export default function AddOpenApiSource(props: { · 125 unmodified lines | ||
| 423 | 434 | identity={identity} |
| 424 | 435 | baseUrl={resolvedBaseUrl} |
| 425 | 436 | onBaseUrlChange={setBaseUrl} |
| 437 | + baseUrlLabel="Base URL override (optional)" | |
| 426 | 438 | faviconIcon={GOOGLE_BUNDLE_FAVICON} |
| 427 | 439 | faviconUrl={resolvedBaseUrl} |
| 428 | − baseUrlMissingMessage="A base URL is required to make requests." | |
| 429 | 440 | /> |
| 430 | 441 | ) : preview ? ( |
| 431 | 442 | <OpenApiSourceDetailsFields |
| @@ -441,6 +452,16 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines | ||
| 441 | 452 | baseUrl={resolvedBaseUrl} |
| 442 | 453 | onBaseUrlChange={setBaseUrl} |
| 443 | 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 | 465 | specUrl={specUrl} |
| 445 | 466 | onSpecUrlChange={(value) => { |
| 446 | 467 | setSpecUrl(value); |
| @@ -448,8 +469,7 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 448 | 469 | setBaseUrl(""); |
| 449 | 470 | }} |
| 450 | 471 | faviconIcon={previewPresetIcon} |
| 451 | − faviconUrl={resolvedBaseUrl} | |
| 452 | − baseUrlMissingMessage="A base URL is required to make requests." | |
| 472 | + faviconUrl={resolvedBaseUrl || firstServerUrl} | |
| 453 | 473 | /> |
| 454 | 474 | ) : null} |
| 455 | 475 | |
| @@ -25,6 +25,9 @@ export function OpenApiSourceDetailsFields(props: { · 24 unmodified lines | ||
| 25 | 25 | readonly baseUrl: string; |
| 26 | 26 | readonly onBaseUrlChange: (value: string) => void; |
| 27 | 27 | readonly baseUrlOptions?: readonly FreeformComboboxOption[]; |
| 28 | + readonly baseUrlLabel?: string; | |
| 29 | + readonly baseUrlPlaceholder?: string; | |
| 30 | + readonly baseUrlHint?: string; | |
| 28 | 31 | readonly specUrl?: string; |
| 29 | 32 | readonly onSpecUrlChange?: (value: string) => void; |
| 30 | 33 | readonly faviconIcon?: string | null; |
| @@ -35,8 +38,6 @@ export function OpenApiSourceDetailsFields(props: { · 4 unmodified lines | ||
| 35 | 38 | readonly baseUrlMissingMessage?: string; |
| 36 | 39 | readonly footer?: string; |
| 37 | 40 | }) { |
| 38 | − const baseUrlOptions = props.baseUrlOptions ?? []; | |
| 39 | − | |
| 40 | 41 | return ( |
| 41 | 42 | <CardStack> |
| 42 | 43 | <CardStackContent className="border-t-0"> |
| @@ -61,13 +62,13 @@ export function OpenApiSourceDetailsFields(props: { · 18 unmodified lines | ||
| 61 | 62 | namespaceReadOnly={props.namespaceReadOnly} |
| 62 | 63 | /> |
| 63 | 64 | <div className="grid grid-cols-1 md:grid-cols-2"> |
| 64 | − <CardStackEntryField label="Base URL"> | |
| 65 | − {baseUrlOptions.length > 0 ? ( | |
| 65 | + <CardStackEntryField label={props.baseUrlLabel ?? "Base URL"}> | |
| 66 | + {props.baseUrlOptions && props.baseUrlOptions.length > 0 ? ( | |
| 66 | 67 | <FreeformCombobox |
| 67 | 68 | value={props.baseUrl} |
| 68 | 69 | onValueChange={props.onBaseUrlChange} |
| 69 | − options={baseUrlOptions} | |
| 70 | − placeholder="https://api.example.com" | |
| 70 | + options={props.baseUrlOptions} | |
| 71 | + placeholder={props.baseUrlPlaceholder ?? "https://api.example.com"} | |
| 71 | 72 | className="w-full" |
| 72 | 73 | inputClassName="font-mono text-sm" |
| 73 | 74 | /> |
| @@ -75,7 +76,7 @@ export function OpenApiSourceDetailsFields(props: { · 1 unmodified line | ||
| 75 | 76 | <Input |
| 76 | 77 | value={props.baseUrl} |
| 77 | 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 | 80 | className="font-mono text-sm" |
| 80 | 81 | /> |
| 81 | 82 | )} |
| @@ -85,6 +86,9 @@ export function OpenApiSourceDetailsFields(props: { · 3 unmodified lines | ||
| 85 | 86 | {props.baseUrlMissingMessage} |
| 86 | 87 | </p> |
| 87 | 88 | )} |
| 89 | + {props.baseUrlHint && ( | |
| 90 | + <p className="text-[11px] text-muted-foreground">{props.baseUrlHint}</p> | |
| 91 | + )} | |
| 88 | 92 | </CardStackEntryField> |
| 89 | 93 | {props.specUrl !== undefined && props.onSpecUrlChange && ( |
| 90 | 94 | <CardStackEntryField label="Spec URL"> |
| @@ -45,7 +45,7 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 44 unmodified lines | ||
| 45 | 45 | sourceUrl: Schema.optional(Schema.String), |
| 46 | 46 | /** Google Discovery bundle URLs, when the spec came from a Google bundle. */ |
| 47 | 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 | 49 | baseUrl: Schema.optional(Schema.String), |
| 50 | 50 | /** Static headers applied to every request (no secret material). */ |
| 51 | 51 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -11,7 +11,7 @@ import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/sha · 10 unmodified lines | ||
| 11 | 11 | |
| 12 | 12 | import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview"; |
| 13 | 13 | import type { APIKeyAuthentication, Authentication } from "./types"; |
| 14 | −import { expandServerUrlOptions } from "./openapi-utils"; | |
| 14 | +import { resolveServerUrl } from "./openapi-utils"; | |
| 15 | 15 | |
| 16 | 16 | // --------------------------------------------------------------------------- |
| 17 | 17 | // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve |
| @@ -134,7 +134,9 @@ export const detectedAuthenticationTemplates = ( · 116 unmodified lines | ||
| 134 | 134 | |
| 135 | 135 | export const firstBaseUrlForPreview = (preview: SpecPreview): string => { |
| 136 | 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 | 142 | /** The fallback `addSpec` uses when no explicit template was passed: every |
| @@ -6,7 +6,6 @@ import { · 5 unmodified lines | ||
| 6 | 6 | declaredContents, |
| 7 | 7 | DocResolver, |
| 8 | 8 | preferredResponseContent, |
| 9 | − resolveBaseUrl, | |
| 10 | 9 | type OperationObject, |
| 11 | 10 | type ParameterObject, |
| 12 | 11 | type PathItemObject, |
| @@ -169,9 +168,60 @@ const extractOutputSchema = (operation: OperationObject, r: DocResolver): unknow · 156 unmodified lines | ||
| 169 | 168 | // 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). | |
| 173 | +const 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 | + | |
| 172 | 221 | const buildInputSchema = ( |
| 173 | 222 | parameters: readonly OperationParameter[], |
| 174 | 223 | requestBody: OperationRequestBody | undefined, |
| 224 | + servers: readonly ServerInfo[], | |
| 175 | 225 | ): Record<string, unknown> | undefined => { |
| 176 | 226 | const properties: Record<string, unknown> = {}; |
| 177 | 227 | const required: string[] = []; |
| @@ -181,6 +231,10 @@ const buildInputSchema = ( · 3 unmodified lines | ||
| 181 | 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 | 238 | if (requestBody) { |
| 185 | 239 | properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" })); |
| 186 | 240 | if (requestBody.required) required.push("body"); |
| @@ -283,17 +337,16 @@ const extractServerList = (servers: readonly ServerObject[] | undefined): Server · 96 unmodified lines | ||
| 283 | 337 | |
| 284 | 338 | const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers); |
| 285 | 339 | |
| 286 | −const extractOperationBaseUrl = ( | |
| 340 | +const operationServers = ( | |
| 287 | 341 | pathItem: PathItemObject, |
| 288 | 342 | operation: OperationObject, |
| 289 | −): string | undefined => { | |
| 290 | − const operationServers = extractServerList(operation.servers); | |
| 291 | − if (operationServers.length > 0) return resolveBaseUrl(operationServers); | |
| 292 | − | |
| 293 | − const pathServers = extractServerList(pathItem.servers); | |
| 294 | − if (pathServers.length > 0) return resolveBaseUrl(pathServers); | |
| 295 | − | |
| 296 | − return undefined; | |
| 343 | + docServers: readonly ServerInfo[], | |
| 344 | +): readonly ServerInfo[] => { | |
| 345 | + const operationLevel = extractServerList(operation.servers); | |
| 346 | + if (operationLevel.length > 0) return operationLevel; | |
| 347 | + const pathLevel = extractServerList(pathItem.servers); | |
| 348 | + if (pathLevel.length > 0) return pathLevel; | |
| 349 | + return docServers; | |
| 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 | 365 | const r = new DocResolver(doc); |
| 366 | + const docServers = extractServers(doc); | |
| 313 | 367 | const operations: ExtractedOperation[] = []; |
| 314 | 368 | |
| 315 | 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 | 378 | const parameters = extractParameters(pathItem, operation, r); |
| 325 | 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 | 382 | const outputSchema = extractOutputSchema(operation, r); |
| 328 | 383 | const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0); |
| 329 | 384 | const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate; |
| @@ -333,7 +388,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 3 unmodified lines | ||
| 333 | 388 | operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)), |
| 334 | 389 | toolPath: Option.fromNullishOr(explicitToolPath(operation)), |
| 335 | 390 | method, |
| 336 | − baseUrl: extractOperationBaseUrl(pathItem, operation), | |
| 391 | + servers, | |
| 337 | 392 | pathTemplate: operationPathTemplate, |
| 338 | 393 | summary: Option.fromNullishOr(operation.summary), |
| 339 | 394 | description: Option.fromNullishOr(operation.description), |
| @@ -351,7 +406,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 11 unmodified lines | ||
| 351 | 406 | return ExtractionResult.make({ |
| 352 | 407 | title: Option.fromNullishOr(doc.info?.title), |
| 353 | 408 | version: Option.fromNullishOr(doc.info?.version), |
| 354 | − servers: extractServers(doc), | |
| 409 | + servers: docServers, | |
| 355 | 410 | operations, |
| 356 | 411 | }); |
| 357 | 412 | }); |
| @@ -436,9 +436,9 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( · 435 unmodified lines | ||
| 436 | 436 | const extractedChatMessage = extracted.operations.find( |
| 437 | 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 | 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 | 442 | // v2: the bundled oauth scopes are carried on the oauth auth template. |
| 443 | 443 | const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); |
| 444 | 444 | expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ |
| @@ -40,7 +40,7 @@ export { · 39 unmodified lines | ||
| 40 | 40 | } from "./preview"; |
| 41 | 41 | export { |
| 42 | 42 | DocResolver, |
| 43 | − resolveBaseUrl, | |
| 43 | + resolveServerUrl, | |
| 44 | 44 | substituteUrlVariables, |
| 45 | 45 | preferredContent, |
| 46 | 46 | } from "./openapi-utils"; |
| @@ -2,12 +2,14 @@ import { Effect, Layer, Option } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 3 | 3 | |
| 4 | 4 | import { OpenApiInvocationError } from "./errors"; |
| 5 | +import { resolveServerUrl } from "./openapi-utils"; | |
| 5 | 6 | import { |
| 6 | 7 | type EncodingObject, |
| 7 | 8 | type OperationBinding, |
| 8 | 9 | InvocationResult, |
| 9 | 10 | type MediaBinding, |
| 10 | 11 | type OperationParameter, |
| 12 | + type ServerInfo, | |
| 11 | 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). | |
| 579 | +const 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 prefix | |
| 604 | +// Invoke with a provided HttpClient layer + per-call host resolution | |
| 577 | 605 | // --------------------------------------------------------------------------- |
| 578 | 606 | |
| 579 | 607 | export const invokeWithLayer = ( |
| @@ -584,8 +612,7 @@ export const invokeWithLayer = ( · 4 unmodified lines | ||
| 584 | 612 | sourceQueryParams: Record<string, string>, |
| 585 | 613 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 586 | 614 | ) => { |
| 587 | − const operationBaseUrl = operation.baseUrl; | |
| 588 | − const effectiveBaseUrl = operationBaseUrl ?? baseUrl; | |
| 615 | + const effectiveBaseUrl = resolveRequestHost(operation.servers ?? [], args.server, baseUrl); | |
| 589 | 616 | const clientWithBaseUrl = effectiveBaseUrl |
| 590 | 617 | ? Layer.effect( |
| 591 | 618 | HttpClient.HttpClient, |
| @@ -4,7 +4,6 @@ · 3 unmodified lines | ||
| 4 | 4 | // Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution. |
| 5 | 5 | // --------------------------------------------------------------------------- |
| 6 | 6 | |
| 7 | −import { Option } from "effect"; | |
| 8 | 7 | import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; |
| 9 | 8 | import type { ParsedDocument } from "./parse"; |
| 10 | 9 | import type { ServerVariable } from "./types"; |
| @@ -65,49 +64,20 @@ export const substituteUrlVariables = (url: string, values: Record<string, strin · 54 unmodified lines | ||
| 65 | 64 | return out; |
| 66 | 65 | }; |
| 67 | 66 | |
| 68 | −export const OPENAPI_MAX_SERVER_VARIABLE_OPTIONS = 64; | |
| 69 | − | |
| 70 | −type ServerLike = { | |
| 71 | − url: string; | |
| 72 | − variables: import("effect/Option").Option<Record<string, ServerVariable | string>>; | |
| 73 | −}; | |
| 74 | − | |
| 75 | −export const expandServerUrlOptions = ( | |
| 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 | − | |
| 100 | −export 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 | − | |
| 67 | +/** Resolve a templated server URL, filling each `{var}` from `overrides` when | |
| 68 | + * non-empty, otherwise the variable's spec default. URLs without placeholders | |
| 69 | + * pass through unchanged. */ | |
| 70 | +export const resolveServerUrl = ( | |
| 71 | + templateUrl: string, | |
| 72 | + variables: Record<string, ServerVariable> | undefined, | |
| 73 | + overrides: Record<string, string>, | |
| 74 | +): string => { | |
| 106 | 75 | const values: Record<string, string> = {}; |
| 107 | − for (const [name, v] of Object.entries(server.variables.value)) { | |
| 108 | − values[name] = typeof v === "string" ? v : v.default; | |
| 76 | + for (const [name, v] of Object.entries(variables ?? {})) values[name] = 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 | // --------------------------------------------------------------------------- |
| @@ -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 | 563 | Effect.scoped( |
| 564 | 564 | Effect.gen(function* () { |
| 565 | 565 | const server = yield* servePluginTestApi(); |
| 566 | 566 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); |
| 567 | 567 | |
| 568 | − // No baseUrl passed — the spec's own servers entry must fill it, or | |
| 569 | − // every invocation on the integration fails with no host to call. | |
| 570 | − // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON | |
| 571 | − const spec = JSON.parse(server.specJson) as Record<string, unknown>; | |
| 572 | − const specWithServer = JSON.stringify({ | |
| 573 | − ...spec, | |
| 574 | − servers: [{ url: server.baseUrl }], | |
| 568 | + // No baseUrl override: the spec declares `servers`, so the host is | |
| 569 | + // resolved per call from the operation's servers rather than baked into | |
| 570 | + // the connection config. `baseUrl: null` suppresses the test helper's | |
| 571 | + // default connection-level override. | |
| 572 | + const conn = yield* addOpenApiTestConnection(executor, server, { | |
| 573 | + slug: "per_call_host", | |
| 574 | + baseUrl: null, | |
| 575 | 575 | }); |
| 576 | 576 | |
| 577 | − yield* executor.openapi.addSpec({ | |
| 578 | − spec: { kind: "blob", value: specWithServer }, | |
| 579 | − slug: "derived_base_api", | |
| 580 | − }); | |
| 577 | + // The override is absent… | |
| 578 | + const config = yield* executor.openapi.getConfig("per_call_host"); | |
| 579 | + expect(config?.baseUrl).toBeUndefined(); | |
| 581 | 580 | |
| 582 | − const config = yield* executor.openapi.getConfig("derived_base_api"); | |
| 583 | − expect(config?.baseUrl).toBe(server.baseUrl); | |
| 581 | + // …yet the integration is still invocable: the request reaches the | |
| 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 | ); |
| @@ -481,7 +481,7 @@ const normalizeOpenApiRefs = (node: unknown): unknown => { · 480 unmodified lines | ||
| 481 | 481 | const toBinding = (def: ToolDefinition): OperationBinding => |
| 482 | 482 | OperationBinding.make({ |
| 483 | 483 | method: def.operation.method, |
| 484 | − baseUrl: def.operation.baseUrl, | |
| 484 | + servers: def.operation.servers, | |
| 485 | 485 | pathTemplate: def.operation.pathTemplate, |
| 486 | 486 | parameters: [...def.operation.parameters], |
| 487 | 487 | requestBody: def.operation.requestBody, |
| @@ -713,8 +713,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 225 unmodified lines | ||
| 713 | 713 | // Defaults the add page derives from its preview, applied here so |
| 714 | 714 | // headless callers (MCP, API) get the same integration the UI's |
| 715 | 715 | // add flow would produce — see e2e/scenarios/connect-handoff.test.ts: |
| 716 | − // - baseUrl: the spec's first server (else tools have no host to | |
| 717 | − // call and every invocation fails with "HTTP request failed") | |
| 716 | + // - effectiveBaseUrl: the spec's first server, used to anchor the | |
| 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 | 720 | // - authenticationTemplate: the spec's declared security schemes |
| 719 | 721 | // (else the Add-connection modal is a dead "No authentication" |
| 720 | 722 | // end with nowhere to paste a credential) |
| @@ -758,7 +760,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 37 unmodified lines | ||
| 758 | 760 | ...(specInputToGoogleBundle(config.spec) !== undefined |
| 759 | 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 | 767 | ...(config.headers ? { headers: config.headers } : {}), |
| 763 | 768 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 764 | 769 | // Prefer the caller's explicit template; otherwise adopt the one the |
| @@ -4,7 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"; · 3 unmodified lines | ||
| 4 | 4 | import { createServer, type Server } from "node:http"; |
| 5 | 5 | |
| 6 | 6 | import { invokeWithLayer } from "./invoke"; |
| 7 | −import { OperationBinding, OperationParameter } from "./types"; | |
| 7 | +import { OperationBinding, OperationParameter, ServerInfo } from "./types"; | |
| 8 | 8 | |
| 9 | 9 | const withServer = <A>( |
| 10 | 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 | 37 | withServer(async ({ baseUrl, requests }) => { |
| 38 | 38 | const operation = OperationBinding.make({ |
| 39 | 39 | method: "get", |
| 40 | + servers: [], | |
| 40 | 41 | pathTemplate: "/messages/{id}", |
| 41 | 42 | requestBody: Option.none(), |
| 42 | 43 | parameters: [ |
| @@ -101,7 +102,9 @@ it.effect("uses operation base URL and preserves reserved path expansion when al · 58 unmodified lines | ||
| 101 | 102 | withServer(async ({ baseUrl, requests }) => { |
| 102 | 103 | const operation = OperationBinding.make({ |
| 103 | 104 | method: "get", |
| 104 | − baseUrl, | |
| 105 | + servers: [ | |
| 106 | + ServerInfo.make({ url: baseUrl, description: Option.none(), variables: Option.none() }), | |
| 107 | + ], | |
| 105 | 108 | pathTemplate: "/v1/{+name}", |
| 106 | 109 | requestBody: Option.none(), |
| 107 | 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 | 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 | 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 | + | |
| 144 | +it.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 | + | |
| 173 | +it.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 | + | |
| 200 | +it.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 | +); | |
| @@ -0,0 +1,253 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Option } from "effect"; | |
| 3 | +import { FetchHttpClient } from "effect/unstable/http"; | |
| 4 | + | |
| 5 | +import { extract } from "./extract"; | |
| 6 | +import { resolveServerUrl } from "./openapi-utils"; | |
| 7 | +import { parse } from "./parse"; | |
| 8 | +import { previewSpec as previewSpecRaw } from "./preview"; | |
| 9 | +import { ServerVariable } from "./types"; | |
| 10 | + | |
| 11 | +const previewSpec = (input: string) => | |
| 12 | + previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer)); | |
| 13 | + | |
| 14 | +const 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 | + | |
| 21 | +describe("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. | |
| 60 | +const 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 | + | |
| 80 | +describe("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 | + | |
| 125 | +describe("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 | + | |
| 174 | +type 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 | + | |
| 191 | +describe("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 | +}); | |
| @@ -135,11 +135,25 @@ export const OperationRequestBody = Schema.Struct({ · 134 unmodified lines | ||
| 135 | 135 | }); |
| 136 | 136 | export type OperationRequestBody = typeof OperationRequestBody.Type; |
| 137 | 137 | |
| 138 | +export const ServerVariable = Schema.Struct({ | |
| 139 | + default: Schema.String, | |
| 140 | + enum: Schema.OptionFromOptional(Schema.Array(Schema.String)), | |
| 141 | + description: Schema.OptionFromOptional(Schema.String), | |
| 142 | +}); | |
| 143 | +export type ServerVariable = typeof ServerVariable.Type; | |
| 144 | + | |
| 145 | +export const ServerInfo = Schema.Struct({ | |
| 146 | + url: Schema.String, | |
| 147 | + description: Schema.OptionFromOptional(Schema.String), | |
| 148 | + variables: Schema.OptionFromOptional(Schema.Record(Schema.String, ServerVariable)), | |
| 149 | +}); | |
| 150 | +export type ServerInfo = typeof ServerInfo.Type; | |
| 151 | + | |
| 138 | 152 | export const ExtractedOperation = Schema.Struct({ |
| 139 | 153 | operationId: OperationId, |
| 140 | 154 | toolPath: Schema.OptionFromOptional(Schema.String), |
| 141 | 155 | method: HttpMethod, |
| 142 | − baseUrl: Schema.optional(Schema.String), | |
| 156 | + servers: Schema.Array(ServerInfo), | |
| 143 | 157 | pathTemplate: Schema.String, |
| 144 | 158 | summary: Schema.OptionFromOptional(Schema.String), |
| 145 | 159 | description: Schema.OptionFromOptional(Schema.String), |
| @@ -152,20 +166,6 @@ export const ExtractedOperation = Schema.Struct({ · 6 unmodified lines | ||
| 152 | 166 | }); |
| 153 | 167 | export type ExtractedOperation = typeof ExtractedOperation.Type; |
| 154 | 168 | |
| 155 | −export const ServerVariable = Schema.Struct({ | |
| 156 | − default: Schema.String, | |
| 157 | − enum: Schema.OptionFromOptional(Schema.Array(Schema.String)), | |
| 158 | − description: Schema.OptionFromOptional(Schema.String), | |
| 159 | −}); | |
| 160 | −export type ServerVariable = typeof ServerVariable.Type; | |
| 161 | − | |
| 162 | −export const ServerInfo = Schema.Struct({ | |
| 163 | − url: Schema.String, | |
| 164 | − description: Schema.OptionFromOptional(Schema.String), | |
| 165 | − variables: Schema.OptionFromOptional(Schema.Record(Schema.String, ServerVariable)), | |
| 166 | −}); | |
| 167 | −export type ServerInfo = typeof ServerInfo.Type; | |
| 168 | − | |
| 169 | 169 | export const ExtractionResult = Schema.Struct({ |
| 170 | 170 | title: Schema.OptionFromOptional(Schema.String), |
| 171 | 171 | version: Schema.OptionFromOptional(Schema.String), |
| @@ -180,7 +180,7 @@ export type ExtractionResult = typeof ExtractionResult.Type; · 8 unmodified lines | ||
| 180 | 180 | |
| 181 | 181 | export const OperationBinding = Schema.Struct({ |
| 182 | 182 | method: HttpMethod, |
| 183 | − baseUrl: Schema.optional(Schema.String), | |
| 183 | + servers: Schema.optional(Schema.Array(ServerInfo)), | |
| 184 | 184 | pathTemplate: Schema.String, |
| 185 | 185 | parameters: Schema.Array(OperationParameter), |
| 186 | 186 | requestBody: Schema.OptionFromOptional(OperationRequestBody), |