Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions e2e/scenarios/openapi-server-selection-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Cross-target (browser): the UI side of per-call server selection. When a
// pasted spec declares more than one server, the Add OpenAPI source form turns
// the Base URL field into a picker over those servers and relabels it an
// optional override — the host is otherwise resolved per tool call. The session
// video + per-step screenshots are the artifact; this scenario skips on targets
// without a browser surface (selfhost today).
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";

// Two servers — production and staging — so the form offers a base-URL picker
// instead of the single locked input a one-server spec gets.
const multiServerSpec = JSON.stringify({
openapi: "3.0.3",
info: { title: "Regions API", version: "1.0.0" },
servers: [
{ url: "https://api.example.com", description: "Production" },
{ url: "https://staging.example.com", description: "Staging" },
],
paths: {
"/ping": { get: { operationId: "ping", responses: { "200": { description: "pong" } } } },
},
});

scenario(
"OpenAPI · the add form offers a server picker and an optional base URL for a multi-server spec",
{},
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const identity = yield* target.newIdentity();

yield* browser.session(identity, async ({ page, step }) => {
await step("Open the Add OpenAPI source form", async () => {
await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor();
});

await step("Paste a spec that declares two servers", async () => {
await page.getByPlaceholder("https://api.example.com/openapi.json").fill(multiServerSpec);
// The form auto-analyzes (debounced) and renders the preview details,
// where the base URL is now an OPTIONAL override.
await page.getByText("Base URL override (optional)").waitFor({ timeout: 20_000 });
});

await step("The base URL is optional — the server is chosen per call", async () => {
// The hint spells out that leaving it empty defers the host (and its
// variables) to each tool call: the heart of per-call selection.
await page.getByText(/leave empty to choose the server.*per tool call/i).waitFor();
});

await step("The field is a picker over the spec's two servers", async () => {
// Opening the combobox reveals both declared servers as choices.
await page.getByPlaceholder("https://api.example.com", { exact: true }).click();
const options = page.getByRole("option");
await options.filter({ hasText: "staging.example.com" }).waitFor({ timeout: 10_000 });
const labels = await options.allInnerTexts();
expect(
labels.join(" | "),
"both declared servers are offered as base-URL choices",
).toEqual(expect.stringContaining("https://staging.example.com"));
expect(labels.join(" | "), "production is offered too").toEqual(
expect.stringContaining("https://api.example.com"),
);
});
});
}),
),
);
169 changes: 169 additions & 0 deletions e2e/scenarios/openapi-server-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Cross-target: when a spec declares more than one server, per-call server
// selection becomes a first-class tool input. The generated tool advertises an
// optional `server` selector — a `url` enum over the declared servers plus the
// `{variables}` drawn from the templated one — so an agent can choose where each
// call goes. Adding the spec with NO baseUrl also proves the base URL is now an
// optional override: the host is resolved per call from the spec's servers.
//
// Entirely through the typed client: addSpec → connection (via a `from` provider
// reference, so no vault round-trip — works against the cloud stub) → read the
// tool's schema and assert the `server` input this feature introduces.
import { randomBytes, randomUUID } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
import {
AuthTemplateSlug,
ConnectionName,
IntegrationSlug,
ProviderItemId,
} from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Target } from "../src/services";

const api = composePluginApi([openApiHttpPlugin()] as const);

// The operation inherits two document-level servers: a fixed production URL and
// a templated regional sandbox whose `{region}` is a per-call variable.
const PROD_SERVER = "https://api.example.test/v1";
const SANDBOX_SERVER = "https://{region}.sandbox.example.test/v1";

const multiServerSpec = (): string =>
JSON.stringify({
openapi: "3.0.3",
info: { title: "Regions API", version: "1.0.0" },
servers: [
{ url: PROD_SERVER, description: "Production" },
{
url: SANDBOX_SERVER,
description: "Regional sandbox",
variables: {
region: { default: "us", enum: ["us", "eu", "ap"], description: "Sandbox region" },
},
},
],
paths: {
"/ping": {
get: {
operationId: "ping",
summary: "Health check",
responses: { "200": { description: "pong" } },
},
},
},
});

