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
99 defineExecutorPluginHttpApiClient,
1010 useAtomValue,
1111 useAtomSet,
12+ useCreateSecret,
1213 useExecutorMutation,
1314 usePrefetchToolDetail,
15+ useSecrets,
1416 useSourceDiscovery,
1517 useSourceInspection,
1618 useSourceToolDetail,
@@ -62,6 +64,93 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 45 unmodified lines
6264 },
6365 });
6466
67+const DEFAULT_BEARER_HEADER_NAME = "Authorization";
68+const DEFAULT_BEARER_PREFIX = "Bearer ";
69+const CREATE_SECRET_VALUE = "__create_openapi_secret__";
70+
71+const 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+
81+const 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+
95+const 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+
113+const 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+
139+const 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+
65154 const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()(
66155 "OpenApiReactHttpClient",
67156 [openApiHttpApiExtension] as const,
@@ -72,13 +161,8 @@ const Section = (props: { · 4 unmodified lines
72161 title: string;
73162 children: ReactNode;
74163 }) => (
75− <section className="rounded-xl border border-border/70 bg-card/40 p-4">
76− <div className="mb-3 flex items-center gap-2">
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>
164+ <section>
165+ <h2 className="mb-3 text-sm font-semibold text-foreground">{props.title}</h2>
82166 {props.children}
83167 </section>
84168 );
@@ -174,9 +258,26 @@ const secretValue = (input: OpenApiConnectInput["auth"]): string => · 89 unmodified lines
174258 ? JSON.stringify(input.tokenSecretRef)
175259 : "";
176260
261+const bearerHeaderNameValue = (input: OpenApiConnectInput["auth"]): string =>
262+ input.kind === "bearer"
263+ ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME
264+ : DEFAULT_BEARER_HEADER_NAME;
265+
266+const bearerPrefixValue = (input: OpenApiConnectInput["auth"]): string =>
267+ input.kind === "bearer"
268+ ? input.prefix ?? DEFAULT_BEARER_PREFIX
269+ : DEFAULT_BEARER_PREFIX;
270+
271+const trimToNull = (value: string): string | null => {
272+ const trimmed = value.trim();
273+ return trimmed.length > 0 ? trimmed : null;
274+};
275+
177276 const authFromSecretValue = (
178277 authKind: OpenApiConnectInput["auth"]["kind"],
179278 value: string,
279+ headerName: string,
280+ prefix: string,
180281 ): OpenApiConnectInput["auth"] => {
181282 if (authKind === "none") {
182283 return {
@@ -191,23 +292,138 @@ const authFromSecretValue = ( · 8 unmodified lines
191292 return {
192293 kind: "bearer",
193294 tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"],
295+ headerName: trimToNull(headerName),
296+ prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix,
194297 };
195298 };
196299
197−const useAvailableSecrets = (
198− openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>,
199−) => {
200− const secretsResult = useAtomValue(
201− openApiHttpClient.query("local", "listSecrets", {
202− reactivityKeys: {
203− secrets: [],
204− },
205− timeToLive: "1 minute",
206− }),
207− );
300+function SecretSelectOrCreateField(props: {
301+ label: string;
302+ value: string;
303+ emptyLabel: string;
304+ draftNamePlaceholder: string;
305+ draftValuePlaceholder: string;
306+ onChange: (value: string) => void;
307+}) {
308+ const secrets = useSecrets();
309+ const createSecret = useCreateSecret();
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+ }
208321
209− return Result.isSuccess(secretsResult) ? secretsResult.value : [];
210−};
322+ setShowCreate(false);
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+}
211427
212428 function OpenApiSourceForm(props: {
213429 mode: "create" | "edit";
@@ -217,7 +433,6 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
217433 onSubmit: (input: OpenApiConnectInput) => Promise<void>;
218434 }) {
219435 const openApiHttpClient = getOpenApiHttpClient();
220− const availableSecrets = useAvailableSecrets(openApiHttpClient);
221436 const previewDocument = useAtomSet(
222437 openApiHttpClient.mutation("openapi", "previewDocument"),
223438 { mode: "promise" },
@@ -232,6 +447,12 @@ function OpenApiSourceForm(props: { · 8 unmodified lines
232447 const [tokenSecretRef, setTokenSecretRef] = useState(
233448 secretValue(props.initialValue.auth),
234449 );
450+ const [authHeaderName, setAuthHeaderName] = useState(
451+ bearerHeaderNameValue(props.initialValue.auth),
452+ );
453+ const [authPrefix, setAuthPrefix] = useState(
454+ bearerPrefixValue(props.initialValue.auth),
455+ );
235456 const [error, setError] = useState<string | null>(null);
236457 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
237458 const [nameEdited, setNameEdited] = useState(false);
@@ -332,7 +553,12 @@ function OpenApiSourceForm(props: { · 94 unmodified lines
332553 }
333554
334555 try {
335− const auth = authFromSecretValue(authKind, tokenSecretRef);
556+ const auth = authFromSecretValue(
557+ authKind,
558+ tokenSecretRef,
559+ authHeaderName,
560+ authPrefix,
561+ );
336562 await submitMutation.mutateAsync({
337563 name: trimmedName,
338564 specUrl: trimmedSpecUrl,
@@ -345,15 +571,11 @@ function OpenApiSourceForm(props: { · 6 unmodified lines
345571 };
346572
347573 return (
348− <div className="space-y-4">
574+ <div className="space-y-6 rounded-xl border border-border p-6">
349575 <Section title="Connection">
350− <p className="text-sm text-muted-foreground">
351− This plugin owns its typed HTTP client and source payload shape. The app shell only
352− mounts the registered page.
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>
576+ <div className="grid gap-4">
577+ <label className="grid gap-2">
578+ <span className="text-xs font-medium text-foreground">Name</span>
357579 <input
358580 value={name}
359581 onChange={(event) => {
@@ -361,12 +583,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
361583 setName(event.target.value);
362584 }}
363585 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"
365587 />
366588 </label>
367589
368− <label className="grid gap-1.5">
369− <span className="text-[12px] font-medium text-foreground">Spec URL</span>
590+ <label className="grid gap-2">
591+ <span className="text-xs font-medium text-foreground">Spec URL</span>
370592 <input
371593 value={specUrl}
372594 onChange={(event) => setSpecUrl(event.target.value)}
@@ -374,12 +596,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
374596 void runPreview({ mode: "manual" });
375597 }}
376598 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"
378600 />
379601 </label>
380602
381− <label className="grid gap-1.5">
382− <span className="text-[12px] font-medium text-foreground">Base URL</span>
603+ <label className="grid gap-2">
604+ <span className="text-xs font-medium text-foreground">Base URL</span>
383605 <input
384606 value={baseUrl}
385607 onChange={(event) => {
@@ -387,17 +609,17 @@ function OpenApiSourceForm(props: { · 1 unmodified line
387609 setBaseUrl(event.target.value);
388610 }}
389611 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"
391613 />
392614 </label>
393615
394− <label className="grid gap-1.5">
395− <span className="text-[12px] font-medium text-foreground">Auth</span>
616+ <label className="grid gap-2">
617+ <span className="text-xs font-medium text-foreground">Auth</span>
396618 <select
397619 value={authKind}
398620 onChange={(event) =>
399621 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"
401623 >
402624 <option value="none">None</option>
403625 <option value="bearer">Bearer Secret</option>
@@ -405,52 +627,54 @@ function OpenApiSourceForm(props: { · 1 unmodified line
405627 </label>
406628
407629 {authKind === "bearer" && (
408− <label className="grid gap-1.5">
409− <span className="text-[12px] font-medium text-foreground">Secret</span>
410− <select
630+ <div className="space-y-4 border-l-2 border-border pl-4">
631+ <SecretSelectOrCreateField
632+ label="Secret"
411633 value={tokenSecretRef}
412− onChange={(event) => setTokenSecretRef(event.target.value)}
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"
414− >
415− <option value="">Select a secret</option>
416− {availableSecrets.map((secret) => (
417− <option
418− key={`${secret.providerId}:${secret.id}`}
419− value={JSON.stringify({
420− providerId: secret.providerId,
421− handle: secret.id,
422− })}
423− >
424− {secret.name || secret.id}
425− </option>
426− ))}
427− </select>
428− </label>
634+ emptyLabel="Select a secret"
635+ draftNamePlaceholder="OpenAPI bearer token"
636+ draftValuePlaceholder="Paste the token value"
637+ onChange={setTokenSecretRef}
638+ />
639+
640+ <div className="grid gap-4 sm:grid-cols-2">
641+ <label className="grid gap-2">
642+ <span className="text-xs font-medium text-foreground">Header Name</span>
643+ <input
644+ value={authHeaderName}
645+ onChange={(event) => setAuthHeaderName(event.target.value)}
646+ placeholder={DEFAULT_BEARER_HEADER_NAME}
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"
648+ />
649+ </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>
429662 )}
430663 </div>
431664 </Section>
432665
433666 <Section title="Preview">
434− <p className="text-sm text-muted-foreground">
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">
667+ <div className="flex items-center gap-3">
439668 <button
440669 type="button"
441670 onClick={() => {
442671 void runPreview({ mode: "manual" });
443672 }}
444673 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"
446675 >
447− {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"}
676+ {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"}
448677 </button>
449− {previewMutation.status === "pending" && (
450− <div className="text-xs text-muted-foreground">
451− Inferring from spec...
452− </div>
453− )}
454678 {preview && (
455679 <div className="text-xs text-muted-foreground">
456680 {preview.operationCount} operations
@@ -459,35 +683,31 @@ function OpenApiSourceForm(props: { · 2 unmodified lines
459683 )}
460684 </div>
461685 {preview && (
462− <div className="mt-4 rounded-lg border border-border/70 bg-background/60 p-4 text-sm">
463− <div className="grid gap-2">
464− <div>
465− <span className="font-medium text-foreground">Title:</span>{" "}
466− <span className="text-muted-foreground">{preview.title ?? "Unknown"}</span>
467− </div>
468− <div>
469− <span className="font-medium text-foreground">Version:</span>{" "}
470− <span className="text-muted-foreground">{preview.version ?? "Unknown"}</span>
471− </div>
472− <div>
473− <span className="font-medium text-foreground">Base URL:</span>{" "}
474− <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span>
475− </div>
476− <div>
477− <span className="font-medium text-foreground">Namespace:</span>{" "}
478− <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span>
479− </div>
480− <div>
481− <span className="font-medium text-foreground">Auth:</span>{" "}
482− <span className="text-muted-foreground">
483− {preview.securitySchemes.length > 0
484− ? preview.securitySchemes
485− .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
486− .join(", ")
487− : "No declared auth schemes"}
488− </span>
489− </div>
490− </div>
686+ <div className="mt-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
687+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
688+ <dt className="font-medium text-foreground">Title</dt>
689+ <dd className="text-muted-foreground">{preview.title ?? "—"}</dd>
690+ <dt className="font-medium text-foreground">Version</dt>
691+ <dd className="text-muted-foreground">{preview.version ?? "—"}</dd>
692+ <dt className="font-medium text-foreground">Base URL</dt>
693+ <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd>
694+ {preview.namespace && (
695+ <>
696+ <dt className="font-medium text-foreground">Namespace</dt>
697+ <dd className="text-muted-foreground">{preview.namespace}</dd>
698+ </>
699+ )}
700+ {preview.securitySchemes.length > 0 && (
701+ <>
702+ <dt className="font-medium text-foreground">Auth</dt>
703+ <dd className="text-muted-foreground">
704+ {preview.securitySchemes
705+ .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
706+ .join(", ")}
707+ </dd>
708+ </>
709+ )}
710+ </dl>
491711 {preview.warnings.length > 0 && (
492712 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">
493713 {preview.warnings.join(" ")}
@@ -497,38 +717,31 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
497717 )}
498718 </Section>
499719
500− <Section title={props.mode === "create" ? "Submit" : "Save"}>
501− {error && (
502− <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">
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>
720+ {error && (
721+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
722+ {error}
522723 </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>
524738 </div>
525739 );
526740 }
527741
528−function OpenApiAddSourcePage(props: {
529− initialValue: OpenApiConnectInput;
530−}) {
742+function OpenApiAddSourcePage() {
531743 const navigation = useSourcePluginNavigation();
744+ const initialValue = openApiInputFromSearch(useSourcePluginSearch());
532745 const openApiHttpClient = getOpenApiHttpClient();
533746 const createSource = useAtomSet(
534747 openApiHttpClient.mutation("openapi", "createSource"),
@@ -539,7 +752,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines
539752 return (
540753 <OpenApiSourceForm
541754 mode="create"
542− initialValue={props.initialValue}
755+ initialValue={initialValue}
543756 submitLabel="Create Source"
544757 busyLabel="Creating..."
545758 onSubmit={async (payload) => {
@@ -587,17 +800,17 @@ function OpenApiEditSourcePage(props: { · 41 unmodified lines
587800 if (!Result.isSuccess(configResult)) {
588801 if (Result.isFailure(configResult)) {
589802 return (
590− <Section title="OpenAPI Plugin Editor">
803+ <Section title="Edit Source">
591804 <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.
593806 </div>
594807 </Section>
595808 );
596809 }
597810
598811 return (
599− <Section title="OpenAPI Plugin Editor">
600− <div className="text-sm text-muted-foreground">Loading plugin config...</div>
812+ <Section title="Edit Source">
813+ <div className="text-sm text-muted-foreground">Loading configuration...</div>
601814 </Section>
602815 );
603816 }
@@ -692,6 +905,7 @@ function OpenApiSourceDetailPage(props: { · 88 unmodified lines
692905 limit: 20,
693906 });
694907 const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath);
908+ const [confirmDelete, setConfirmDelete] = useState(false);
695909
696910 const setRouteSearch = (next: {
697911 tab?: "model" | "discover";
@@ -781,23 +995,46 @@ function OpenApiSourceDetailPage(props: { · 83 unmodified lines
781995 <IconPencil className="size-3" />
782996 Edit
783997 </button>
784− <button
785− type="button"
786− onClick={() => {
787− const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`);
788− if (!confirmed) return;
789−
790− void removeMutation.mutateAsync(props.source.id).then(() => {
791− startTransition(() => {
792− void navigation.home();
793− });
794− });
795− }}
796− disabled={removeMutation.status === "pending"}
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"
798− >
799− {removeMutation.status === "pending" ? "Deleting..." : "Delete"}
800− </button>
998+ {confirmDelete ? (
999+ <div className="flex items-center gap-2">
1000+ <span className="text-[11px] font-medium text-destructive">
1001+ Confirm delete?
1002+ </span>
1003+ <button
1004+ type="button"
1005+ onClick={() => setConfirmDelete(false)}
1006+ disabled={removeMutation.status === "pending"}
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"
1008+ >
1009+ Cancel
1010+ </button>
1011+ <button
1012+ type="button"
1013+ onClick={() => {
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+ )}
8011038 </div>
8021039 </div>
8031040
@@ -1425,9 +1662,7 @@ const openApiSourceType = defineFrontendSourceType({ · 621 unmodified lines
14251662 key: "openapi",
14261663 kind: "openapi",
14271664 displayName: "OpenAPI",
1428− renderAddPage: () => (
1429− <OpenApiAddSourcePage initialValue={defaultOpenApiInput()} />
1430− ),
1665+ renderAddPage: OpenApiAddSourcePage,
14311666 renderEditPage: OpenApiEditSourcePage,
14321667 renderDetailPage: OpenApiSourceDetailPage,
14331668 });
plugins/openapi/sdk/index.ts+81−16
@@ -102,12 +102,49 @@ const OpenApiExecutorAddInputSchema = Schema.Struct({ · 101 unmodified lines
102102
103103 type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;
104104
105+const 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+
128+const normalizeStoredSourceData = (
129+ stored: OpenApiStoredSourceData,
130+): OpenApiStoredSourceData => ({
131+ ...stored,
132+ auth: normalizeOpenApiAuth(stored.auth as Record<string, unknown>),
133+});
134+
105135 const createStoredSourceData = (
106136 input: OpenApiConnectInput,
107137 ): OpenApiStoredSourceData => ({
108138 specUrl: input.specUrl.trim(),
109139 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,
111148 defaultHeaders: null,
112149 etag: null,
113150 lastSyncAt: null,
@@ -284,7 +321,10 @@ const createOpenApiSourceSdk = ( · 170 unmodified lines
284321 );
285322 }
286323
287− return configFromStoredSourceData(source, stored);
324+ return configFromStoredSourceData(
325+ source,
326+ normalizeStoredSourceData(stored),
327+ );
288328 }),
289329 createSource: (input: OpenApiConnectInput) =>
290330 Effect.gen(function* () {
@@ -424,6 +464,17 @@ const resolveBearerToken = ( · 133 unmodified lines
424464 );
425465 };
426466
467+const 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+
474+const resolveBearerPrefix = (
475+ auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>,
476+): string => auth.prefix ?? "Bearer ";
477+
427478 const openApiDocumentHeaders = (input: {
428479 stored: OpenApiStoredSourceData;
429480 bearerToken: string | null;
@@ -434,8 +485,15 @@ const openApiDocumentHeaders = (input: { · 4 unmodified lines
434485 for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) {
435486 headers.set(key, value);
436487 }
437− if (input.bearerToken && input.bearerToken.length > 0) {
438− headers.set("authorization", `Bearer ${input.bearerToken}`);
488+ if (
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+ );
439497 }
440498 if (input.etag) {
441499 headers.set("if-none-match", input.etag);
@@ -542,17 +600,18 @@ const createOpenApiSourceRuntime = ( · 100 unmodified lines
542600 });
543601 }
544602
545− const bearerToken = yield* resolveBearerToken(stored);
603+ const normalizedStored = normalizeStoredSourceData(stored);
604+ const bearerToken = yield* resolveBearerToken(normalizedStored);
546605 const fetched = yield* fetchOpenApiDocument({
547− stored,
606+ stored: normalizedStored,
548607 bearerToken,
549608 });
550609 const manifest = yield* extractOpenApiManifest(source.name, fetched.text, {
551− documentUrl: stored.specUrl,
610+ documentUrl: normalizedStored.specUrl,
552611 loadDocument: async (url) => {
553612 const response = await fetch(url, {
554613 headers: openApiDocumentHeaders({
555− stored,
614+ stored: normalizedStored,
556615 bearerToken,
557616 }),
558617 });
@@ -571,7 +630,7 @@ const createOpenApiSourceRuntime = ( · 12 unmodified lines
571630 scopeId: source.scopeId,
572631 sourceId: source.id,
573632 value: {
574− ...stored,
633+ ...normalizedStored,
575634 etag: fetched.etag,
576635 lastSyncAt: now,
577636 },
@@ -583,7 +642,7 @@ const createOpenApiSourceRuntime = ( · 5 unmodified lines
583642 documents: [
584643 {
585644 documentKind: "openapi",
586− documentKey: stored.specUrl,
645+ documentKey: normalizedStored.specUrl,
587646 contentText: fetched.text,
588647 fetchedAt: now,
589648 },
@@ -596,7 +655,7 @@ const createOpenApiSourceRuntime = ( · 6 unmodified lines
596655 pluginKey: "openapi",
597656 }),
598657 importerVersion: "ir.v1.openapi",
599− sourceConfigHash: stableSourceHash(stored),
658+ sourceConfigHash: stableSourceHash(normalizedStored),
600659 },
601660 sourceHash: manifest.sourceHash,
602661 });
@@ -613,6 +672,7 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines
613672 );
614673 }
615674
675+ const normalizedStored = normalizeStoredSourceData(stored);
616676 const providerData = decodeProviderData(
617677 input.executable.binding,
618678 ) as OpenApiToolProviderData;
@@ -623,7 +683,7 @@ const createOpenApiSourceRuntime = ( · 4 unmodified lines
623683 providerData.invocation,
624684 );
625685 const headers: Record<string, string> = {
626− ...(stored.defaultHeaders ?? {}),
686+ ...(normalizedStored.defaultHeaders ?? {}),
627687 };
628688 const queryEntries: Array<{
629689 name: string;
@@ -678,14 +738,19 @@ const createOpenApiSourceRuntime = ( · 48 unmodified lines
678738 }
679739 }
680740
681− const bearerToken = yield* resolveBearerToken(stored);
682− if (bearerToken && bearerToken.length > 0) {
683− headers.authorization = `Bearer ${bearerToken}`;
741+ const bearerToken = yield* resolveBearerToken(normalizedStored);
742+ if (
743+ bearerToken &&
744+ bearerToken.length > 0 &&
745+ normalizedStored.auth.kind === "bearer"
746+ ) {
747+ headers[resolveBearerHeaderName(normalizedStored.auth)] =
748+ `${resolveBearerPrefix(normalizedStored.auth)}${bearerToken}`;
684749 }
685750
686751 const requestUrl = resolveRequestUrl(
687752 resolveOpenApiBaseUrl({
688− stored,
753+ stored: normalizedStored,
689754 providerData,
690755 }),
691756 resolvedPath,
plugins/openapi/shared/index.ts+2−0
@@ -14,6 +14,8 @@ export const OpenApiConnectionAuthSchema = Schema.Union( · 13 unmodified lines
1414 Schema.Struct({
1515 kind: Schema.Literal("bearer"),
1616 tokenSecretRef: SecretRefSchema,
17+ headerName: Schema.NullOr(Schema.String),
18+ prefix: Schema.NullOr(Schema.String),
1719 }),
1820 );
1921