From 43f5bd1c624c7773d181d7c253df7fa3fc7cdaf9 Mon Sep 17 00:00:00 2001 From: PurdueDM Date: Fri, 3 Apr 2026 00:27:11 -0400 Subject: [PATCH 1/2] feat: add OpenAPI spec upload for bulk resource registration - Add OpenAPI 3.x / Swagger 2.x parser (lib/openapi/parse-spec.ts) - Extracts endpoints, parameters, request bodies, response schemas - Resolves $ref references and server variables - Base URL auto-detection from spec servers or Swagger 2.x host/basePath - Add registerFromOpenApi tRPC endpoint with dry-run mode - Preview all endpoints before committing to registration - Sequential registration with rate limiting (200ms) - Reuses existing probeX402Endpoint + registerResource pipeline - Add OpenApiUpload UI component on the register page - File upload (JSON/YAML) via existing Dropzone component - Paste textarea for copy-pasting spec content - Endpoint preview with HTTP method badges and descriptions - Per-endpoint success/failure reporting after registration - Base URL override for specs without server info Fixes #97 --- .../register/_components/openapi-upload.tsx | 415 ++++++++++++++++++ .../(app)/(home)/resources/register/page.tsx | 4 +- apps/scan/src/lib/openapi/index.ts | 7 + apps/scan/src/lib/openapi/parse-spec.ts | 265 +++++++++++ .../scan/src/trpc/routers/public/resources.ts | 133 ++++++ 5 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 apps/scan/src/app/(app)/(home)/resources/register/_components/openapi-upload.tsx create mode 100644 apps/scan/src/lib/openapi/index.ts create mode 100644 apps/scan/src/lib/openapi/parse-spec.ts diff --git a/apps/scan/src/app/(app)/(home)/resources/register/_components/openapi-upload.tsx b/apps/scan/src/app/(app)/(home)/resources/register/_components/openapi-upload.tsx new file mode 100644 index 000000000..e1f989182 --- /dev/null +++ b/apps/scan/src/app/(app)/(home)/resources/register/_components/openapi-upload.tsx @@ -0,0 +1,415 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; + +import { + CheckCircle2, + ChevronDown, + FileUp, + Loader2, + Upload, + XCircle, +} from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Dropzone } from '@/components/ui/dropzone'; + +import { api } from '@/trpc/client'; + +type DryRunEndpoint = { + method: string; + path: string; + url: string; + summary: string | undefined; + description: string | undefined; + parameterCount: number; +}; + +type RegisterResult = { + url: string; + method: string; + path: string; + success: boolean; + error?: string; +}; + +const METHOD_COLORS: Record = { + GET: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + POST: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + PUT: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + PATCH: + 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', + DELETE: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', +}; + +export function OpenApiUpload() { + const [specText, setSpecText] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [parseError, setParseError] = useState(null); + const [previewEndpoints, setPreviewEndpoints] = useState< + DryRunEndpoint[] | null + >(null); + const [previewMeta, setPreviewMeta] = useState<{ + title?: string; + version?: string; + description?: string; + baseUrl?: string; + } | null>(null); + const [registerResults, setRegisterResults] = useState< + RegisterResult[] | null + >(null); + + const registerMutation = + api.public.resources.registerFromOpenApi.useMutation(); + + const isParsing = registerMutation.isPending && !registerResults; + const isRegistering = registerMutation.isPending && previewEndpoints !== null; + + const hasSpec = specText.trim().length > 0; + + const specInput = useMemo(() => { + if (!hasSpec) return null; + const trimmed = specText.trim(); + try { + return JSON.parse(trimmed) as Record; + } catch { + return trimmed; + } + }, [specText, hasSpec]); + + const handleFileUpload = useCallback( + (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = event => { + const content = event.target?.result; + if (typeof content === 'string') { + setSpecText(content); + setParseError(null); + setPreviewEndpoints(null); + setPreviewMeta(null); + setRegisterResults(null); + registerMutation.reset(); + } + }; + reader.readAsText(file); + }, + [registerMutation] + ); + + const handleParseSpec = async () => { + if (!specInput) return; + + setParseError(null); + setPreviewEndpoints(null); + setPreviewMeta(null); + setRegisterResults(null); + + try { + const result = await registerMutation.mutateAsync({ + spec: specInput, + baseUrl: baseUrl || undefined, + dryRun: true, + }); + + if (result.dryRun) { + setPreviewEndpoints(result.endpoints); + setPreviewMeta({ + title: result.title ?? undefined, + version: result.version ?? undefined, + description: result.description ?? undefined, + baseUrl: result.baseUrl ?? undefined, + }); + } + } catch (error) { + setParseError( + error instanceof Error ? error.message : 'Failed to parse spec' + ); + } + }; + + const handleRegisterAll = async () => { + if (!specInput || !previewEndpoints) return; + + setRegisterResults(null); + + try { + const result = await registerMutation.mutateAsync({ + spec: specInput, + baseUrl: baseUrl || undefined, + dryRun: false, + }); + + if (!result.dryRun) { + setRegisterResults(result.results); + } + } catch (error) { + setParseError( + error instanceof Error ? error.message : 'Registration failed' + ); + } + }; + + const handleReset = () => { + setSpecText(''); + setBaseUrl(''); + setParseError(null); + setPreviewEndpoints(null); + setPreviewMeta(null); + setRegisterResults(null); + registerMutation.reset(); + }; + + const registeredCount = registerResults?.filter(r => r.success).length ?? 0; + const failedCount = registerResults?.filter(r => !r.success).length ?? 0; + + return ( + + + + + Import from OpenAPI Spec + + + Upload or paste an OpenAPI 3.x / Swagger 2.x specification to register + multiple endpoints at once. + + + + + {/* File upload */} +
+ + + + + Drop a .json or .yaml file, or click to browse + + +
+ + {/* Paste area */} +
+ +