import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import { IntegrationSlug } from "@executor-js/sdk/shared"; import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { slugifyNamespace, useIntegrationIdentity, } from "@executor-js/react/plugins/integration-identity"; import { Button } from "@executor-js/react/components/button"; import { AuthMethodListEditor, useAuthMethodList, type AuthMethodRow, type AuthMethodSeed, } from "@executor-js/react/components/auth-method-list-editor"; import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Textarea } from "@executor-js/react/components/textarea"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { addIntegrationErrorMessage, errorMessageFromExit, FormErrorAlert, SlugCollisionAlert, useSlugAlreadyExists, } from "@executor-js/react/lib/integration-add"; import { authenticationFromEditorValue, editorValueFromAuthentication, openApiWireAuthInput, } from "./auth-method-config"; import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import { GoogleProductPicker } from "./GoogleProductPicker"; import { openApiPresets } from "../sdk/presets"; import { GOOGLE_BUNDLE_PRESET_ID, googleOpenApiPresets, type GoogleOpenApiPreset, } from "../sdk/google-presets"; import type { SpecPreview } from "../sdk/preview"; import { type Authentication } from "../sdk/types"; import { resolveServerUrl } from "../sdk/openapi-utils"; import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; // The bundle picker opens with the featured Google APIs pre-checked. const googleBundleDefaultPresetIds: ReadonlySet = new Set( googleOpenApiPresets .filter((preset: GoogleOpenApiPreset) => preset.featured) .map((preset: GoogleOpenApiPreset) => preset.id), ); const googleBundleUrls = ( selectedPresetIds: ReadonlySet, customUrls: readonly string[], ): readonly string[] => { const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) => preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [], ); // Preset URLs first (stable order), then any custom Discovery URLs, de-duped. return [...new Set([...fromPresets, ...customUrls])]; }; const isGoogleDiscoveryUrl = (url: string): boolean => { const trimmed = url.trim(); if (!URL.canParse(trimmed)) return false; const parsed = new URL(trimmed); const host = parsed.hostname.toLowerCase(); if (!host.endsWith("googleapis.com")) return false; return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); }; const normalizePresetUrl = (url: string): string => { const trimmed = url.trim(); if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); const parsed = new URL(trimmed); parsed.hash = ""; parsed.searchParams.sort(); return parsed.toString().replace(/\/$/, ""); }; const specInputForAdd = (input: string) => { const value = input.trim(); const parsed = Effect.runSyncExit( Effect.try({ try: () => new URL(value), catch: () => null, }), ); return Exit.isSuccess(parsed) ? isGoogleDiscoveryUrl(value) ? { kind: "googleDiscovery" as const, url: value } : { kind: "url" as const, url: value } : { kind: "blob" as const, value }; }; // --------------------------------------------------------------------------- // Component — single progressive form. Post-redesign: preview → addSpec // (register the integration catalog entry with ALL detected auth methods) → // route to the integration's detail hub, where the user adds accounts. The add // flow no longer creates a connection. // --------------------------------------------------------------------------- export default function AddOpenApiSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; initialUrl?: string; initialPreset?: string; initialNamespace?: string; }) { const isGoogleBundlePreset = props.initialPreset === GOOGLE_BUNDLE_PRESET_ID; // Spec input. For the Google BUNDLE preset the input is a product picker (a set // of selected Discovery URLs), not a single spec URL/blob — the merge happens // server-side via `{ kind: "googleDiscoveryBundle", urls }`, so the textarea // preview path is bypassed entirely. const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); const [selectedPresetIds, setSelectedPresetIds] = useState>( googleBundleDefaultPresetIds, ); const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState([]); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); // After analysis const [preview, setPreview] = useState(null); const [baseUrl, setBaseUrl] = useState(""); // Agent-visible description: prefilled from the spec's `info.description` // until the user types (null = untouched, keep deriving from the preview). const [descriptionDraft, setDescriptionDraft] = useState(null); const identityFallbackName = isGoogleBundlePreset ? "Google" : preview ? Option.getOrElse(preview.title, () => "") : ""; const identity = useIntegrationIdentity({ fallbackName: identityFallbackName, fallbackNamespace: props.initialNamespace ?? (isGoogleBundlePreset ? "google" : undefined), }); const bundleDiscoveryUrls = useMemo( () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls), [selectedPresetIds, customDiscoveryUrls], ); const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { setSelectedPresetIds((current: ReadonlySet) => { const next = new Set(current); if (checked) next.add(presetId); else next.delete(presetId); return next; }); }, []); const addCustomDiscoveryUrl = useCallback((url: string) => { setCustomDiscoveryUrls((current: readonly string[]) => current.includes(url) ? current : [...current, url], ); }, []); const removeCustomDiscoveryUrl = useCallback((url: string) => { setCustomDiscoveryUrls((current: readonly string[]) => current.filter((entry: string) => entry !== url), ); }, []); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); // Keep the latest handleAnalyze in a ref so the debounced effect doesn't need // it as a dependency (it closes over fresh state). const handleAnalyzeRef = useRef<() => void>(() => {}); useEffect(() => { // The bundle preset never analyzes a single spec — its input is the picker. if (isGoogleBundlePreset) return; const trimmed = specUrl.trim(); if (!trimmed) return; if (preview) return; const handle = setTimeout(() => { handleAnalyzeRef.current(); }, 400); return () => clearTimeout(handle); }, [specUrl, preview, isGoogleBundlePreset]); // ---- Derived state ---- const previewHasNoServers = preview !== null && preview.servers.length === 0; // Offer the spec's servers (resolved with defaults) as base-URL choices when // there's more than one; a single/no server uses a plain input. const baseUrlOptions = preview && preview.servers.length > 1 ? preview.servers.map((server) => { const url = resolveServerUrl(server.url, Option.getOrUndefined(server.variables), {}); return { value: url, label: url }; }) : undefined; const firstServer = preview?.servers[0]; const firstServerUrl = firstServer ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {}) : ""; const previewPresetIcon = openApiPresets.find( (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), )?.icon ?? null; const resolvedBaseUrl = baseUrl.trim(); const resolvedSourceId = slugifyNamespace(identity.namespace) || (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); const resolvedDisplayName = identity.name.trim() || (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); const resolvedDescription = descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : ""); // Register EVERY spec-detected auth method, not just a single selected one. // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the // freshly-allocated `?? []` fallback arrays. const authenticationTemplate: readonly Authentication[] = useMemo( () => detectedAuthenticationTemplates( preview?.headerPresets ?? [], preview?.oauth2Presets ?? [], resolvedBaseUrl, ), [preview, resolvedBaseUrl], ); // Editable auth methods, seeded from the spec-detected templates. The add flow // registers EVERY method (P6) — so this is a LIST, preserving multi-method // specs (e.g. apiKey + OAuth). Each seed carries the detected template's // original slug, so an unedited detected method submits with its EXACT // original slug (preserving behavior); added methods (no seed) get a // deterministic fresh slug. Re-seeded whenever a fresh detection arrives // (keyed on the detected templates, stable per analysis + base URL). const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { const labels = [ ...(preview?.headerPresets ?? []).map((preset) => preset.label), ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), ]; return authenticationTemplate.map( (template: Authentication, index: number): AuthMethodSeed => ({ value: editorValueFromAuthentication(template), slug: String(template.slug), ...(labels[index] !== undefined ? { label: labels[index] } : {}), }), ); }, [preview, authenticationTemplate]); const authMethodList = useAuthMethodList(authMethodSeeds); // The methods to register, mapped back to stored `Authentication[]`. Drops // `none` rows (nothing to register). An unedited detected method keeps its // original `seedSlug`; an added method gets a deterministic fresh slug. const editedAuthenticationTemplate: readonly Authentication[] = useMemo(() => { const templates: Authentication[] = []; authMethodList.rows.forEach((row: AuthMethodRow, index: number) => { const slug = row.seedSlug ?? (row.value.kind === "oauth" ? `oauth-${index}` : `apikey-${index}`); const template = authenticationFromEditorValue(row.value, slug); if (template !== null) templates.push(template); }); return templates; }, [authMethodList.rows]); // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration // whose slug already exists clobbers the existing one's connections/policies, // so the API blocks it. Surface that here from the tenant-scoped catalog list. const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); // The bundle path is ready once at least one Google API is selected (no // network preview gates it); the single/custom-spec path still requires a // successful preview. Both require a base URL and a free slug. const hasPreviewOrBundle = isGoogleBundlePreset ? bundleDiscoveryUrls.length > 0 : preview !== null; // The base URL is optional when the spec declares servers (resolved per call); // required only when it doesn't. const canAdd = hasPreviewOrBundle && !slugAlreadyExists && (!previewHasNoServers || resolvedBaseUrl.length > 0); // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); const exit = await doPreview({ payload: { spec: specUrl } }); if (Exit.isFailure(exit)) { setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); setAnalyzing(false); return; } const result = exit.value; setPreview(result); setBaseUrl(""); setAnalyzing(false); }; handleAnalyzeRef.current = handleAnalyze; // Persist the integration and return its slug. Registers the catalog entry // with every detected auth method. Adding a slug that already exists is // rejected by the API (`IntegrationAlreadyExistsError`) — surfaced inline. const ensureIntegration = useCallback(async (): Promise => { // The Google BUNDLE preset emits the multi-service bundle input; the server // merges the selected Discovery documents into one `google` spec and stores // the unioned `googleOAuth2` auth template (so no client template is sent). // Every other preset/custom input keeps the single-spec url/blob/discovery // branch unchanged. const specForAdd = isGoogleBundlePreset ? ({ kind: "googleDiscoveryBundle" as const, urls: [...bundleDiscoveryUrls] } satisfies { readonly kind: "googleDiscoveryBundle"; readonly urls: readonly string[]; }) : specInputForAdd(specUrl); const exit = await doAdd({ payload: { spec: specForAdd, slug: resolvedSourceId, name: resolvedDisplayName, ...(resolvedDescription.trim().length > 0 ? { description: resolvedDescription.trim() } : {}), baseUrl: resolvedBaseUrl, // Always send the edited method list (even empty) when the user has // inspected a preview: an explicit [] means "no auth methods", while // OMITTING the field tells the server to derive defaults from the // spec — which would silently resurrect methods the user deleted. // The Google bundle path stays omitted; its auth is converter-derived // server-side. ...(!isGoogleBundlePreset ? { // Serialize to the wire input dialect (apikey → request-shaped). authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput), } : {}), }, reactivityKeys: integrationWriteKeys, }); if (Exit.isFailure(exit)) { setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add integration")); return null; } return exit.value.slug; }, [ isGoogleBundlePreset, bundleDiscoveryUrls, specUrl, doAdd, resolvedSourceId, resolvedDisplayName, resolvedDescription, resolvedBaseUrl, editedAuthenticationTemplate, ]); const handleAdd = async () => { setAdding(true); setAddError(null); const integration = await ensureIntegration(); if (!integration) { setAdding(false); return; } props.onComplete(String(integration)); }; // ---- Render ---- return (

{isGoogleBundlePreset ? "Add Google" : "Add OpenAPI Integration"}

{isGoogleBundlePreset ? (

Bundle Google APIs into one integration from their Discovery documents and register their methods as tools under a single shared OAuth consent.

) : null}
{isGoogleBundlePreset ? ( ) : !preview ? (
OpenAPI Spec