History

Support OpenAPI server variables during onboarding (#246)

Preserve enum and description metadata on server variables through extraction and preview so the add-source UI can render per-variable pickers (e.g. Sentry's {region}) and submit a fully resolved base URL instead of a templated one.

Rhys Sullivan committed 7 months ago

Commit 12dd5811 parent 120d7e77 files · +332 −59Browse files

Unified · Split

7 files
  1. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +174 −40
  2. packages/plugins/openapi/src/sdk/extract.ts +21 −3
  3. packages/plugins/openapi/src/sdk/index.test.ts +94 −0
  4. packages/plugins/openapi/src/sdk/index.ts +7 −1
  5. packages/plugins/openapi/src/sdk/openapi-utils.ts +25 −11
  6. packages/plugins/openapi/src/sdk/preview.ts +3 −3
  7. packages/plugins/openapi/src/sdk/types.ts +8 −1
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+174−40
@@ -28,13 +28,25 @@ import { FieldLabel } from "@executor/react/components/field"; · 27 unmodified lines
2828 import { FloatActions } from "@executor/react/components/float-actions";
2929 import { Input } from "@executor/react/components/input";
3030 import { Label } from "@executor/react/components/label";
31+import {
32+ NativeSelect,
33+ NativeSelectOption,
34+} from "@executor/react/components/native-select";
3135 import { Textarea } from "@executor/react/components/textarea";
3236 import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";
3337 import { Skeleton } from "@executor/react/components/skeleton";
3438 import { IOSSpinner, Spinner } from "@executor/react/components/spinner";
3539 import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";
3640 import type { SpecPreview, HeaderPreset } from "../sdk/preview";
37−import type { HeaderValue } from "../sdk/types";
41+import type { HeaderValue, ServerInfo, ServerVariable } from "../sdk/types";
42+
43+const substituteUrlVariables = (url: string, values: Record<string, string>): string => {
44+ let out = url;
45+ for (const [name, value] of Object.entries(values)) {
46+ out = out.replaceAll(`{${name}}`, value);
47+ }
48+ return out;
49+};
3850
3951 // ---------------------------------------------------------------------------
4052 // Helpers
@@ -79,7 +91,11 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines
7991
8092 // After analysis
8193 const [preview, setPreview] = useState<SpecPreview | null>(null);
82− const [baseUrl, setBaseUrl] = useState("");
94+ // -1 means the user is entering a fully custom base URL (no server selected).
95+ const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
96+ const [customBaseUrl, setCustomBaseUrl] = useState("");
97+ // Variable selections for the currently selected server, keyed by variable name.
98+ const [variableSelections, setVariableSelections] = useState<Record<string, string>>({});
8399 const identity = useSourceIdentity({
84100 fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",
85101 fallbackNamespace: props.initialNamespace,
@@ -118,7 +134,35 @@ export default function AddOpenApiSource(props: { · 32 unmodified lines
118134
119135 // ---- Derived state ----
120136
121− const servers = (preview?.servers ?? []) as Array<{ url?: string }>;
137+ const servers: readonly ServerInfo[] = preview?.servers ?? [];
138+ const selectedServer: ServerInfo | null =
139+ selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null;
140+
141+ const serverVariables: Record<string, ServerVariable> = selectedServer
142+ ? Option.getOrElse(
143+ selectedServer.variables,
144+ () => ({}) as Record<string, ServerVariable>,
145+ )
146+ : {};
147+ const serverVariableEntries: Array<[string, ServerVariable]> =
148+ Object.entries(serverVariables);
149+
150+ const resolvedBaseUrl =
151+ selectedServer !== null
152+ ? substituteUrlVariables(selectedServer.url, variableSelections)
153+ : customBaseUrl.trim();
154+
155+ // Helper used by analyze + server selection: build a default selection map
156+ // from a server's variable defaults.
157+ const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => {
158+ const vars: Record<string, ServerVariable> = Option.getOrElse(
159+ server.variables,
160+ () => ({}) as Record<string, ServerVariable>,
161+ );
162+ const out: Record<string, string> = {};
163+ for (const [name, v] of Object.entries(vars)) out[name] = v.default;
164+ return out;
165+ };
122166
123167 // Derive a favicon URL from the spec URL (if the user entered one — raw
124168 // JSON/YAML content will fail URL parsing and yield null). Uses Google's
@@ -155,7 +199,7 @@ export default function AddOpenApiSource(props: { · 30 unmodified lines
155199
156200 const canAdd =
157201 preview !== null &&
158− baseUrl.trim().length > 0 &&
202+ resolvedBaseUrl.length > 0 &&
159203 (customHeaders.length === 0 || customHeadersValid);
160204
161205 // ---- Handlers ----
@@ -171,8 +215,16 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
171215 });
172216 setPreview(result);
173217
174− const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;
175− if (firstUrl) setBaseUrl(firstUrl);
218+ const firstServer = result.servers[0];
219+ if (firstServer) {
220+ setSelectedServerIndex(0);
221+ setVariableSelections(defaultSelectionsFor(firstServer));
222+ setCustomBaseUrl("");
223+ } else {
224+ setSelectedServerIndex(-1);
225+ setVariableSelections({});
226+ setCustomBaseUrl("");
227+ }
176228
177229 const firstPreset = result.headerPresets[0];
178230 if (firstPreset) {
@@ -228,7 +280,7 @@ export default function AddOpenApiSource(props: { · 49 unmodified lines
228280 spec: specUrl,
229281 name: identity.name.trim() || undefined,
230282 namespace: slugifyNamespace(identity.namespace) || undefined,
231− baseUrl: baseUrl.trim() || undefined,
283+ baseUrl: resolvedBaseUrl || undefined,
232284 ...(hasHeaders ? { headers: allHeaders } : {}),
233285 },
234286 });
@@ -259,7 +311,9 @@ export default function AddOpenApiSource(props: { · 24 unmodified lines
259311 setSpecUrl((e.target as HTMLTextAreaElement).value);
260312 if (preview) {
261313 setPreview(null);
262− setBaseUrl("");
314+ setSelectedServerIndex(-1);
315+ setCustomBaseUrl("");
316+ setVariableSelections({});
263317 setCustomHeaders([]);
264318 setSelectedStrategy(-1);
265319 }
@@ -337,45 +391,125 @@ export default function AddOpenApiSource(props: { · 71 unmodified lines
337391 <CardStack>
338392 <CardStackContent className="border-t-0">
339393 <CardStackEntryField label="Base URL">
340− {servers.length > 1 ? (
341− <div className="space-y-2">
342− <RadioGroup value={baseUrl} onValueChange={setBaseUrl} className="gap-1.5">
343− {servers.map((s, i) => {
344− const url = s.url ?? "";
345− return (
346− <Label
347− key={i}
348− className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
349− baseUrl === url
350− ? "border-primary/50 bg-primary/[0.03]"
351− : "border-border hover:bg-accent/50"
352− }`}
353− >
354− <RadioGroupItem value={url} />
355− <span className="font-mono text-xs text-foreground truncate">
356− {url}
357− </span>
358− </Label>
359− );
360− })}
361− </RadioGroup>
362− <Input
363− value={baseUrl}
364− onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}
365− placeholder="Or enter a custom URL…"
366− className="font-mono text-sm"
367− />
394+ {servers.length >= 1 && (
395+ <RadioGroup
396+ value={String(selectedServerIndex)}
397+ onValueChange={(value) => {
398+ const idx = Number(value);
399+ setSelectedServerIndex(idx);
400+ if (idx >= 0) {
401+ const s = servers[idx];
402+ if (s) setVariableSelections(defaultSelectionsFor(s));
403+ } else {
404+ setVariableSelections({});
405+ }
406+ }}
407+ className="gap-1.5"
408+ >
409+ {servers.map((s, i) => (
410+ <Label
411+ key={i}
412+ className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
413+ selectedServerIndex === i
414+ ? "border-primary/50 bg-primary/[0.03]"
415+ : "border-border hover:bg-accent/50"
416+ }`}
417+ >
418+ <RadioGroupItem value={String(i)} className="mt-0.5" />
419+ <div className="min-w-0 flex-1">
420+ <div className="font-mono text-xs text-foreground truncate">
421+ {s.url}
422+ </div>
423+ {Option.isSome(s.description) && (
424+ <div className="mt-0.5 text-[10px] text-muted-foreground">
425+ {s.description.value}
426+ </div>
427+ )}
428+ </div>
429+ </Label>
430+ ))}
431+ <Label
432+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
433+ selectedServerIndex === -1
434+ ? "border-primary/50 bg-primary/[0.03]"
435+ : "border-border hover:bg-accent/50"
436+ }`}
437+ >
438+ <RadioGroupItem value="-1" />
439+ <span className="text-xs font-medium text-foreground">Custom</span>
440+ </Label>
441+ </RadioGroup>
442+ )}
443+
444+ {/* Per-variable pickers for the selected server */}
445+ {selectedServer && serverVariableEntries.length > 0 && (
446+ <div className="mt-2 space-y-2 rounded-lg border border-border/60 bg-muted/20 p-2.5">
447+ {serverVariableEntries.map(([name, variable]) => {
448+ const enumValues: readonly string[] = Option.getOrElse(
449+ variable.enum,
450+ () => [] as readonly string[],
451+ );
452+ const current = variableSelections[name] ?? variable.default;
453+ return (
454+ <div key={name} className="space-y-1">
455+ <div className="flex items-baseline justify-between gap-2">
456+ <Label className="font-mono text-[11px] text-foreground">
457+ {`{${name}}`}
458+ </Label>
459+ {Option.isSome(variable.description) && (
460+ <span className="text-[10px] text-muted-foreground truncate">
461+ {variable.description.value}
462+ </span>
463+ )}
464+ </div>
465+ {enumValues.length > 0 ? (
466+ <NativeSelect
467+ value={current}
468+ onChange={(e) =>
469+ setVariableSelections((prev) => ({
470+ ...prev,
471+ [name]: (e.target as HTMLSelectElement).value,
472+ }))
473+ }
474+ >
475+ {enumValues.map((v) => (
476+ <NativeSelectOption key={v} value={v}>
477+ {v}
478+ </NativeSelectOption>
479+ ))}
480+ </NativeSelect>
481+ ) : (
482+ <Input
483+ value={current}
484+ onChange={(e) =>
485+ setVariableSelections((prev) => ({
486+ ...prev,
487+ [name]: (e.target as HTMLInputElement).value,
488+ }))
489+ }
490+ className="font-mono text-xs"
491+ />
492+ )}
493+ </div>
494+ );
495+ })}
368496 </div>
369− ) : (
497+ )}
498+
499+ {selectedServerIndex === -1 ? (
370500 <Input
371− value={baseUrl}
372− onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}
501+ value={customBaseUrl}
502+ onChange={(e) => setCustomBaseUrl((e.target as HTMLInputElement).value)}
373503 placeholder="https://api.example.com"
374504 className="font-mono text-sm"
375505 />
506+ ) : (
507+ <div className="rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground">
508+ {resolvedBaseUrl || "\u00A0"}
509+ </div>
376510 )}
377511
378− {!baseUrl.trim() && (
512+ {!resolvedBaseUrl && (
379513 <p className="text-[11px] text-amber-600 dark:text-amber-400">
380514 A base URL is required to make requests.
381515 </p>
packages/plugins/openapi/src/sdk/extract.ts+21−3
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
2020 OperationRequestBody,
2121 type ParameterLocation,
2222 ServerInfo,
23+ ServerVariable,
2324 } from "./types";
2425
2526 // ---------------------------------------------------------------------------
@@ -177,14 +178,31 @@ const extractServers = (doc: ParsedDocument): ServerInfo[] => · 151 unmodified lines
177178 if (!server.url) return [];
178179 const vars = server.variables
179180 ? Object.fromEntries(
180− Object.entries(server.variables).flatMap(([name, v]) =>
181− v.default ? [[name, v.default]] : [],
182− ),
181+ Object.entries(server.variables).flatMap(([name, v]) => {
182+ if (v.default === undefined || v.default === null) return [];
183+ const enumValues = Array.isArray(v.enum)
184+ ? v.enum.filter((x): x is string => typeof x === "string")
185+ : undefined;
186+ return [
187+ [
188+ name,
189+ new ServerVariable({
190+ default: String(v.default),
191+ enum:
192+ enumValues && enumValues.length > 0
193+ ? Option.some(enumValues)
194+ : Option.none(),
195+ description: Option.fromNullable(v.description),
196+ }),
197+ ],
198+ ];
199+ }),
183200 )
184201 : undefined;
185202 return [
186203 new ServerInfo({
187204 url: server.url,
205+ description: Option.fromNullable(server.description),
188206 variables: vars && Object.keys(vars).length > 0 ? Option.some(vars) : Option.none(),
189207 }),
190208 ];
packages/plugins/openapi/src/sdk/index.test.ts+94−0
@@ -160,4 +160,98 @@ describe("OpenAPI plugin", () => { · 159 unmodified lines
160160 expect(paths).toContain("pets.getPet");
161161 }),
162162 );
163+
164+ it.effect("extracts server variables with enum and description", () =>
165+ Effect.gen(function* () {
166+ const specWithServerVars = {
167+ openapi: "3.0.0",
168+ info: { title: "Sentry", version: "1.0.0" },
169+ servers: [
170+ {
171+ url: "https://{region}.sentry.io",
172+ description: "Regional endpoint",
173+ variables: {
174+ region: {
175+ default: "us",
176+ description: "The data-storage-location for an organization",
177+ enum: ["us", "de"],
178+ },
179+ },
180+ },
181+ ],
182+ paths: {
183+ "/ping": { get: { responses: { "200": { description: "ok" } } } },
184+ },
185+ };
186+ // @effect-diagnostics-next-line preferSchemaOverJson:off
187+ const doc = yield* parse(JSON.stringify(specWithServerVars));
188+ const result = yield* extract(doc);
189+
190+ expect(result.servers).toHaveLength(1);
191+ const server = result.servers[0]!;
192+ expect(server.url).toBe("https://{region}.sentry.io");
193+ expect(Option.getOrElse(server.description, () => "")).toBe("Regional endpoint");
194+
195+ const vars = Option.getOrThrow(server.variables);
196+ expect(vars.region!.default).toBe("us");
197+ expect(
198+ Option.getOrElse(vars.region!.enum, () => [] as readonly string[]),
199+ ).toEqual(["us", "de"]);
200+ expect(Option.getOrElse(vars.region!.description, () => "")).toBe(
201+ "The data-storage-location for an organization",
202+ );
203+ }),
204+ );
205+});
206+
207+// ---------------------------------------------------------------------------
208+// Server variable extraction
209+// ---------------------------------------------------------------------------
210+
211+describe("extract — server variables", () => {
212+ const specWithServerVars = {
213+ openapi: "3.0.0",
214+ info: { title: "Test", version: "1.0.0" },
215+ servers: [
216+ {
217+ url: "https://{region}.example.com/{basePath}",
218+ description: "Regional endpoint",
219+ variables: {
220+ region: {
221+ default: "us",
222+ enum: ["us", "eu", "ap"],
223+ description: "Data region",
224+ },
225+ basePath: { default: "v1" },
226+ },
227+ },
228+ ],
229+ paths: {
230+ "/ping": { get: { responses: { "200": { description: "ok" } } } },
231+ },
232+ };
233+
234+ it.effect("preserves enum, default, and description for server variables", () =>
235+ Effect.gen(function* () {
236+ // @effect-diagnostics-next-line preferSchemaOverJson:off
237+ const doc = yield* parse(JSON.stringify(specWithServerVars));
238+ const result = yield* extract(doc);
239+
240+ expect(result.servers).toHaveLength(1);
241+ const server = result.servers[0]!;
242+ expect(server.url).toBe("https://{region}.example.com/{basePath}");
243+ expect(Option.getOrNull(server.description)).toBe("Regional endpoint");
244+
245+ const vars = Option.getOrNull(server.variables);
246+ expect(vars).not.toBeNull();
247+ const region = vars!.region!;
248+ expect(region.default).toBe("us");
249+ expect(Option.getOrNull(region.enum)).toEqual(["us", "eu", "ap"]);
250+ expect(Option.getOrNull(region.description)).toBe("Data region");
251+
252+ const basePath = vars!.basePath!;
253+ expect(basePath.default).toBe("v1");
254+ expect(Option.isNone(basePath.enum)).toBe(true);
255+ }),
256+ );
163257 });
packages/plugins/openapi/src/sdk/index.ts+7−1
@@ -18,7 +18,12 @@ export { · 17 unmodified lines
1818 PreviewOperation,
1919 SpecPreview,
2020 } from "./preview";
21−export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
21+export {
22+ DocResolver,
23+ resolveBaseUrl,
24+ substituteUrlVariables,
25+ preferredContent,
26+} from "./openapi-utils";
2227
2328 export { OpenApiParseError, OpenApiExtractionError, OpenApiInvocationError } from "./errors";
2429
@@ -31,6 +36,7 @@ export { · 6 unmodified lines
3136 OperationParameter,
3237 OperationRequestBody,
3338 ServerInfo,
39+ ServerVariable,
3440 OperationId,
3541 HttpMethod,
3642 ParameterLocation,
packages/plugins/openapi/src/sdk/openapi-utils.ts+25−11
@@ -54,22 +54,36 @@ const isRef = (value: unknown): value is { $ref: string } => · 53 unmodified lines
5454 // Server URL resolution
5555 // ---------------------------------------------------------------------------
5656
57−export const resolveBaseUrl = (
58− servers: readonly {
59− url: string;
60− variables: import("effect/Option").Option<Record<string, string>>;
61− }[],
57+/** Substitute `{var}` placeholders in a templated URL using a plain map. */
58+export const substituteUrlVariables = (
59+ url: string,
60+ values: Record<string, string>,
6261 ): string => {
62+ let out = url;
63+ for (const [name, value] of Object.entries(values)) {
64+ out = out.replaceAll(`{${name}}`, value);
65+ }
66+ return out;
67+};
68+
69+type ServerLike = {
70+ url: string;
71+ variables: import("effect/Option").Option<
72+ Record<string, { default: string } | string>
73+ >;
74+};
75+
76+export const resolveBaseUrl = (servers: readonly ServerLike[]): string => {
6377 const server = servers[0];
6478 if (!server) return "";
6579
66− let url = server.url;
67− if (Option.isSome(server.variables)) {
68− for (const [name, value] of Object.entries(server.variables.value)) {
69− url = url.replaceAll(`{${name}}`, value);
70− }
80+ if (!Option.isSome(server.variables)) return server.url;
81+
82+ const values: Record<string, string> = {};
83+ for (const [name, v] of Object.entries(server.variables.value)) {
84+ values[name] = typeof v === "string" ? v : v.default;
7185 }
72− return url;
86+ return substituteUrlVariables(server.url, values);
7387 };
7488
7589 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/sdk/preview.ts+3−3
@@ -3,7 +3,7 @@ import { Schema } from "effect"; · 2 unmodified lines
33
44 import { parse } from "./parse";
55 import { extract } from "./extract";
6−import { HttpMethod, type ExtractionResult } from "./types";
6+import { HttpMethod, ServerInfo, type ExtractionResult } from "./types";
77
88 // ---------------------------------------------------------------------------
99 // Security scheme — what the spec declares it needs
@@ -66,7 +66,7 @@ export class SpecPreview extends Schema.Class<SpecPreview>("SpecPreview")({ · 56 unmodified lines
6666 title: Schema.optionalWith(Schema.String, { as: "Option" }),
6767 version: Schema.optionalWith(Schema.String, { as: "Option" }),
6868 /** Reuses ServerInfo from extraction */
69− servers: Schema.Array(Schema.Unknown),
69+ servers: Schema.Array(ServerInfo),
7070 operationCount: Schema.Number,
7171 /** Lightweight operation list for the add-source UI */
7272 operations: Schema.Array(PreviewOperation),
@@ -185,7 +185,7 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: · 112 unmodified lines
185185 return new SpecPreview({
186186 title: result.title,
187187 version: result.version,
188− servers: result.servers as unknown as readonly unknown[],
188+ servers: result.servers,
189189 operationCount: result.operations.length,
190190 operations: result.operations.map(
191191 (op) =>
packages/plugins/openapi/src/sdk/types.ts+8−1
@@ -63,9 +63,16 @@ export class ExtractedOperation extends Schema.Class<ExtractedOperation>("Extrac · 62 unmodified lines
6363 deprecated: Schema.optionalWith(Schema.Boolean, { default: () => false }),
6464 }) {}
6565
66+export class ServerVariable extends Schema.Class<ServerVariable>("ServerVariable")({
67+ default: Schema.String,
68+ enum: Schema.optionalWith(Schema.Array(Schema.String), { as: "Option" }),
69+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
70+}) {}
71+
6672 export class ServerInfo extends Schema.Class<ServerInfo>("ServerInfo")({
6773 url: Schema.String,
68− variables: Schema.optionalWith(Schema.Record({ key: Schema.String, value: Schema.String }), {
74+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
75+ variables: Schema.optionalWith(Schema.Record({ key: Schema.String, value: ServerVariable }), {
6976 as: "Option",
7077 }),
7178 }) {}