// Minimal structural view of the JSON Schema we assert against.
type ServerInputSchema = {
readonly properties?: {
readonly server?: {
readonly properties?: {
readonly url?: { readonly enum?: readonly unknown[]; readonly default?: unknown };
readonly variables?: {
readonly properties?: Record<
string,
{ readonly enum?: readonly unknown[]; readonly default?: unknown }
>;
};
};
};
};
};

scenario(
"OpenAPI · a multi-server spec advertises a per-call server selector on its tools",
{},
Effect.gen(function* () {
const target = yield* Target;
const { client } = yield* Api;
const identity = yield* target.newIdentity();
const apiClient = yield* client(api, identity);

// Unique slug per run: selfhost shares the bootstrap-admin identity, so the
// prefix keeps parallel/repeated runs out of each other's catalogs.
const slug = `openapi-scn-servers-${randomBytes(4).toString("hex")}`;

yield* Effect.ensuring(
Effect.gen(function* () {
// Add the spec with NO baseUrl — the host is resolved per call from the
// spec's servers, so a base URL is purely an optional override now.
const added = yield* apiClient.openapi.addSpec({
payload: {
spec: { kind: "blob", value: multiServerSpec() },
slug,
authenticationTemplate: [
{
slug: "apiKey",
type: "apiKey",
headers: { "x-api-key": [{ type: "variable", name: "token" }] },
},
],
},
});
expect(added.toolCount, "the spec's operation became a tool").toBeGreaterThan(0);

// The catalog stamps tools once a connection exists; a `from` provider
// reference avoids any vault round-trip.
const providers = yield* apiClient.providers.list();
expect(providers.length, "a credential provider is available").toBeGreaterThan(0);
yield* apiClient.connections.create({
payload: {
owner: "org",
name: ConnectionName.make("main"),
integration: IntegrationSlug.make(slug),
template: AuthTemplateSlug.make("apiKey"),
from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
},
});

// Locate the generated tool and read its full input schema.
const tools = yield* apiClient.tools.list({ query: {} });
const ping = tools.find(
(tool) => String(tool.integration) === slug && String(tool.name).includes("ping"),
);
expect(ping?.address, "the ping tool is in the catalog").toBeDefined();

const view = yield* apiClient.tools.schema({ query: { address: ping!.address } });
const input = view.inputSchema as ServerInputSchema;
const serverInput = input.properties?.server?.properties;

// The per-call `server` input exists: `url` is an enum over BOTH declared
// servers (raw templates), defaulting to the first.
expect(serverInput?.url?.enum, "server.url enumerates the declared servers").toEqual([
PROD_SERVER,
SANDBOX_SERVER,
]);
expect(serverInput?.url?.default, "server.url defaults to the first server").toBe(
PROD_SERVER,
);

// …and the templated server's `{region}` surfaces as a per-call variable
// carrying its spec enum and default.
const region = serverInput?.variables?.properties?.region;
expect(region?.enum, "server.variables.region carries the spec enum").toEqual([
"us",
"eu",
"ap",
]);
expect(region?.default, "server.variables.region keeps the spec default").toBe("us");
}),
// Selfhost shares one bootstrap admin, so this scenario must not leak its
// connection or integration into other scenarios' zero-state assertions.
Effect.gen(function* () {
yield* apiClient.connections
.remove({
params: {
owner: "org",
integration: IntegrationSlug.make(slug),
name: ConnectionName.make("main"),
},
})
.pipe(Effect.ignore);
yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
}),
);
}),
);
54 changes: 37 additions & 17 deletions packages/plugins/openapi/src/react/AddOpenApiSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,10 @@ import {
type GoogleOpenApiPreset,
} from "../sdk/google-presets";
import type { SpecPreview } from "../sdk/preview";
import { type Authentication, type ServerInfo } from "../sdk/types";
import { expandServerUrlOptions } from "../sdk/openapi-utils";
import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth";
import { type Authentication } from "../sdk/types";
import { resolveServerUrl } from "../sdk/openapi-utils";
import { detectedAuthenticationTemplates } from "../sdk/derive-auth";

const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";

