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.
Unified · Split
| @@ -28,13 +28,25 @@ import { FieldLabel } from "@executor/react/components/field"; · 27 unmodified lines | ||
| 28 | 28 | import { FloatActions } from "@executor/react/components/float-actions"; |
| 29 | 29 | import { Input } from "@executor/react/components/input"; |
| 30 | 30 | import { Label } from "@executor/react/components/label"; |
| 31 | +import { | |
| 32 | + NativeSelect, | |
| 33 | + NativeSelectOption, | |
| 34 | +} from "@executor/react/components/native-select"; | |
| 31 | 35 | import { Textarea } from "@executor/react/components/textarea"; |
| 32 | 36 | import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; |
| 33 | 37 | import { Skeleton } from "@executor/react/components/skeleton"; |
| 34 | 38 | import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; |
| 35 | 39 | import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; |
| 36 | 40 | 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 | +}; | |
| 38 | 50 | |
| 39 | 51 | // --------------------------------------------------------------------------- |
| 40 | 52 | // Helpers |
| @@ -79,7 +91,11 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines | ||
| 79 | 91 | |
| 80 | 92 | // After analysis |
| 81 | 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 | 99 | const identity = useSourceIdentity({ |
| 84 | 100 | fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", |
| 85 | 101 | fallbackNamespace: props.initialNamespace, |
| @@ -118,7 +134,35 @@ export default function AddOpenApiSource(props: { · 32 unmodified lines | ||
| 118 | 134 | |
| 119 | 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 | 167 | // Derive a favicon URL from the spec URL (if the user entered one — raw |
| 124 | 168 | // 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 | 200 | const canAdd = |
| 157 | 201 | preview !== null && |
| 158 | − baseUrl.trim().length > 0 && | |
| 202 | + resolvedBaseUrl.length > 0 && | |
| 159 | 203 | (customHeaders.length === 0 || customHeadersValid); |
| 160 | 204 | |
| 161 | 205 | // ---- Handlers ---- |
| @@ -171,8 +215,16 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines | ||
| 171 | 215 | }); |
| 172 | 216 | setPreview(result); |
| 173 | 217 | |
| 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 | + } | |
| 176 | 228 | |
| 177 | 229 | const firstPreset = result.headerPresets[0]; |
| 178 | 230 | if (firstPreset) { |
| @@ -228,7 +280,7 @@ export default function AddOpenApiSource(props: { · 49 unmodified lines | ||
| 228 | 280 | spec: specUrl, |
| 229 | 281 | name: identity.name.trim() || undefined, |
| 230 | 282 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 231 | − baseUrl: baseUrl.trim() || undefined, | |
| 283 | + baseUrl: resolvedBaseUrl || undefined, | |
| 232 | 284 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 233 | 285 | }, |
| 234 | 286 | }); |
| @@ -259,7 +311,9 @@ export default function AddOpenApiSource(props: { · 24 unmodified lines | ||
| 259 | 311 | setSpecUrl((e.target as HTMLTextAreaElement).value); |
| 260 | 312 | if (preview) { |
| 261 | 313 | setPreview(null); |
| 262 | − setBaseUrl(""); | |
| 314 | + setSelectedServerIndex(-1); | |
| 315 | + setCustomBaseUrl(""); | |
| 316 | + setVariableSelections({}); | |
| 263 | 317 | setCustomHeaders([]); |
| 264 | 318 | setSelectedStrategy(-1); |
| 265 | 319 | } |
| @@ -337,45 +391,125 @@ export default function AddOpenApiSource(props: { · 71 unmodified lines | ||
| 337 | 391 | <CardStack> |
| 338 | 392 | <CardStackContent className="border-t-0"> |
| 339 | 393 | <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 | + })} | |
| 368 | 496 | </div> |
| 369 | − ) : ( | |
| 497 | + )} | |
| 498 | + | |
| 499 | + {selectedServerIndex === -1 ? ( | |
| 370 | 500 | <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)} | |
| 373 | 503 | placeholder="https://api.example.com" |
| 374 | 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 | 513 | <p className="text-[11px] text-amber-600 dark:text-amber-400"> |
| 380 | 514 | A base URL is required to make requests. |
| 381 | 515 | </p> |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | OperationRequestBody, |
| 21 | 21 | type ParameterLocation, |
| 22 | 22 | ServerInfo, |
| 23 | + ServerVariable, | |
| 23 | 24 | } from "./types"; |
| 24 | 25 | |
| 25 | 26 | // --------------------------------------------------------------------------- |
| @@ -177,14 +178,31 @@ const extractServers = (doc: ParsedDocument): ServerInfo[] => · 151 unmodified lines | ||
| 177 | 178 | if (!server.url) return []; |
| 178 | 179 | const vars = server.variables |
| 179 | 180 | ? 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 | + }), | |
| 183 | 200 | ) |
| 184 | 201 | : undefined; |
| 185 | 202 | return [ |
| 186 | 203 | new ServerInfo({ |
| 187 | 204 | url: server.url, |
| 205 | + description: Option.fromNullable(server.description), | |
| 188 | 206 | variables: vars && Object.keys(vars).length > 0 ? Option.some(vars) : Option.none(), |
| 189 | 207 | }), |
| 190 | 208 | ]; |
| @@ -160,4 +160,98 @@ describe("OpenAPI plugin", () => { · 159 unmodified lines | ||
| 160 | 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 | + | |
| 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 | + ); | |
| 163 | 257 | }); |
| @@ -18,7 +18,12 @@ export { · 17 unmodified lines | ||
| 18 | 18 | PreviewOperation, |
| 19 | 19 | SpecPreview, |
| 20 | 20 | } from "./preview"; |
| 21 | −export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils"; | |
| 21 | +export { | |
| 22 | + DocResolver, | |
| 23 | + resolveBaseUrl, | |
| 24 | + substituteUrlVariables, | |
| 25 | + preferredContent, | |
| 26 | +} from "./openapi-utils"; | |
| 22 | 27 | |
| 23 | 28 | export { OpenApiParseError, OpenApiExtractionError, OpenApiInvocationError } from "./errors"; |
| 24 | 29 | |
| @@ -31,6 +36,7 @@ export { · 6 unmodified lines | ||
| 31 | 36 | OperationParameter, |
| 32 | 37 | OperationRequestBody, |
| 33 | 38 | ServerInfo, |
| 39 | + ServerVariable, | |
| 34 | 40 | OperationId, |
| 35 | 41 | HttpMethod, |
| 36 | 42 | ParameterLocation, |
| @@ -54,22 +54,36 @@ const isRef = (value: unknown): value is { $ref: string } => · 53 unmodified lines | ||
| 54 | 54 | // Server URL resolution |
| 55 | 55 | // --------------------------------------------------------------------------- |
| 56 | 56 | |
| 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>, | |
| 62 | 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 | + | |
| 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 => { | |
| 63 | 77 | const server = servers[0]; |
| 64 | 78 | if (!server) return ""; |
| 65 | 79 | |
| 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; | |
| 71 | 85 | } |
| 72 | − return url; | |
| 86 | + return substituteUrlVariables(server.url, values); | |
| 73 | 87 | }; |
| 74 | 88 | |
| 75 | 89 | // --------------------------------------------------------------------------- |
| @@ -3,7 +3,7 @@ import { Schema } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { parse } from "./parse"; |
| 5 | 5 | import { extract } from "./extract"; |
| 6 | −import { HttpMethod, type ExtractionResult } from "./types"; | |
| 6 | +import { HttpMethod, ServerInfo, type ExtractionResult } from "./types"; | |
| 7 | 7 | |
| 8 | 8 | // --------------------------------------------------------------------------- |
| 9 | 9 | // Security scheme — what the spec declares it needs |
| @@ -66,7 +66,7 @@ export class SpecPreview extends Schema.Class<SpecPreview>("SpecPreview")({ · 56 unmodified lines | ||
| 66 | 66 | title: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 67 | 67 | version: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 68 | 68 | /** Reuses ServerInfo from extraction */ |
| 69 | − servers: Schema.Array(Schema.Unknown), | |
| 69 | + servers: Schema.Array(ServerInfo), | |
| 70 | 70 | operationCount: Schema.Number, |
| 71 | 71 | /** Lightweight operation list for the add-source UI */ |
| 72 | 72 | operations: Schema.Array(PreviewOperation), |
| @@ -185,7 +185,7 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: · 112 unmodified lines | ||
| 185 | 185 | return new SpecPreview({ |
| 186 | 186 | title: result.title, |
| 187 | 187 | version: result.version, |
| 188 | − servers: result.servers as unknown as readonly unknown[], | |
| 188 | + servers: result.servers, | |
| 189 | 189 | operationCount: result.operations.length, |
| 190 | 190 | operations: result.operations.map( |
| 191 | 191 | (op) => |
| @@ -63,9 +63,16 @@ export class ExtractedOperation extends Schema.Class<ExtractedOperation>("Extrac · 62 unmodified lines | ||
| 63 | 63 | deprecated: Schema.optionalWith(Schema.Boolean, { default: () => false }), |
| 64 | 64 | }) {} |
| 65 | 65 | |
| 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 | + | |
| 66 | 72 | export class ServerInfo extends Schema.Class<ServerInfo>("ServerInfo")({ |
| 67 | 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 | 76 | as: "Option", |
| 70 | 77 | }), |
| 71 | 78 | }) {} |