diff --git a/ROADMAP.md b/ROADMAP.md index b65335187e..e4eaa1cc32 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -759,6 +759,7 @@ Final polish and advanced features. - [ ] Page Builder — drag-and-drop page designer with element palette (see [Gap Analysis](docs/design/airtable-interface-gap-analysis.md)) - [x] Flow Builder Protocol — canvas node shapes, edge styles, BPMN node descriptors (parallel_gateway, join_gateway, boundary_event, wait), layout algorithms, palette categories (`studio/flow-builder.zod.ts`) - [ ] Flow Builder Runtime — visual automation flow editor with drag-and-drop canvas, node palette, property panel, minimap +- [x] Global REST API Console — auto-discover all registered REST APIs, grouped endpoint tree with search/filter, request/response panel with history and replay (`ApiConsolePage`, `useApiDiscovery`) - [ ] Security Console — permission matrix, RLS policy editor - [ ] AI Playground — agent testing, NLQ sandbox - [ ] Code Editor — Monaco-based TypeScript editing with live preview diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 4a2dd60995..1e48503445 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -9,13 +9,14 @@ import { SiteHeader } from "@/components/site-header" import { SidebarProvider } from "@/components/ui/sidebar" import { DeveloperOverview } from './components/DeveloperOverview'; import { PackageManager } from './components/PackageManager'; +import { ApiConsolePage } from './components/ApiConsolePage'; import { Toaster } from "@/components/ui/toaster" import { getApiBaseUrl, config } from './lib/config'; import { PluginRegistryProvider, PluginHost } from './plugins'; import { builtInPlugins } from './plugins/built-in'; import type { InstalledPackage } from '@objectstack/spec/kernel'; -type ViewType = 'overview' | 'packages' | 'object' | 'metadata'; +type ViewType = 'overview' | 'packages' | 'object' | 'metadata' | 'api-console'; export default function App() { const [client, setClient] = useState(null); @@ -129,6 +130,8 @@ export default function App() { ) : selectedView === 'packages' ? ( + ) : selectedView === 'api-console' ? ( + ) : ( = { + GET: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/50', + POST: 'text-blue-600 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-950/50', + PATCH: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50', + PUT: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50', + DELETE: 'text-red-600 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50', +}; + +const STATUS_COLORS: Record = { + '2': 'text-emerald-600 dark:text-emerald-400', + '3': 'text-blue-600 dark:text-blue-400', + '4': 'text-amber-600 dark:text-amber-400', + '5': 'text-red-600 dark:text-red-400', +}; + +/** Common OData-style query parameter presets for quick-add */ +const QUERY_PARAM_PRESETS = [ + { key: '$top', placeholder: '10', hint: 'limit' }, + { key: '$skip', placeholder: '0', hint: 'offset' }, + { key: '$sort', placeholder: 'name', hint: 'sort field' }, + { key: '$select', placeholder: 'name,email', hint: 'fields' }, + { key: '$count', placeholder: 'true', hint: 'total count' }, + { key: '$filter', placeholder: "status eq 'active'", hint: 'OData filter' }, +]; + +interface QueryParam { + id: number; + key: string; + value: string; + enabled: boolean; +} + +interface RequestHistoryEntry { + id: number; + method: HttpMethod; + url: string; + body?: string; + status: number; + duration: number; + response: string; + timestamp: Date; +} + +// ─── Component ────────────────────────────────────────────────────── + +let nextParamId = 1; + +export function ApiConsolePage() { + const client = useClient(); + const { groups, loading: discovering, refresh } = useApiDiscovery(); + + const [searchQuery, setSearchQuery] = useState(''); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [selectedEndpoint, setSelectedEndpoint] = useState(null); + const [methodOverride, setMethodOverride] = useState(''); + const [urlOverride, setUrlOverride] = useState(''); + const [requestBody, setRequestBody] = useState(''); + const [queryParams, setQueryParams] = useState([]); + const [loading, setLoading] = useState(false); + const [response, setResponse] = useState<{ status: number; body: string; duration: number } | null>(null); + const [history, setHistory] = useState([]); + const [expandedHistory, setExpandedHistory] = useState(null); + const [copied, setCopied] = useState(false); + + // Auto-expand groups that match search + const filteredGroups = useMemo(() => { + if (!searchQuery) return groups; + const q = searchQuery.toLowerCase(); + return groups + .map(g => ({ + ...g, + endpoints: g.endpoints.filter(ep => + ep.desc.toLowerCase().includes(q) || + ep.path.toLowerCase().includes(q) || + ep.method.toLowerCase().includes(q) + ), + })) + .filter(g => g.endpoints.length > 0); + }, [groups, searchQuery]); + + const toggleGroup = useCallback((key: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + }, []); + + const selectEndpoint = useCallback((ep: EndpointDef) => { + setSelectedEndpoint(ep); + setMethodOverride(ep.method); + setUrlOverride(ep.path); + setRequestBody(ep.bodyTemplate ? JSON.stringify(ep.bodyTemplate, null, 2) : ''); + setQueryParams([]); + setResponse(null); + }, []); + + const effectiveMethod = (methodOverride || selectedEndpoint?.method || 'GET') as HttpMethod; + const basePath = urlOverride || selectedEndpoint?.path || ''; + + // Build full URL with query params + const effectiveUrl = useMemo(() => { + const enabledParams = queryParams.filter(p => p.enabled && p.key.trim()); + if (enabledParams.length === 0) return basePath; + const qs = enabledParams.map(p => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`).join('&'); + return `${basePath}?${qs}`; + }, [basePath, queryParams]); + + // ─── Query Param Helpers ──────────────────────────────────────────── + + const addQueryParam = useCallback((key = '', value = '') => { + setQueryParams(prev => [...prev, { id: nextParamId++, key, value, enabled: true }]); + }, []); + + const removeQueryParam = useCallback((id: number) => { + setQueryParams(prev => prev.filter(p => p.id !== id)); + }, []); + + const updateQueryParam = useCallback((id: number, field: 'key' | 'value', val: string) => { + setQueryParams(prev => prev.map(p => p.id === id ? { ...p, [field]: val } : p)); + }, []); + + const toggleQueryParam = useCallback((id: number) => { + setQueryParams(prev => prev.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p)); + }, []); + + const sendRequest = useCallback(async () => { + if (loading || !effectiveUrl) return; + + const baseUrl = (client as any)?.baseUrl ?? ''; + const fullUrl = `${baseUrl}${effectiveUrl}`; + + setLoading(true); + const start = performance.now(); + + try { + const fetchOptions: RequestInit = { + method: effectiveMethod, + headers: { 'Content-Type': 'application/json' }, + }; + + if (['POST', 'PATCH', 'PUT'].includes(effectiveMethod) && requestBody.trim()) { + fetchOptions.body = requestBody; + } + + const res = await fetch(fullUrl, fetchOptions); + const duration = Math.round(performance.now() - start); + + let bodyText: string; + const contentType = res.headers.get('content-type') ?? ''; + if (contentType.includes('json')) { + const json = await res.json(); + bodyText = JSON.stringify(json, null, 2); + } else { + bodyText = await res.text(); + } + + const result = { status: res.status, body: bodyText, duration }; + setResponse(result); + + setHistory(prev => [{ + id: Date.now(), + method: effectiveMethod, + url: effectiveUrl, + body: requestBody || undefined, + status: res.status, + duration, + response: bodyText, + timestamp: new Date(), + }, ...prev].slice(0, 50)); + } catch (err: any) { + const duration = Math.round(performance.now() - start); + setResponse({ + status: 0, + body: `Network Error: ${err.message}`, + duration, + }); + } finally { + setLoading(false); + } + }, [client, effectiveUrl, effectiveMethod, requestBody, loading]); + + const replayHistoryEntry = useCallback((entry: RequestHistoryEntry) => { + setMethodOverride(entry.method); + // Separate path and query params from history URL + const [path, qs] = entry.url.split('?'); + setUrlOverride(path); + setRequestBody(entry.body || ''); + if (qs) { + const params = new URLSearchParams(qs); + const restored: QueryParam[] = []; + params.forEach((value, key) => { + restored.push({ id: nextParamId++, key, value, enabled: true }); + }); + setQueryParams(restored); + } else { + setQueryParams([]); + } + setResponse(null); + setSelectedEndpoint(null); + }, []); + + const copyResponse = useCallback(() => { + if (response?.body) { + navigator.clipboard.writeText(response.body); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }, [response]); + + const statusColor = (status: number) => STATUS_COLORS[String(status)[0]] ?? 'text-muted-foreground'; + + return ( +
+ + {/* ── Left: Endpoint Tree ─────────────────────────────── */} +
+ {/* Search + Refresh */} +
+
+

API Endpoints

+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search endpoints..." + className="w-full rounded-md border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ + {/* Endpoint tree */} +
+ {discovering ? ( +
+ + Discovering APIs... +
+ ) : filteredGroups.length === 0 ? ( +
+ + {searchQuery ? 'No matching endpoints' : 'No endpoints found'} +
+ ) : ( + filteredGroups.map(group => { + const isExpanded = expandedGroups.has(group.key) || !!searchQuery; + return ( + toggleGroup(group.key)}> + + + + +
+ {group.endpoints.map((ep, i) => { + const isActive = selectedEndpoint === ep; + return ( + + ); + })} +
+
+
+ ); + }) + )} +
+ + {/* Query params form */} +
+
+

Query Parameters

+ +
+ + {/* Preset quick-add buttons */} +
+ {QUERY_PARAM_PRESETS.map(preset => ( + + ))} +
+ + {/* Param rows */} + {queryParams.length > 0 && ( +
+ {queryParams.map(param => ( +
+ toggleQueryParam(param.id)} + className="h-3 w-3 shrink-0 rounded border accent-primary" + /> + updateQueryParam(param.id, 'key', e.target.value)} + placeholder="key" + className="flex-1 min-w-0 rounded border bg-background px-1.5 py-0.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-ring" + /> + updateQueryParam(param.id, 'value', e.target.value)} + placeholder="value" + className="flex-1 min-w-0 rounded border bg-background px-1.5 py-0.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+ ))} +
+ )} +
+
+ + {/* ── Right: Request / Response ──────────────────────── */} +
+ {/* URL bar */} +
+
+ + setUrlOverride(e.target.value)} + className="flex-1 rounded-md border bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring" + placeholder="/api/v1/..." + onKeyDown={e => { if (e.key === 'Enter') sendRequest(); }} + /> + +
+ {queryParams.some(p => p.enabled && p.key.trim()) && ( +
+ → {effectiveUrl} +
+ )} +
+ + {/* Body + Response split */} +
+ {/* Request Body (for POST/PATCH/PUT) */} + {['POST', 'PATCH', 'PUT'].includes(effectiveMethod) && ( +
+ +