| @@ -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 | |
| | 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 | |
| 65 | const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()( | 154 | const 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 | |
| | 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 | |
| 177 | const authFromSecretValue = ( | 276 | const 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 | |
| 197 | const useAvailableSecrets = ( | 300 | function 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 | |
| 212 | function OpenApiSourceForm(props: { | 428 | function 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 only | 577 | <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 | <input | 579 | <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 | <input | 592 | <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 | <input | 605 | <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 | <select | 618 | <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 | <select | 632 | 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 | <option | 639 | |
| 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 | <button | 668 | <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} operations | 680 | {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 > 0 | 707 | </dd> |
| 484 | ? preview.securitySchemes | 708 | </> |
| 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 | |
| 528 | function OpenApiAddSourcePage(props: { | 742 | function 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 | <OpenApiSourceForm | 753 | <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 | Edit | 996 | Edit |
| 783 | </button> | 997 | </button> |
| 784 | <button | 998 | {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 | }); |