History

fix ui and stuff

Rhys Sullivan committed 7 months ago

Commit 7be8c281 parent 54aa27330 files · +3970 −797Browse files

Showing the changes to plugins/openapi (3 of 30 files). Show all

Unified · Split

3 files
  1. plugins/openapi/react/index.tsx +387 −152
  2. plugins/openapi/sdk/index.ts +81 −16
  3. plugins/openapi/shared/index.ts +2 −0
plugins/openapi/react/index.tsx+387−152
@@ -9,8 +9,10 @@ import { · 8 unmodified lines
9 defineExecutorPluginHttpApiClient,9 defineExecutorPluginHttpApiClient,
10 useAtomValue,10 useAtomValue,
11 useAtomSet,11 useAtomSet,
12 useCreateSecret,
12 useExecutorMutation,13 useExecutorMutation,
13 usePrefetchToolDetail,14 usePrefetchToolDetail,
15 useSecrets,
14 useSourceDiscovery,16 useSourceDiscovery,
15 useSourceInspection,17 useSourceInspection,
16 useSourceToolDetail,18 useSourceToolDetail,
@@ -62,6 +64,93 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 45 unmodified lines
62 },64 },
63});65});
64 66
67const DEFAULT_BEARER_HEADER_NAME = "Authorization";
68const DEFAULT_BEARER_PREFIX = "Bearer ";
69const CREATE_SECRET_VALUE = "__create_openapi_secret__";
70
71const presetString = (
72 search: Record<string, unknown>,
73 key: string,
74): string | null => {
75 const value = search[key];
76 return typeof value === "string" && value.trim().length > 0
77 ? value.trim()
78 : null;
79};
80
81const firstSearchString = (
82 search: Record<string, unknown>,
83 keys: ReadonlyArray<string>,
84): string | null => {
85 for (const key of keys) {
86 const value = presetString(search, key);
87 if (value) {
88 return value;
89 }
90 }
91
92 return null;
93};
94
95const looksLikeOpenApiSpecUrl = (value: string): boolean => {
96 const lower = value.trim().toLowerCase();
97 if (!/^https?:\/\//.test(lower)) {
98 return false;
99 }
100
101 return (
102 lower.endsWith(".json")
103 || lower.endsWith(".yaml")
104 || lower.endsWith(".yml")
105 || lower.includes("/openapi")
106 || lower.includes("openapi.")
107 || lower.includes("/swagger")
108 || lower.includes("swagger.")
109 || lower.includes("/api-docs")
110 );
111};
112
113const inferOpenApiUrls = (value: string): {
114 specUrl: string;
115 baseUrl: string | null;
116} => {
117 const trimmed = value.trim();
118 if (!trimmed) {
119 return {
120 specUrl: defaultOpenApiInput().specUrl,
121 baseUrl: defaultOpenApiInput().baseUrl,
122 };
123 }
124
125 if (looksLikeOpenApiSpecUrl(trimmed)) {
126 return {
127 specUrl: trimmed,
128 baseUrl: null,
129 };
130 }
131
132 const normalizedBaseUrl = trimmed.replace(/\/+$/, "");
133 return {
134 specUrl: `${normalizedBaseUrl}/openapi.json`,
135 baseUrl: normalizedBaseUrl,
136 };
137};
138
139const openApiInputFromSearch = (
140 search: Record<string, unknown>,
141): OpenApiConnectInput => {
142 const defaults = defaultOpenApiInput();
143 const genericUrl = firstSearchString(search, ["inputUrl", "pastedUrl", "url"]);
144 const inferred = genericUrl ? inferOpenApiUrls(genericUrl) : defaults;
145
146 return {
147 ...defaults,
148 name: presetString(search, "presetName") ?? defaults.name,
149 specUrl: presetString(search, "presetSpecUrl") ?? inferred.specUrl,
150 baseUrl: presetString(search, "presetBaseUrl") ?? inferred.baseUrl,
151 };
152};
153
65const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()(154const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()(
66 "OpenApiReactHttpClient",155 "OpenApiReactHttpClient",
67 [openApiHttpApiExtension] as const,156 [openApiHttpApiExtension] as const,
@@ -72,13 +161,8 @@ const Section = (props: { · 4 unmodified lines
72 title: string;161 title: string;
73 children: ReactNode;162 children: ReactNode;
74}) => (163}) => (
75 <section className="rounded-xl border border-border/70 bg-card/40 p-4">164 <section>
76 <div className="mb-3 flex items-center gap-2">165 <h2 className="mb-3 text-sm font-semibold text-foreground">{props.title}</h2>
77 <h2 className="text-sm font-semibold">{props.title}</h2>
78 <span className="inline-flex items-center rounded-full border border-transparent bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-secondary-foreground">
79 Plugin
80 </span>
81 </div>
82 {props.children}166 {props.children}
83 </section>167 </section>
84);168);
@@ -174,9 +258,26 @@ const secretValue = (input: OpenApiConnectInput["auth"]): string => · 89 unmodified lines
174 ? JSON.stringify(input.tokenSecretRef)258 ? JSON.stringify(input.tokenSecretRef)
175 : "";259 : "";
176 260
261const bearerHeaderNameValue = (input: OpenApiConnectInput["auth"]): string =>
262 input.kind === "bearer"
263 ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME
264 : DEFAULT_BEARER_HEADER_NAME;
265
266const bearerPrefixValue = (input: OpenApiConnectInput["auth"]): string =>
267 input.kind === "bearer"
268 ? input.prefix ?? DEFAULT_BEARER_PREFIX
269 : DEFAULT_BEARER_PREFIX;
270
271const trimToNull = (value: string): string | null => {
272 const trimmed = value.trim();
273 return trimmed.length > 0 ? trimmed : null;
274};
275
177const authFromSecretValue = (276const authFromSecretValue = (
178 authKind: OpenApiConnectInput["auth"]["kind"],277 authKind: OpenApiConnectInput["auth"]["kind"],
179 value: string,278 value: string,
279 headerName: string,
280 prefix: string,
180): OpenApiConnectInput["auth"] => {281): OpenApiConnectInput["auth"] => {
181 if (authKind === "none") {282 if (authKind === "none") {
182 return {283 return {
@@ -191,23 +292,138 @@ const authFromSecretValue = ( · 8 unmodified lines
191 return {292 return {
192 kind: "bearer",293 kind: "bearer",
193 tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"],294 tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"],
295 headerName: trimToNull(headerName),
296 prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix,
194 };297 };
195};298};
196 299
197const useAvailableSecrets = (300function SecretSelectOrCreateField(props: {
198 openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>,301 label: string;
199) => {302 value: string;
200 const secretsResult = useAtomValue(303 emptyLabel: string;
201 openApiHttpClient.query("local", "listSecrets", {304 draftNamePlaceholder: string;
202 reactivityKeys: {305 draftValuePlaceholder: string;
203 secrets: [],306 onChange: (value: string) => void;
204 },307}) {
205 timeToLive: "1 minute",308 const secrets = useSecrets();
206 }),309 const createSecret = useCreateSecret();
207 );310 const [draftName, setDraftName] = useState("");
311 const [draftValue, setDraftValue] = useState("");
312 const [createError, setCreateError] = useState<string | null>(null);
313 const [showCreate, setShowCreate] = useState(false);
314
315 const handleSelectChange = (nextValue: string) => {
316 if (nextValue === CREATE_SECRET_VALUE) {
317 setShowCreate(true);
318 props.onChange("");
319 return;
320 }
208 321
209 return Result.isSuccess(secretsResult) ? secretsResult.value : [];322 setShowCreate(false);
210};323 setCreateError(null);
324 props.onChange(nextValue);
325 };
326
327 const handleCreate = async () => {
328 const trimmedName = draftName.trim();
329 if (!trimmedName) {
330 setCreateError("Secret name is required.");
331 return;
332 }
333 if (!draftValue.trim()) {
334 setCreateError("Secret value is required.");
335 return;
336 }
337
338 try {
339 setCreateError(null);
340 const created = await createSecret.mutateAsync({
341 name: trimmedName,
342 value: draftValue,
343 });
344 props.onChange(JSON.stringify({
345 providerId: created.providerId,
346 handle: created.id,
347 }));
348 setDraftName("");
349 setDraftValue("");
350 setShowCreate(false);
351 } catch (cause) {
352 setCreateError(cause instanceof Error ? cause.message : "Failed creating secret.");
353 }
354 };
355
356 return (
357 <div className="grid gap-2">
358 <span className="text-xs font-medium text-foreground">{props.label}</span>
359 <select
360 value={showCreate ? CREATE_SECRET_VALUE : props.value}
361 onChange={(event) => handleSelectChange(event.target.value)}
362 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
363 >
364 <option value="">{props.emptyLabel}</option>
365 {secrets.status === "ready" &&
366 secrets.data.map((secret) => (
367 <option
368 key={`${secret.providerId}:${secret.id}`}
369 value={JSON.stringify({
370 providerId: secret.providerId,
371 handle: secret.id,
372 })}
373 >
374 {secret.name ?? secret.id}
375 </option>
376 ))}
377 <option value={CREATE_SECRET_VALUE}>Create new secret</option>
378 </select>
379
380 {showCreate && (
381 <div className="space-y-3 border-l-2 border-border pl-4">
382 <input
383 value={draftName}
384 onChange={(event) => setDraftName(event.target.value)}
385 placeholder={props.draftNamePlaceholder}
386 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
387 />
388 <textarea
389 value={draftValue}
390 onChange={(event) => setDraftValue(event.target.value)}
391 rows={3}
392 placeholder={props.draftValuePlaceholder}
393 className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
394 />
395 {createError && (
396 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive">
397 {createError}
398 </div>
399 )}
400 <div className="flex items-center gap-2">
401 <button
402 type="button"
403 onClick={() => {
404 void handleCreate();
405 }}
406 disabled={createSecret.status === "pending"}
407 className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50"
408 >
409 {createSecret.status === "pending" ? "Creating..." : "Create Secret"}
410 </button>
411 <button
412 type="button"
413 onClick={() => {
414 setShowCreate(false);
415 setCreateError(null);
416 }}
417 className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
418 >
419 Cancel
420 </button>
421 </div>
422 </div>
423 )}
424 </div>
425 );
426}
211 427
212function OpenApiSourceForm(props: {428function OpenApiSourceForm(props: {
213 mode: "create" | "edit";429 mode: "create" | "edit";
@@ -217,7 +433,6 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
217 onSubmit: (input: OpenApiConnectInput) => Promise<void>;433 onSubmit: (input: OpenApiConnectInput) => Promise<void>;
218}) {434}) {
219 const openApiHttpClient = getOpenApiHttpClient();435 const openApiHttpClient = getOpenApiHttpClient();
220 const availableSecrets = useAvailableSecrets(openApiHttpClient);
221 const previewDocument = useAtomSet(436 const previewDocument = useAtomSet(
222 openApiHttpClient.mutation("openapi", "previewDocument"),437 openApiHttpClient.mutation("openapi", "previewDocument"),
223 { mode: "promise" },438 { mode: "promise" },
@@ -232,6 +447,12 @@ function OpenApiSourceForm(props: { · 8 unmodified lines
232 const [tokenSecretRef, setTokenSecretRef] = useState(447 const [tokenSecretRef, setTokenSecretRef] = useState(
233 secretValue(props.initialValue.auth),448 secretValue(props.initialValue.auth),
234 );449 );
450 const [authHeaderName, setAuthHeaderName] = useState(
451 bearerHeaderNameValue(props.initialValue.auth),
452 );
453 const [authPrefix, setAuthPrefix] = useState(
454 bearerPrefixValue(props.initialValue.auth),
455 );
235 const [error, setError] = useState<string | null>(null);456 const [error, setError] = useState<string | null>(null);
236 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);457 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
237 const [nameEdited, setNameEdited] = useState(false);458 const [nameEdited, setNameEdited] = useState(false);
@@ -332,7 +553,12 @@ function OpenApiSourceForm(props: { · 94 unmodified lines
332 }553 }
333 554
334 try {555 try {
335 const auth = authFromSecretValue(authKind, tokenSecretRef);556 const auth = authFromSecretValue(
557 authKind,
558 tokenSecretRef,
559 authHeaderName,
560 authPrefix,
561 );
336 await submitMutation.mutateAsync({562 await submitMutation.mutateAsync({
337 name: trimmedName,563 name: trimmedName,
338 specUrl: trimmedSpecUrl,564 specUrl: trimmedSpecUrl,
@@ -345,15 +571,11 @@ function OpenApiSourceForm(props: { · 6 unmodified lines
345 };571 };
346 572
347 return (573 return (
348 <div className="space-y-4">574 <div className="space-y-6 rounded-xl border border-border p-6">
349 <Section title="Connection">575 <Section title="Connection">
350 <p className="text-sm text-muted-foreground">576 <div className="grid gap-4">
351 This plugin owns its typed HTTP client and source payload shape. The app shell only577 <label className="grid gap-2">
352 mounts the registered page.578 <span className="text-xs font-medium text-foreground">Name</span>
353 </p>
354 <div className="mt-4 grid gap-4">
355 <label className="grid gap-1.5">
356 <span className="text-[12px] font-medium text-foreground">Name</span>
357 <input579 <input
358 value={name}580 value={name}
359 onChange={(event) => {581 onChange={(event) => {
@@ -361,12 +583,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
361 setName(event.target.value);583 setName(event.target.value);
362 }}584 }}
363 placeholder="GitHub REST"585 placeholder="GitHub REST"
364 className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"586 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
365 />587 />
366 </label>588 </label>
367 589
368 <label className="grid gap-1.5">590 <label className="grid gap-2">
369 <span className="text-[12px] font-medium text-foreground">Spec URL</span>591 <span className="text-xs font-medium text-foreground">Spec URL</span>
370 <input592 <input
371 value={specUrl}593 value={specUrl}
372 onChange={(event) => setSpecUrl(event.target.value)}594 onChange={(event) => setSpecUrl(event.target.value)}
@@ -374,12 +596,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
374 void runPreview({ mode: "manual" });596 void runPreview({ mode: "manual" });
375 }}597 }}
376 placeholder="https://example.com/openapi.json"598 placeholder="https://example.com/openapi.json"
377 className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"599 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
378 />600 />
379 </label>601 </label>
380 602
381 <label className="grid gap-1.5">603 <label className="grid gap-2">
382 <span className="text-[12px] font-medium text-foreground">Base URL</span>604 <span className="text-xs font-medium text-foreground">Base URL</span>
383 <input605 <input
384 value={baseUrl}606 value={baseUrl}
385 onChange={(event) => {607 onChange={(event) => {
@@ -387,17 +609,17 @@ function OpenApiSourceForm(props: { · 1 unmodified line
387 setBaseUrl(event.target.value);609 setBaseUrl(event.target.value);
388 }}610 }}
389 placeholder="https://api.example.com"611 placeholder="https://api.example.com"
390 className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"612 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
391 />613 />
392 </label>614 </label>
393 615
394 <label className="grid gap-1.5">616 <label className="grid gap-2">
395 <span className="text-[12px] font-medium text-foreground">Auth</span>617 <span className="text-xs font-medium text-foreground">Auth</span>
396 <select618 <select
397 value={authKind}619 value={authKind}
398 onChange={(event) =>620 onChange={(event) =>
399 setAuthKind(event.target.value as OpenApiConnectInput["auth"]["kind"])}621 setAuthKind(event.target.value as OpenApiConnectInput["auth"]["kind"])}
400 className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"622 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
401 >623 >
402 <option value="none">None</option>624 <option value="none">None</option>
403 <option value="bearer">Bearer Secret</option>625 <option value="bearer">Bearer Secret</option>
@@ -405,52 +627,54 @@ function OpenApiSourceForm(props: { · 1 unmodified line
405 </label>627 </label>
406 628
407 {authKind === "bearer" && (629 {authKind === "bearer" && (
408 <label className="grid gap-1.5">630 <div className="space-y-4 border-l-2 border-border pl-4">
409 <span className="text-[12px] font-medium text-foreground">Secret</span>631 <SecretSelectOrCreateField
410 <select632 label="Secret"
411 value={tokenSecretRef}633 value={tokenSecretRef}
412 onChange={(event) => setTokenSecretRef(event.target.value)}634 emptyLabel="Select a secret"
413 className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"635 draftNamePlaceholder="OpenAPI bearer token"
414 >636 draftValuePlaceholder="Paste the token value"
415 <option value="">Select a secret</option>637 onChange={setTokenSecretRef}
416 {availableSecrets.map((secret) => (638 />
417 <option639
418 key={`${secret.providerId}:${secret.id}`}640 <div className="grid gap-4 sm:grid-cols-2">
419 value={JSON.stringify({641 <label className="grid gap-2">
420 providerId: secret.providerId,642 <span className="text-xs font-medium text-foreground">Header Name</span>
421 handle: secret.id,643 <input
422 })}644 value={authHeaderName}
423 >645 onChange={(event) => setAuthHeaderName(event.target.value)}
424 {secret.name || secret.id}646 placeholder={DEFAULT_BEARER_HEADER_NAME}
425 </option>647 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
426 ))}648 />
427 </select>649 </label>
428 </label>650
651 <label className="grid gap-2">
652 <span className="text-xs font-medium text-foreground">Prefix</span>
653 <input
654 value={authPrefix}
655 onChange={(event) => setAuthPrefix(event.target.value)}
656 placeholder={DEFAULT_BEARER_PREFIX}
657 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
658 />
659 </label>
660 </div>
661 </div>
429 )}662 )}
430 </div>663 </div>
431 </Section>664 </Section>
432 665
433 <Section title="Preview">666 <Section title="Preview">
434 <p className="text-sm text-muted-foreground">667 <div className="flex items-center gap-3">
435 Preview introspects the document and can pull out defaults like title and base URL from
436 the OpenAPI spec.
437 </p>
438 <div className="mt-3 flex items-center gap-3">
439 <button668 <button
440 type="button"669 type="button"
441 onClick={() => {670 onClick={() => {
442 void runPreview({ mode: "manual" });671 void runPreview({ mode: "manual" });
443 }}672 }}
444 disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}673 disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}
445 className="inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium text-foreground disabled:pointer-events-none disabled:opacity-50"674 className="inline-flex h-9 items-center justify-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50"
446 >675 >
447 {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"}676 {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"}
448 </button>677 </button>
449 {previewMutation.status === "pending" && (
450 <div className="text-xs text-muted-foreground">
451 Inferring from spec...
452 </div>
453 )}
454 {preview && (678 {preview && (
455 <div className="text-xs text-muted-foreground">679 <div className="text-xs text-muted-foreground">
456 {preview.operationCount} operations680 {preview.operationCount} operations
@@ -459,35 +683,31 @@ function OpenApiSourceForm(props: { · 2 unmodified lines
459 )}683 )}
460 </div>684 </div>
461 {preview && (685 {preview && (
462 <div className="mt-4 rounded-lg border border-border/70 bg-background/60 p-4 text-sm">686 <div className="mt-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
463 <div className="grid gap-2">687 <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
464 <div>688 <dt className="font-medium text-foreground">Title</dt>
465 <span className="font-medium text-foreground">Title:</span>{" "}689 <dd className="text-muted-foreground">{preview.title ?? "—"}</dd>
466 <span className="text-muted-foreground">{preview.title ?? "Unknown"}</span>690 <dt className="font-medium text-foreground">Version</dt>
467 </div>691 <dd className="text-muted-foreground">{preview.version ?? "—"}</dd>
468 <div>692 <dt className="font-medium text-foreground">Base URL</dt>
469 <span className="font-medium text-foreground">Version:</span>{" "}693 <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd>
470 <span className="text-muted-foreground">{preview.version ?? "Unknown"}</span>694 {preview.namespace && (
471 </div>695 <>
472 <div>696 <dt className="font-medium text-foreground">Namespace</dt>
473 <span className="font-medium text-foreground">Base URL:</span>{" "}697 <dd className="text-muted-foreground">{preview.namespace}</dd>
474 <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span>698 </>
475 </div>699 )}
476 <div>700 {preview.securitySchemes.length > 0 && (
477 <span className="font-medium text-foreground">Namespace:</span>{" "}701 <>
478 <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span>702 <dt className="font-medium text-foreground">Auth</dt>
479 </div>703 <dd className="text-muted-foreground">
480 <div>704 {preview.securitySchemes
481 <span className="font-medium text-foreground">Auth:</span>{" "}705 .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
482 <span className="text-muted-foreground">706 .join(", ")}
483 {preview.securitySchemes.length > 0707 </dd>
484 ? preview.securitySchemes708 </>
485 .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)709 )}
486 .join(", ")710 </dl>
487 : "No declared auth schemes"}
488 </span>
489 </div>
490 </div>
491 {preview.warnings.length > 0 && (711 {preview.warnings.length > 0 && (
492 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">712 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">
493 {preview.warnings.join(" ")}713 {preview.warnings.join(" ")}
@@ -497,38 +717,31 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
497 )}717 )}
498 </Section>718 </Section>
499 719
500 <Section title={props.mode === "create" ? "Submit" : "Save"}>720 {error && (
501 {error && (721 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
502 <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">722 {error}
503 {error}
504 </div>
505 )}
506 <div className="flex items-center gap-3">
507 <button
508 type="button"
509 onClick={() => {
510 void handleSubmit();
511 }}
512 disabled={submitMutation.status === "pending"}
513 className="inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground disabled:pointer-events-none disabled:opacity-50"
514 >
515 {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
516 </button>
517 <div className="text-xs text-muted-foreground">
518 {props.mode === "create"
519 ? "Creates a real source and immediately imports its tools."
520 : "Updates plugin-owned config and refreshes the imported model."}
521 </div>
522 </div>723 </div>
523 </Section>724 )}
725
726 <div className="flex items-center justify-end">
727 <button
728 type="button"
729 onClick={() => {
730 void handleSubmit();
731 }}
732 disabled={submitMutation.status === "pending"}
733 className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
734 >
735 {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
736 </button>
737 </div>
524 </div>738 </div>
525 );739 );
526}740}
527 741
528function OpenApiAddSourcePage(props: {742function OpenApiAddSourcePage() {
529 initialValue: OpenApiConnectInput;
530}) {
531 const navigation = useSourcePluginNavigation();743 const navigation = useSourcePluginNavigation();
744 const initialValue = openApiInputFromSearch(useSourcePluginSearch());
532 const openApiHttpClient = getOpenApiHttpClient();745 const openApiHttpClient = getOpenApiHttpClient();
533 const createSource = useAtomSet(746 const createSource = useAtomSet(
534 openApiHttpClient.mutation("openapi", "createSource"),747 openApiHttpClient.mutation("openapi", "createSource"),
@@ -539,7 +752,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines
539 return (752 return (
540 <OpenApiSourceForm753 <OpenApiSourceForm
541 mode="create"754 mode="create"
542 initialValue={props.initialValue}755 initialValue={initialValue}
543 submitLabel="Create Source"756 submitLabel="Create Source"
544 busyLabel="Creating..."757 busyLabel="Creating..."
545 onSubmit={async (payload) => {758 onSubmit={async (payload) => {
@@ -587,17 +800,17 @@ function OpenApiEditSourcePage(props: { · 41 unmodified lines
587 if (!Result.isSuccess(configResult)) {800 if (!Result.isSuccess(configResult)) {
588 if (Result.isFailure(configResult)) {801 if (Result.isFailure(configResult)) {
589 return (802 return (
590 <Section title="OpenAPI Plugin Editor">803 <Section title="Edit Source">
591 <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive">804 <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive">
592 Failed loading plugin config.805 Failed loading source configuration.
593 </div>806 </div>
594 </Section>807 </Section>
595 );808 );
596 }809 }
597 810
598 return (811 return (
599 <Section title="OpenAPI Plugin Editor">812 <Section title="Edit Source">
600 <div className="text-sm text-muted-foreground">Loading plugin config...</div>813 <div className="text-sm text-muted-foreground">Loading configuration...</div>
601 </Section>814 </Section>
602 );815 );
603 }816 }
@@ -692,6 +905,7 @@ function OpenApiSourceDetailPage(props: { · 88 unmodified lines
692 limit: 20,905 limit: 20,
693 });906 });
694 const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath);907 const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath);
908 const [confirmDelete, setConfirmDelete] = useState(false);
695 909
696 const setRouteSearch = (next: {910 const setRouteSearch = (next: {
697 tab?: "model" | "discover";911 tab?: "model" | "discover";
@@ -781,23 +995,46 @@ function OpenApiSourceDetailPage(props: { · 83 unmodified lines
781 <IconPencil className="size-3" />995 <IconPencil className="size-3" />
782 Edit996 Edit
783 </button>997 </button>
784 <button998 {confirmDelete ? (
785 type="button"999 <div className="flex items-center gap-2">
786 onClick={() => {1000 <span className="text-[11px] font-medium text-destructive">
787 const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`);1001 Confirm delete?
788 if (!confirmed) return;1002 </span>
789 1003 <button
790 void removeMutation.mutateAsync(props.source.id).then(() => {1004 type="button"
791 startTransition(() => {1005 onClick={() => setConfirmDelete(false)}
792 void navigation.home();1006 disabled={removeMutation.status === "pending"}
793 });1007 className="inline-flex items-center rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
794 });1008 >
795 }}1009 Cancel
796 disabled={removeMutation.status === "pending"}1010 </button>
797 className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"1011 <button
798 >1012 type="button"
799 {removeMutation.status === "pending" ? "Deleting..." : "Delete"}1013 onClick={() => {
800 </button>1014 void removeMutation.mutateAsync(props.source.id).then(() => {
1015 startTransition(() => {
1016 void navigation.home();
1017 });
1018 }).finally(() => {
1019 setConfirmDelete(false);
1020 });
1021 }}
1022 disabled={removeMutation.status === "pending"}
1023 className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1024 >
1025 {removeMutation.status === "pending" ? "Deleting..." : "Delete"}
1026 </button>
1027 </div>
1028 ) : (
1029 <button
1030 type="button"
1031 onClick={() => setConfirmDelete(true)}
1032 disabled={removeMutation.status === "pending"}
1033 className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1034 >
1035 Delete
1036 </button>
1037 )}
801 </div>1038 </div>
802 </div>1039 </div>
803 1040
@@ -1425,9 +1662,7 @@ const openApiSourceType = defineFrontendSourceType({ · 621 unmodified lines
1425 key: "openapi",1662 key: "openapi",
1426 kind: "openapi",1663 kind: "openapi",
1427 displayName: "OpenAPI",1664 displayName: "OpenAPI",
1428 renderAddPage: () => (1665 renderAddPage: OpenApiAddSourcePage,
1429 <OpenApiAddSourcePage initialValue={defaultOpenApiInput()} />
1430 ),
1431 renderEditPage: OpenApiEditSourcePage,1666 renderEditPage: OpenApiEditSourcePage,
1432 renderDetailPage: OpenApiSourceDetailPage,1667 renderDetailPage: OpenApiSourceDetailPage,
1433});1668});
plugins/openapi/sdk/index.ts+81−16
@@ -102,12 +102,49 @@ const OpenApiExecutorAddInputSchema = Schema.Struct({ · 101 unmodified lines
102 102
103type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;103type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;
104 104
105const normalizeOpenApiAuth = (
106 auth: Record<string, unknown>,
107): OpenApiStoredSourceData["auth"] =>
108 auth.kind === "bearer"
109 ? {
110 kind: "bearer",
111 tokenSecretRef: auth.tokenSecretRef as Extract<
112 OpenApiStoredSourceData["auth"],
113 { kind: "bearer" }
114 >["tokenSecretRef"],
115 headerName:
116 typeof auth.headerName === "string" || auth.headerName === null
117 ? auth.headerName
118 : null,
119 prefix:
120 typeof auth.prefix === "string" || auth.prefix === null
121 ? auth.prefix
122 : null,
123 }
124 : {
125 kind: "none",
126 };
127
128const normalizeStoredSourceData = (
129 stored: OpenApiStoredSourceData,
130): OpenApiStoredSourceData => ({
131 ...stored,
132 auth: normalizeOpenApiAuth(stored.auth as Record<string, unknown>),
133});
134
105const createStoredSourceData = (135const createStoredSourceData = (
106 input: OpenApiConnectInput,136 input: OpenApiConnectInput,
107): OpenApiStoredSourceData => ({137): OpenApiStoredSourceData => ({
108 specUrl: input.specUrl.trim(),138 specUrl: input.specUrl.trim(),
109 baseUrl: input.baseUrl?.trim() || null,139 baseUrl: input.baseUrl?.trim() || null,
110 auth: input.auth,140 auth:
141 input.auth.kind === "bearer"
142 ? {
143 ...input.auth,
144 headerName: input.auth.headerName ?? null,
145 prefix: input.auth.prefix ?? null,
146 }
147 : input.auth,
111 defaultHeaders: null,148 defaultHeaders: null,
112 etag: null,149 etag: null,
113 lastSyncAt: null,150 lastSyncAt: null,
@@ -284,7 +321,10 @@ const createOpenApiSourceSdk = ( · 170 unmodified lines
284 );321 );
285 }322 }
286 323
287 return configFromStoredSourceData(source, stored);324 return configFromStoredSourceData(
325 source,
326 normalizeStoredSourceData(stored),
327 );
288 }),328 }),
289 createSource: (input: OpenApiConnectInput) =>329 createSource: (input: OpenApiConnectInput) =>
290 Effect.gen(function* () {330 Effect.gen(function* () {
@@ -424,6 +464,17 @@ const resolveBearerToken = ( · 133 unmodified lines
424 );464 );
425};465};
426 466
467const resolveBearerHeaderName = (
468 auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>,
469): string => {
470 const trimmed = auth.headerName?.trim();
471 return trimmed && trimmed.length > 0 ? trimmed : "Authorization";
472};
473
474const resolveBearerPrefix = (
475 auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>,
476): string => auth.prefix ?? "Bearer ";
477
427const openApiDocumentHeaders = (input: {478const openApiDocumentHeaders = (input: {
428 stored: OpenApiStoredSourceData;479 stored: OpenApiStoredSourceData;
429 bearerToken: string | null;480 bearerToken: string | null;
@@ -434,8 +485,15 @@ const openApiDocumentHeaders = (input: { · 4 unmodified lines
434 for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) {485 for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) {
435 headers.set(key, value);486 headers.set(key, value);
436 }487 }
437 if (input.bearerToken && input.bearerToken.length > 0) {488 if (
438 headers.set("authorization", `Bearer ${input.bearerToken}`);489 input.bearerToken &&
490 input.bearerToken.length > 0 &&
491 input.stored.auth.kind === "bearer"
492 ) {
493 headers.set(
494 resolveBearerHeaderName(input.stored.auth),
495 `${resolveBearerPrefix(input.stored.auth)}${input.bearerToken}`,
496 );
439 }497 }
440 if (input.etag) {498 if (input.etag) {
441 headers.set("if-none-match", input.etag);499 headers.set("if-none-match", input.etag);
@@ -542,17 +600,18 @@ const createOpenApiSourceRuntime = ( · 100 unmodified lines
542 });600 });
543 }601 }
544 602
545 const bearerToken = yield* resolveBearerToken(stored);603 const normalizedStored = normalizeStoredSourceData(stored);
604 const bearerToken = yield* resolveBearerToken(normalizedStored);
546 const fetched = yield* fetchOpenApiDocument({605 const fetched = yield* fetchOpenApiDocument({
547 stored,606 stored: normalizedStored,
548 bearerToken,607 bearerToken,
549 });608 });
550 const manifest = yield* extractOpenApiManifest(source.name, fetched.text, {609 const manifest = yield* extractOpenApiManifest(source.name, fetched.text, {
551 documentUrl: stored.specUrl,610 documentUrl: normalizedStored.specUrl,
552 loadDocument: async (url) => {611 loadDocument: async (url) => {
553 const response = await fetch(url, {612 const response = await fetch(url, {
554 headers: openApiDocumentHeaders({613 headers: openApiDocumentHeaders({
555 stored,614 stored: normalizedStored,
556 bearerToken,615 bearerToken,
557 }),616 }),
558 });617 });
@@ -571,7 +630,7 @@ const createOpenApiSourceRuntime = ( · 12 unmodified lines
571 scopeId: source.scopeId,630 scopeId: source.scopeId,
572 sourceId: source.id,631 sourceId: source.id,
573 value: {632 value: {
574 ...stored,633 ...normalizedStored,
575 etag: fetched.etag,634 etag: fetched.etag,
576 lastSyncAt: now,635 lastSyncAt: now,
577 },636 },
@@ -583,7 +642,7 @@ const createOpenApiSourceRuntime = ( · 5 unmodified lines
583 documents: [642 documents: [
584 {643 {
585 documentKind: "openapi",644 documentKind: "openapi",
586 documentKey: stored.specUrl,645 documentKey: normalizedStored.specUrl,
587 contentText: fetched.text,646 contentText: fetched.text,
588 fetchedAt: now,647 fetchedAt: now,
589 },648 },
@@ -596,7 +655,7 @@ const createOpenApiSourceRuntime = ( · 6 unmodified lines
596 pluginKey: "openapi",655 pluginKey: "openapi",
597 }),656 }),
598 importerVersion: "ir.v1.openapi",657 importerVersion: "ir.v1.openapi",
599 sourceConfigHash: stableSourceHash(stored),658 sourceConfigHash: stableSourceHash(normalizedStored),
600 },659 },
601 sourceHash: manifest.sourceHash,660 sourceHash: manifest.sourceHash,
602 });661 });
@@ -613,6 +672,7 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines
613 );672 );
614 }673 }
615 674
675 const normalizedStored = normalizeStoredSourceData(stored);
616 const providerData = decodeProviderData(676 const providerData = decodeProviderData(
617 input.executable.binding,677 input.executable.binding,
618 ) as OpenApiToolProviderData;678 ) as OpenApiToolProviderData;
@@ -623,7 +683,7 @@ const createOpenApiSourceRuntime = ( · 4 unmodified lines
623 providerData.invocation,683 providerData.invocation,
624 );684 );
625 const headers: Record<string, string> = {685 const headers: Record<string, string> = {
626 ...(stored.defaultHeaders ?? {}),686 ...(normalizedStored.defaultHeaders ?? {}),
627 };687 };
628 const queryEntries: Array<{688 const queryEntries: Array<{
629 name: string;689 name: string;
@@ -678,14 +738,19 @@ const createOpenApiSourceRuntime = ( · 48 unmodified lines
678 }738 }
679 }739 }
680 740
681 const bearerToken = yield* resolveBearerToken(stored);741 const bearerToken = yield* resolveBearerToken(normalizedStored);
682 if (bearerToken && bearerToken.length > 0) {742 if (
683 headers.authorization = `Bearer ${bearerToken}`;743 bearerToken &&
744 bearerToken.length > 0 &&
745 normalizedStored.auth.kind === "bearer"
746 ) {
747 headers[resolveBearerHeaderName(normalizedStored.auth)] =
748 `${resolveBearerPrefix(normalizedStored.auth)}${bearerToken}`;
684 }749 }
685 750
686 const requestUrl = resolveRequestUrl(751 const requestUrl = resolveRequestUrl(
687 resolveOpenApiBaseUrl({752 resolveOpenApiBaseUrl({
688 stored,753 stored: normalizedStored,
689 providerData,754 providerData,
690 }),755 }),
691 resolvedPath,756 resolvedPath,
plugins/openapi/shared/index.ts+2−0
@@ -14,6 +14,8 @@ export const OpenApiConnectionAuthSchema = Schema.Union( · 13 unmodified lines
14 Schema.Struct({14 Schema.Struct({
15 kind: Schema.Literal("bearer"),15 kind: Schema.Literal("bearer"),
16 tokenSecretRef: SecretRefSchema,16 tokenSecretRef: SecretRefSchema,
17 headerName: Schema.NullOr(Schema.String),
18 prefix: Schema.NullOr(Schema.String),
17 }),19 }),
18);20);
19 21