// The bundle picker opens with the featured Google APIs pre-checked.
Expand Down Expand Up @@ -103,9 +102,6 @@ const specInputForAdd = (input: string) => {
: { kind: "blob" as const, value };
};

const expandServerOptions = (server: ServerInfo) =>
expandServerUrlOptions(server).map((value) => ({ value, label: value }));

// ---------------------------------------------------------------------------
// Component — single progressive form. Post-redesign: preview → addSpec
// (register the integration catalog entry with ALL detected auth methods) →
Expand Down Expand Up @@ -136,7 +132,7 @@ export default function AddOpenApiSource(props: {

// After analysis
const [preview, setPreview] = useState<SpecPreview | null>(null);
const [baseUrl, setBaseUrl] = useState(isGoogleBundlePreset ? GOOGLE_BUNDLE_BASE_URL : "");
const [baseUrl, setBaseUrl] = useState("");
const identityFallbackName = isGoogleBundlePreset
? "Google"
: preview
Expand Down Expand Up @@ -198,10 +194,20 @@ export default function AddOpenApiSource(props: {

// ---- Derived state ----

const servers: readonly ServerInfo[] = preview?.servers ?? [];
const baseUrlOptions = Array.from(
new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),
);
const previewHasNoServers = preview !== null && preview.servers.length === 0;
// Offer the spec's servers (resolved with defaults) as base-URL choices when
// there's more than one; a single/no server uses a plain input.
const baseUrlOptions =
preview && preview.servers.length > 1
? preview.servers.map((server) => {
const url = resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {});
return { value: url, label: url };
})
: undefined;
const firstServer = preview?.servers[0];
const firstServerUrl = firstServer
? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
: "";
const previewPresetIcon =
openApiPresets.find(
(preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
Expand Down Expand Up @@ -275,7 +281,12 @@ export default function AddOpenApiSource(props: {
const hasPreviewOrBundle = isGoogleBundlePreset
? bundleDiscoveryUrls.length > 0
: preview !== null;
const canAdd = hasPreviewOrBundle && resolvedBaseUrl.length > 0 && !slugAlreadyExists;
// The base URL is optional when the spec declares servers (resolved per call);
// required only when it doesn't.
const canAdd =
hasPreviewOrBundle &&
!slugAlreadyExists &&
(!previewHasNoServers || resolvedBaseUrl.length > 0);

// ---- Handlers ----

Expand All @@ -291,7 +302,7 @@ export default function AddOpenApiSource(props: {
}
const result = exit.value;
setPreview(result);
setBaseUrl(firstBaseUrlForPreview(result));
setBaseUrl("");
setAnalyzing(false);
};

Expand Down Expand Up @@ -423,9 +434,9 @@ export default function AddOpenApiSource(props: {
identity={identity}
baseUrl={resolvedBaseUrl}
onBaseUrlChange={setBaseUrl}
baseUrlLabel="Base URL override (optional)"
faviconIcon={GOOGLE_BUNDLE_FAVICON}
faviconUrl={resolvedBaseUrl}
baseUrlMissingMessage="A base URL is required to make requests."
/>
) : preview ? (
<OpenApiSourceDetailsFields
Expand All @@ -441,15 +452,24 @@ export default function AddOpenApiSource(props: {
baseUrl={resolvedBaseUrl}
onBaseUrlChange={setBaseUrl}
baseUrlOptions={baseUrlOptions}
baseUrlLabel={previewHasNoServers ? "Base URL" : "Base URL override (optional)"}
baseUrlPlaceholder={firstServerUrl || "https://api.example.com"}
baseUrlHint={
previewHasNoServers
? undefined
: "Overrides the spec's servers; leave empty to choose the server (and variables) per tool call."
}
baseUrlMissingMessage={
previewHasNoServers ? "This spec declares no servers — enter a base URL." : undefined
}
specUrl={specUrl}
onSpecUrlChange={(value) => {
setSpecUrl(value);
setPreview(null);
setBaseUrl("");
}}
faviconIcon={previewPresetIcon}
faviconUrl={resolvedBaseUrl}
baseUrlMissingMessage="A base URL is required to make requests."
faviconUrl={resolvedBaseUrl || firstServerUrl}
/>
) : null}

Expand Down
Loading