* 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 | type GoogleOpenApiPreset, | 45 | type GoogleOpenApiPreset, |
| 46 | } from "../sdk/google-presets"; | 46 | } from "../sdk/google-presets"; |
| 47 | import type { SpecPreview } from "../sdk/preview"; | 47 | import type { SpecPreview } from "../sdk/preview"; |
| 48 | import { type Authentication, type ServerInfo } from "../sdk/types"; | 48 | import { type Authentication } from "../sdk/types"; |
| 49 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; | 49 | import { resolveServerUrl } from "../sdk/openapi-utils"; |
| 50 | import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth"; | 50 | import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; |
| 51 | 51 | ||
| 52 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | ||
| 53 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | 52 | const 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 | ||
| 106 | const expandServerOptions = (server: ServerInfo) => | ||
| 107 | expandServerUrlOptions(server).map((value) => ({ value, label: value })); | ||
| 108 | |||
| 109 | // --------------------------------------------------------------------------- | 105 | // --------------------------------------------------------------------------- |
| 110 | // Component — single progressive form. Post-redesign: preview → addSpec | 106 | // 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 analysis | 133 | // 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 = isGoogleBundlePreset | 136 | const identityFallbackName = isGoogleBundlePreset |
| 141 | ? "Google" | 137 | ? "Google" |
| 142 | : preview | 138 | : 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 = isGoogleBundlePreset | 281 | const hasPreviewOrBundle = isGoogleBundlePreset |
| 276 | ? bundleDiscoveryUrls.length > 0 | 282 | ? 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 | <OpenApiSourceDetailsFields | 442 | <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 | ||
| @@ -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 | <FreeformCombobox | 67 | <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 | <Input | 76 | <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"> |
| @@ -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)), |
| @@ -11,7 +11,7 @@ import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/sha · 10 unmodified lines | |||
| 11 | 11 | ||
| 12 | import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview"; | 12 | import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview"; |
| 13 | import type { APIKeyAuthentication, Authentication } from "./types"; | 13 | import type { APIKeyAuthentication, Authentication } from "./types"; |
| 14 | import { expandServerUrlOptions } from "./openapi-utils"; | 14 | import { resolveServerUrl } from "./openapi-utils"; |
| 15 | 15 | ||
| 16 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| 17 | // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve | 17 | // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve |
| @@ -134,7 +134,9 @@ export const detectedAuthenticationTemplates = ( · 116 unmodified lines | |||
| 134 | 134 | ||
| 135 | export const firstBaseUrlForPreview = (preview: SpecPreview): string => { | 135 | export 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: every | 142 | /** The fallback `addSpec` uses when no explicit template was passed: every |
| @@ -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 builder | 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 | const buildInputSchema = ( | 221 | const 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 | ||
| 284 | const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers); | 338 | const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers); |
| 285 | 339 | ||
| 286 | const extractOperationBaseUrl = ( | 340 | const 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 | }); |
| @@ -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([ |
| @@ -40,7 +40,7 @@ export { · 39 unmodified lines | |||
| 40 | } from "./preview"; | 40 | } from "./preview"; |
| 41 | export { | 41 | export { |
| 42 | DocResolver, | 42 | DocResolver, |
| 43 | resolveBaseUrl, | 43 | resolveServerUrl, |
| 44 | substituteUrlVariables, | 44 | substituteUrlVariables, |
| 45 | preferredContent, | 45 | preferredContent, |
| 46 | } from "./openapi-utils"; | 46 | } from "./openapi-utils"; |
| @@ -2,12 +2,14 @@ import { Effect, Layer, Option } from "effect"; · 1 unmodified line | |||
| 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 3 | 3 | ||
| 4 | import { OpenApiInvocationError } from "./errors"; | 4 | import { OpenApiInvocationError } from "./errors"; |
| 5 | import { resolveServerUrl } from "./openapi-utils"; | ||
| 5 | import { | 6 | import { |
| 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). | ||
| 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 | export const invokeWithLayer = ( | 607 | export 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 = effectiveBaseUrl | 616 | const clientWithBaseUrl = effectiveBaseUrl |
| 590 | ? Layer.effect( | 617 | ? Layer.effect( |
| 591 | HttpClient.HttpClient, | 618 | HttpClient.HttpClient, |
| @@ -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 | ||
| 7 | import { Option } from "effect"; | ||
| 8 | import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; | 7 | import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; |
| 9 | import type { ParsedDocument } from "./parse"; | 8 | import type { ParsedDocument } from "./parse"; |
| 10 | import type { ServerVariable } from "./types"; | 9 | import 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 | ||
| 68 | export 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 | |
| 70 | type ServerLike = { | 69 | * pass through unchanged. */ |
| 71 | url: string; | 70 | export 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>, | |
| 75 | export 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 | |||
| 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 | |||
| 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 | // --------------------------------------------------------------------------- |
| @@ -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, or | 568 | // 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 JSON | 570 | // 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 | ); |
| @@ -481,7 +481,7 @@ const normalizeOpenApiRefs = (node: unknown): unknown => { · 480 unmodified lines | |||
| 481 | const toBinding = (def: ToolDefinition): OperationBinding => | 481 | const 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 so | 713 | // Defaults the add page derives from its preview, applied here so |
| 714 | // headless callers (MCP, API) get the same integration the UI's | 714 | // 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 to | 716 | // - 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 schemes | 720 | // - 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) !== undefined | 760 | ...(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 the | 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 | import { createServer, type Server } from "node:http"; | 4 | import { createServer, type Server } from "node:http"; |
| 5 | 5 | ||
| 6 | import { invokeWithLayer } from "./invoke"; | 6 | import { invokeWithLayer } from "./invoke"; |
| 7 | import { OperationBinding, OperationParameter } from "./types"; | 7 | import { OperationBinding, OperationParameter, ServerInfo } from "./types"; |
| 8 | 8 | ||
| 9 | const withServer = <A>( | 9 | const 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 | |||
| 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 | export type OperationRequestBody = typeof OperationRequestBody.Type; | 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 | export const ExtractedOperation = Schema.Struct({ | 152 | export 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 | }); |
| 153 | export type ExtractedOperation = typeof ExtractedOperation.Type; | 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 | export const ExtractionResult = Schema.Struct({ | 169 | export 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 | ||
| 181 | export const OperationBinding = Schema.Struct({ | 181 | export 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), |