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
28import { FloatActions } from "@executor/react/components/float-actions";28import { FloatActions } from "@executor/react/components/float-actions";
29import { Input } from "@executor/react/components/input";29import { Input } from "@executor/react/components/input";
30import { Label } from "@executor/react/components/label";30import { Label } from "@executor/react/components/label";
31import {
32 NativeSelect,
33 NativeSelectOption,
34} from "@executor/react/components/native-select";
31import { Textarea } from "@executor/react/components/textarea";35import { Textarea } from "@executor/react/components/textarea";
32import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";36import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";
33import { Skeleton } from "@executor/react/components/skeleton";37import { Skeleton } from "@executor/react/components/skeleton";
34import { IOSSpinner, Spinner } from "@executor/react/components/spinner";38import { IOSSpinner, Spinner } from "@executor/react/components/spinner";
35import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";39import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";
36import type { SpecPreview, HeaderPreset } from "../sdk/preview";40import type { SpecPreview, HeaderPreset } from "../sdk/preview";
37import type { HeaderValue } from "../sdk/types";41import type { HeaderValue, ServerInfo, ServerVariable } from "../sdk/types";
42
43const 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};
38 50
39// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
40// Helpers52// Helpers
@@ -79,7 +91,11 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines
79 91
80 // After analysis92 // After analysis
81 const [preview, setPreview] = useState<SpecPreview | null>(null);93 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>>({});
83 const identity = useSourceIdentity({99 const identity = useSourceIdentity({
84 fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",100 fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",
85 fallbackNamespace: props.initialNamespace,101 fallbackNamespace: props.initialNamespace,
@@ -118,7 +134,35 @@ export default function AddOpenApiSource(props: { · 32 unmodified lines
118 134
119 // ---- Derived state ----135 // ---- Derived state ----
120 136
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 };
122 166
123 // Derive a favicon URL from the spec URL (if the user entered one — raw167 // Derive a favicon URL from the spec URL (if the user entered one — raw
124 // JSON/YAML content will fail URL parsing and yield null). Uses Google's168 // 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
155 199
156 const canAdd =200 const canAdd =
157 preview !== null &&201 preview !== null &&
158 baseUrl.trim().length > 0 &&202 resolvedBaseUrl.length > 0 &&
159 (customHeaders.length === 0 || customHeadersValid);203 (customHeaders.length === 0 || customHeadersValid);
160 204
161 // ---- Handlers ----205 // ---- Handlers ----
@@ -171,8 +215,16 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
171 });215 });
172 setPreview(result);216 setPreview(result);
173 217
174 const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;218 const firstServer = result.servers[0];
175 if (firstUrl) setBaseUrl(firstUrl);219 if (firstServer) {
220 setSelectedServerIndex(0);
221 setVariableSelections(defaultSelectionsFor(firstServer));
222 setCustomBaseUrl("");
223 } else {
224 setSelectedServerIndex(-1);
225 setVariableSelections({});
226 setCustomBaseUrl("");
227 }
176 228
177 const firstPreset = result.headerPresets[0];229 const firstPreset = result.headerPresets[0];
178 if (firstPreset) {230 if (firstPreset) {
@@ -228,7 +280,7 @@ export default function AddOpenApiSource(props: { · 49 unmodified lines
228 spec: specUrl,280 spec: specUrl,
229 name: identity.name.trim() || undefined,281 name: identity.name.trim() || undefined,
230 namespace: slugifyNamespace(identity.namespace) || undefined,282 namespace: slugifyNamespace(identity.namespace) || undefined,
231 baseUrl: baseUrl.trim() || undefined,283 baseUrl: resolvedBaseUrl || undefined,
232 ...(hasHeaders ? { headers: allHeaders } : {}),284 ...(hasHeaders ? { headers: allHeaders } : {}),
233 },285 },
234 });286 });
@@ -259,7 +311,9 @@ export default function AddOpenApiSource(props: { · 24 unmodified lines
259 setSpecUrl((e.target as HTMLTextAreaElement).value);311 setSpecUrl((e.target as HTMLTextAreaElement).value);
260 if (preview) {312 if (preview) {
261 setPreview(null);313 setPreview(null);
262 setBaseUrl("");314 setSelectedServerIndex(-1);
315 setCustomBaseUrl("");
316 setVariableSelections({});
263 setCustomHeaders([]);317 setCustomHeaders([]);
264 setSelectedStrategy(-1);318 setSelectedStrategy(-1);
265 }319 }
@@ -337,45 +391,125 @@ export default function AddOpenApiSource(props: { · 71 unmodified lines
337 <CardStack>391 <CardStack>
338 <CardStackContent className="border-t-0">392 <CardStackContent className="border-t-0">
339 <CardStackEntryField label="Base URL">393 <CardStackEntryField label="Base URL">
340 {servers.length > 1 ? (394 {servers.length >= 1 && (
341 <div className="space-y-2">395 <RadioGroup
342 <RadioGroup value={baseUrl} onValueChange={setBaseUrl} className="gap-1.5">396 value={String(selectedServerIndex)}
343 {servers.map((s, i) => {397 onValueChange={(value) => {
344 const url = s.url ?? "";398 const idx = Number(value);
345 return (399 setSelectedServerIndex(idx);
346 <Label400 if (idx >= 0) {
347 key={i}401 const s = servers[idx];
348 className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${402 if (s) setVariableSelections(defaultSelectionsFor(s));
349 baseUrl === url403 } else {
350 ? "border-primary/50 bg-primary/[0.03]"404 setVariableSelections({});
351 : "border-border hover:bg-accent/50"405 }
352 }`}406 }}
353 >407 className="gap-1.5"
354 <RadioGroupItem value={url} />408 >
355 <span className="font-mono text-xs text-foreground truncate">409 {servers.map((s, i) => (
356 {url}410 <Label
357 </span>411 key={i}
358 </Label>412 className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
359 );413 selectedServerIndex === i
360 })}414 ? "border-primary/50 bg-primary/[0.03]"
361 </RadioGroup>415 : "border-border hover:bg-accent/50"
362 <Input416 }`}
363 value={baseUrl}417 >
364 onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}418 <RadioGroupItem value={String(i)} className="mt-0.5" />
365 placeholder="Or enter a custom URL…"419 <div className="min-w-0 flex-1">
366 className="font-mono text-sm"420 <div className="font-mono text-xs text-foreground truncate">
367 />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 })}
368 </div>496 </div>
369 ) : (497 )}
498
499 {selectedServerIndex === -1 ? (
370 <Input500 <Input
371 value={baseUrl}501 value={customBaseUrl}
372 onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}502 onChange={(e) => setCustomBaseUrl((e.target as HTMLInputElement).value)}
373 placeholder="https://api.example.com"503 placeholder="https://api.example.com"
374 className="font-mono text-sm"504 className="font-mono text-sm"
375 />505 />
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>
376 )}510 )}
377 511
378 {!baseUrl.trim() && (512 {!resolvedBaseUrl && (
379 <p className="text-[11px] text-amber-600 dark:text-amber-400">513 <p className="text-[11px] text-amber-600 dark:text-amber-400">
380 A base URL is required to make requests.514 A base URL is required to make requests.
381 </p>515 </p>
packages/plugins/openapi/src/sdk/extract.ts+21−3
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
20 OperationRequestBody,20 OperationRequestBody,
21 type ParameterLocation,21 type ParameterLocation,
22 ServerInfo,22 ServerInfo,
23 ServerVariable,
23} from "./types";24} from "./types";
24 25
25// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
@@ -177,14 +178,31 @@ const extractServers = (doc: ParsedDocument): ServerInfo[] => · 151 unmodified lines
177 if (!server.url) return [];178 if (!server.url) return [];
178 const vars = server.variables179 const vars = server.variables
179 ? Object.fromEntries(180 ? Object.fromEntries(
180 Object.entries(server.variables).flatMap(([name, v]) =>181 Object.entries(server.variables).flatMap(([name, v]) => {
181 v.default ? [[name, v.default]] : [],182 if (v.default === undefined || v.default === null) return [];
182 ),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 }),
183 )200 )
184 : undefined;201 : undefined;
185 return [202 return [
186 new ServerInfo({203 new ServerInfo({
187 url: server.url,204 url: server.url,
205 description: Option.fromNullable(server.description),
188 variables: vars && Object.keys(vars).length > 0 ? Option.some(vars) : Option.none(),206 variables: vars && Object.keys(vars).length > 0 ? Option.some(vars) : Option.none(),
189 }),207 }),
190 ];208 ];
packages/plugins/openapi/src/sdk/index.test.ts+94−0
@@ -160,4 +160,98 @@ describe("OpenAPI plugin", () => { · 159 unmodified lines
160 expect(paths).toContain("pets.getPet");160 expect(paths).toContain("pets.getPet");
161 }),161 }),
162 );162 );
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
211describe("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 );
163});257});
packages/plugins/openapi/src/sdk/index.ts+7−1
@@ -18,7 +18,12 @@ export { · 17 unmodified lines
18 PreviewOperation,18 PreviewOperation,
19 SpecPreview,19 SpecPreview,
20} from "./preview";20} from "./preview";
21export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";21export {
22 DocResolver,
23 resolveBaseUrl,
24 substituteUrlVariables,
25 preferredContent,
26} from "./openapi-utils";
22 27
23export { OpenApiParseError, OpenApiExtractionError, OpenApiInvocationError } from "./errors";28export { OpenApiParseError, OpenApiExtractionError, OpenApiInvocationError } from "./errors";
24 29
@@ -31,6 +36,7 @@ export { · 6 unmodified lines
31 OperationParameter,36 OperationParameter,
32 OperationRequestBody,37 OperationRequestBody,
33 ServerInfo,38 ServerInfo,
39 ServerVariable,
34 OperationId,40 OperationId,
35 HttpMethod,41 HttpMethod,
36 ParameterLocation,42 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
54// Server URL resolution54// Server URL resolution
55// ---------------------------------------------------------------------------55// ---------------------------------------------------------------------------
56 56
57export const resolveBaseUrl = (57/** Substitute `{var}` placeholders in a templated URL using a plain map. */
58 servers: readonly {58export const substituteUrlVariables = (
59 url: string;59 url: string,
60 variables: import("effect/Option").Option<Record<string, string>>;60 values: Record<string, string>,
61 }[],
62): string => {61): 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
69type ServerLike = {
70 url: string;
71 variables: import("effect/Option").Option<
72 Record<string, { default: string } | string>
73 >;
74};
75
76export const resolveBaseUrl = (servers: readonly ServerLike[]): string => {
63 const server = servers[0];77 const server = servers[0];
64 if (!server) return "";78 if (!server) return "";
65 79
66 let url = server.url;80 if (!Option.isSome(server.variables)) return server.url;
67 if (Option.isSome(server.variables)) {81
68 for (const [name, value] of Object.entries(server.variables.value)) {82 const values: Record<string, string> = {};
69 url = url.replaceAll(`{${name}}`, value);83 for (const [name, v] of Object.entries(server.variables.value)) {
70 }84 values[name] = typeof v === "string" ? v : v.default;
71 }85 }
72 return url;86 return substituteUrlVariables(server.url, values);
73};87};
74 88
75// ---------------------------------------------------------------------------89// ---------------------------------------------------------------------------
packages/plugins/openapi/src/sdk/preview.ts+3−3
@@ -3,7 +3,7 @@ import { Schema } from "effect"; · 2 unmodified lines
3 3
4import { parse } from "./parse";4import { parse } from "./parse";
5import { extract } from "./extract";5import { extract } from "./extract";
6import { HttpMethod, type ExtractionResult } from "./types";6import { HttpMethod, ServerInfo, type ExtractionResult } from "./types";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9// Security scheme — what the spec declares it needs9// Security scheme — what the spec declares it needs
@@ -66,7 +66,7 @@ export class SpecPreview extends Schema.Class<SpecPreview>("SpecPreview")({ · 56 unmodified lines
66 title: Schema.optionalWith(Schema.String, { as: "Option" }),66 title: Schema.optionalWith(Schema.String, { as: "Option" }),
67 version: Schema.optionalWith(Schema.String, { as: "Option" }),67 version: Schema.optionalWith(Schema.String, { as: "Option" }),
68 /** Reuses ServerInfo from extraction */68 /** Reuses ServerInfo from extraction */
69 servers: Schema.Array(Schema.Unknown),69 servers: Schema.Array(ServerInfo),
70 operationCount: Schema.Number,70 operationCount: Schema.Number,
71 /** Lightweight operation list for the add-source UI */71 /** Lightweight operation list for the add-source UI */
72 operations: Schema.Array(PreviewOperation),72 operations: Schema.Array(PreviewOperation),
@@ -185,7 +185,7 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: · 112 unmodified lines
185 return new SpecPreview({185 return new SpecPreview({
186 title: result.title,186 title: result.title,
187 version: result.version,187 version: result.version,
188 servers: result.servers as unknown as readonly unknown[],188 servers: result.servers,
189 operationCount: result.operations.length,189 operationCount: result.operations.length,
190 operations: result.operations.map(190 operations: result.operations.map(
191 (op) =>191 (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
63 deprecated: Schema.optionalWith(Schema.Boolean, { default: () => false }),63 deprecated: Schema.optionalWith(Schema.Boolean, { default: () => false }),
64}) {}64}) {}
65 65
66export 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
66export class ServerInfo extends Schema.Class<ServerInfo>("ServerInfo")({72export class ServerInfo extends Schema.Class<ServerInfo>("ServerInfo")({
67 url: Schema.String,73 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 }), {
69 as: "Option",76 as: "Option",
70 }),77 }),
71}) {}78}) {}