import { useState, useEffect, useRef } from "react"; import { useAtomSet } from "@effect-atom/atom-react"; import { Option } from "effect"; import { useScope } from "@executor/react/api/scope-context"; import { SecretHeaderAuthRow, defaultHeaderAuthPresets, } from "@executor/react/plugins/secret-header-auth"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { Textarea } from "@executor/react/components/textarea"; import { Badge } from "@executor/react/components/badge"; import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; import { Spinner } from "@executor/react/components/spinner"; import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; import type { SpecPreview, HeaderPreset } from "../sdk/preview"; import type { HeaderValue } from "../sdk/types"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function prefixForHeader(preset: HeaderPreset, headerName: string): string | undefined { const label = preset.label.toLowerCase(); if (headerName.toLowerCase() === "authorization") { if (label.includes("bearer")) return "Bearer "; if (label.includes("basic")) return "Basic "; } return undefined; } function matchPresetKey(name: string, prefix?: string): string { const preset = defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === prefix) ?? defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === undefined); return preset?.key ?? "custom"; } function presetEntriesFromHeaderPreset(preset: HeaderPreset) { return preset.secretHeaders.map((headerName) => { const prefix = prefixForHeader(preset, headerName); return { name: headerName, secretId: null as string | null, prefix, presetKey: matchPresetKey(headerName, prefix), fromPreset: true, }; }); } // --------------------------------------------------------------------------- // Main component — single progressive form // --------------------------------------------------------------------------- export default function AddOpenApiSource(props: { onComplete: () => void; onCancel: () => void; initialUrl?: string; initialNamespace?: string; }) { // Spec input const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); // After analysis const [preview, setPreview] = useState(null); const [baseUrl, setBaseUrl] = useState(""); const [namespace, setNamespace] = useState(props.initialNamespace ?? ""); const [sourceName, setSourceName] = useState(""); // Auth const [presetIndex, setPresetIndex] = useState(0); const [customHeaders, setCustomHeaders] = useState< Array<{ name: string; secretId: string | null; prefix?: string; presetKey?: string; fromPreset?: boolean; }> >([]); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); const scopeId = useScope(); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); const secretList = useSecretPickerSecrets(); const autoAnalyzed = useRef(false); useEffect(() => { if (props.initialUrl && !autoAnalyzed.current) { autoAnalyzed.current = true; handleAnalyze(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps // ---- Derived state ---- const presets = preview?.headerPresets ?? []; const hasAuth = presets.length > 0; const servers = (preview?.servers ?? []) as Array<{ url?: string }>; const allHeaders: Record = {}; for (const ch of customHeaders) { if (ch.name.trim() && ch.secretId) { allHeaders[ch.name.trim()] = { secretId: ch.secretId, ...(ch.prefix ? { prefix: ch.prefix } : {}), }; } } const hasHeaders = Object.keys(allHeaders).length > 0; const customHeadersValid = customHeaders.every((ch) => ch.name.trim() && ch.secretId); const canAdd = preview !== null && baseUrl.trim().length > 0 && (customHeaders.length === 0 || customHeadersValid); // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); try { const result = await doPreview({ path: { scopeId }, payload: { spec: specUrl }, }); setPreview(result); // Derive defaults from the title const title = Option.getOrElse(result.title, () => "api"); if (!sourceName) setSourceName(title); if (!props.initialNamespace) { setNamespace( title .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") || "api", ); } const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url; if (firstUrl) setBaseUrl(firstUrl); const newPresetIndex = result.headerPresets.length > 0 ? 0 : -1; setPresetIndex(newPresetIndex); setCustomHeaders( newPresetIndex >= 0 ? presetEntriesFromHeaderPreset(result.headerPresets[newPresetIndex]) : [], ); } catch (e) { setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec"); } finally { setAnalyzing(false); } }; const selectPreset = (index: number) => { setPresetIndex(index); if (index === -1) { // "None" — clear everything setCustomHeaders([]); } else if (index === -2) { // "Custom" — keep user headers, drop preset-derived, seed if empty const userHeaders = customHeaders.filter((h) => !h.fromPreset); setCustomHeaders( userHeaders.length > 0 ? userHeaders : [{ name: "", secretId: null, presetKey: undefined }], ); } else { // Preset strategy — replace preset-derived headers, keep user headers const preset = presets[index]; const userHeaders = customHeaders.filter((h) => !h.fromPreset); setCustomHeaders( preset ? [...presetEntriesFromHeaderPreset(preset), ...userHeaders] : userHeaders, ); } }; const addCustomHeader = () => { if (presetIndex === -1) setPresetIndex(-2); setCustomHeaders([...customHeaders, { name: "", secretId: null, presetKey: undefined }]); }; const updateCustomHeader = ( index: number, update: Partial<{ name: string; secretId: string | null; prefix?: string; presetKey?: string }>, ) => { setCustomHeaders(customHeaders.map((ch, i) => (i === index ? { ...ch, ...update } : ch))); }; const removeCustomHeader = (index: number) => { setCustomHeaders(customHeaders.filter((_, i) => i !== index)); }; const handleAdd = async () => { setAdding(true); setAddError(null); try { await doAdd({ path: { scopeId }, payload: { spec: specUrl, name: sourceName.trim() || undefined, namespace: namespace.trim() || undefined, baseUrl: baseUrl.trim() || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), }, }); props.onComplete(); } catch (e) { setAddError(e instanceof Error ? e.message : "Failed to add source"); setAdding(false); } }; // ---- Render ---- return (

Add OpenAPI Source

{/* ── Spec input ── */}