From 11a626b3d6920b9b79a99b35b6a1560d2920ee27 Mon Sep 17 00:00:00 2001 From: manNomi Date: Mon, 25 May 2026 18:29:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20Bruno?= =?UTF-8?q?=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=B2=A0=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/package.json | 1 + .../features/bruno/BrunoApiPageContent.tsx | 444 +++-- packages/api-schema/package.json | 5 + .../api-schema/src/apiDefinitionRegistry.ts | 1572 +++++++++++++++++ .../src/generator/apiDefinitionGenerator.ts | 174 +- .../src/generator/index.ts | 34 +- .../src/parser/bruParser.ts | 104 +- pnpm-lock.yaml | 3 + 8 files changed, 2138 insertions(+), 199 deletions(-) create mode 100644 packages/api-schema/src/apiDefinitionRegistry.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index 62d60089..0453afa9 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@solid-connect/api-schema": "workspace:^", "@stomp/stompjs": "^7.1.1", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-query": "^5.84.1", diff --git a/apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx b/apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx index fd1785e8..4e8902d0 100644 --- a/apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx +++ b/apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx @@ -1,7 +1,11 @@ "use client"; -import { Copy, Play, RotateCcw } from "lucide-react"; -import { useMemo, useState } from "react"; +import { + type BrunoApiDefinitionRegistryItem, + brunoApiDefinitionRegistry, +} from "@solid-connect/api-schema/api-definition-registry"; +import { AlertTriangle, Copy, Play, RotateCcw } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AdminLayout } from "@/components/layout/AdminLayout"; import { Button } from "@/components/ui/button"; @@ -13,23 +17,10 @@ import { Textarea } from "@/components/ui/textarea"; import { axiosInstance } from "@/lib/api/client"; import { cn } from "@/lib/utils"; -type DefinitionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; +type EndpointItem = BrunoApiDefinitionRegistryItem; +type DefinitionMethod = EndpointItem["definition"]["method"]; type MethodFilter = "ALL" | DefinitionMethod; - -interface ApiDefinitionEntry { - method: DefinitionMethod; - path: string; - pathParams: Record; - queryParams: Record; - body: unknown; - response: unknown; -} - -interface EndpointItem { - domain: string; - name: string; - definition: ApiDefinitionEntry; -} +type DomainFilter = "ALL" | string; interface RequestResult { status: number; @@ -38,81 +29,51 @@ interface RequestResult { body: unknown; } -const definitionModules = import.meta.glob("../../../../../../packages/api-schema/src/apis/*/apiDefinitions.ts", { - eager: true, -}) as Record>; - -const normalizeTokenKey = (value: string) => value.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); +interface EditorState { + pathParamsText: string; + queryParamsText: string; + bodyText: string; + headersText: string; +} -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +const ALL_ENDPOINTS = [...brunoApiDefinitionRegistry].sort((a, b) => { + if (a.domain === b.domain) { + return a.displayName.localeCompare(b.displayName); + } + return a.domain.localeCompare(b.domain); +}); -const isDefinitionMethod = (value: unknown): value is DefinitionMethod => - value === "GET" || - value === "POST" || - value === "PUT" || - value === "PATCH" || - value === "DELETE" || - value === "HEAD" || - value === "OPTIONS"; +const METHOD_FILTERS: MethodFilter[] = ["ALL", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const METHODS_WITHOUT_BODY = new Set(["GET", "HEAD"]); +const apiServerUrl = import.meta.env.VITE_API_SERVER_URL?.trim() ?? ""; -const isApiDefinitionEntry = (value: unknown): value is ApiDefinitionEntry => { - if (!isRecord(value)) { - return false; +const toPrettyJson = (value: unknown): string => { + if (value === undefined) { + return ""; } - return ( - isDefinitionMethod(value.method) && - typeof value.path === "string" && - isRecord(value.pathParams) && - isRecord(value.queryParams) && - "body" in value && - "response" in value - ); + return JSON.stringify(value, null, 2) ?? ""; }; -const parseDefinitionRegistry = (): EndpointItem[] => { - const endpoints: EndpointItem[] = []; - - for (const [modulePath, moduleExports] of Object.entries(definitionModules)) { - const domainMatch = modulePath.match(/apis\/([^/]+)\/apiDefinitions\.ts$/); - if (!domainMatch) { - continue; - } - - const domain = domainMatch[1]; - - for (const exportedValue of Object.values(moduleExports)) { - if (!isRecord(exportedValue)) { - continue; - } - - for (const [endpointName, endpointDefinition] of Object.entries(exportedValue)) { - if (!isApiDefinitionEntry(endpointDefinition)) { - continue; - } - - endpoints.push({ - domain, - name: endpointName, - definition: endpointDefinition, - }); - } - } +const buildEditorState = (endpoint: EndpointItem | null): EditorState => { + if (!endpoint) { + return { + pathParamsText: "{}", + queryParamsText: "{}", + bodyText: "{}", + headersText: "{}", + }; } - return endpoints.sort((a, b) => { - if (a.domain === b.domain) { - return a.name.localeCompare(b.name); - } - return a.domain.localeCompare(b.domain); - }); + return { + pathParamsText: toPrettyJson(endpoint.definition.pathParamsExample), + queryParamsText: toPrettyJson(endpoint.definition.queryParamsExample), + bodyText: endpoint.definition.hasBody ? toPrettyJson(endpoint.definition.bodyExample ?? {}) : "{}", + headersText: "{}", + }; }; -const ALL_ENDPOINTS = parseDefinitionRegistry(); - -const toPrettyJson = (value: unknown) => JSON.stringify(value, null, 2); - const parseJsonValue = (text: string, label: string): unknown => { if (!text.trim()) { return {}; @@ -138,25 +99,28 @@ const toStringRecord = (value: Record): Record return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, String(entry)])); }; +const resolvePathParamValue = (pathParams: Record, tokenName: string) => { + const value = pathParams[tokenName]; + if (value !== undefined && value !== null && String(value).trim().length > 0) { + return encodeURIComponent(String(value)); + } + + throw new Error(`경로 파라미터 '${tokenName}' 값이 필요합니다.`); +}; + const resolvePath = (rawPath: string, pathParams: Record) => { const withoutBaseToken = rawPath.replace("{{URL}}", ""); - - return withoutBaseToken.replace(/\{\{([^}]+)\}\}/g, (_full, tokenName: string) => { - const exact = pathParams[tokenName]; - if (exact !== undefined && exact !== null) { - return encodeURIComponent(String(exact)); + const resolvedBrunoPath = withoutBaseToken.replace(/\{\{([^}]+)\}\}/g, (_full, tokenName: string) => { + if (tokenName === "URL") { + return ""; } - const normalizedToken = normalizeTokenKey(tokenName); - const similarEntry = Object.entries(pathParams).find( - ([key, value]) => normalizeTokenKey(key) === normalizedToken && value !== undefined && value !== null, - ); - - if (similarEntry) { - return encodeURIComponent(String(similarEntry[1])); - } + return resolvePathParamValue(pathParams, tokenName); + }); - throw new Error(`경로 파라미터 '${tokenName}' 값이 필요합니다.`); + return resolvedBrunoPath.replace(/:(\w+)|\{(\w+)\}/g, (_full, colonParam: string, braceParam: string) => { + const tokenName = colonParam || braceParam; + return resolvePathParamValue(pathParams, tokenName); }); }; @@ -168,50 +132,81 @@ const splitPathAndInlineQuery = (pathWithInlineQuery: string) => { return { path, inlineQuery }; }; -const METHOD_FILTERS: MethodFilter[] = ["ALL", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; +const isRemoteApiServer = (url: string) => { + if (!url) { + return false; + } + + return !/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?/i.test(url); +}; + +const getEndpointKey = (endpoint: EndpointItem) => `${endpoint.domain}:${endpoint.name}:${endpoint.sourceFile ?? ""}`; + +const getMethodClassName = (method: DefinitionMethod) => + cn( + "rounded px-1.5 py-0.5 typo-regular-4", + method === "GET" && "bg-[#E8F3FF] text-[#1D4ED8]", + method === "POST" && "bg-[#ECFDF3] text-[#047857]", + method === "PUT" && "bg-[#FFF7ED] text-[#C2410C]", + method === "PATCH" && "bg-[#FEF3C7] text-[#B45309]", + method === "DELETE" && "bg-[#FEE2E2] text-[#B91C1C]", + (method === "HEAD" || method === "OPTIONS") && "bg-k-100 text-k-700", + ); export function BrunoApiPageContent() { const [search, setSearch] = useState(""); const [methodFilter, setMethodFilter] = useState("ALL"); - const [selectedKey, setSelectedKey] = useState( - ALL_ENDPOINTS[0] ? `${ALL_ENDPOINTS[0].domain}:${ALL_ENDPOINTS[0].name}` : "", - ); - const [pathParamsText, setPathParamsText] = useState("{}"); - const [queryParamsText, setQueryParamsText] = useState("{}"); - const [bodyText, setBodyText] = useState("{}"); - const [headersText, setHeadersText] = useState("{}"); + const [domainFilter, setDomainFilter] = useState("ALL"); + const [selectedKey, setSelectedKey] = useState(ALL_ENDPOINTS[0] ? getEndpointKey(ALL_ENDPOINTS[0]) : ""); + const [editorState, setEditorState] = useState(() => buildEditorState(ALL_ENDPOINTS[0] ?? null)); const [isSending, setIsSending] = useState(false); const [requestResult, setRequestResult] = useState(null); + const [editorError, setEditorError] = useState(null); + + const domains = useMemo(() => Array.from(new Set(ALL_ENDPOINTS.map((endpoint) => endpoint.domain))).sort(), []); const visibleEndpoints = useMemo(() => { const normalized = search.trim().toLowerCase(); return ALL_ENDPOINTS.filter((endpoint) => { - const matchesMethod = methodFilter === "ALL" || endpoint.definition.method === methodFilter; - if (!matchesMethod) { + if (methodFilter !== "ALL" && endpoint.definition.method !== methodFilter) { + return false; + } + if (domainFilter !== "ALL" && endpoint.domain !== domainFilter) { return false; } if (!normalized) { return true; } - const matchesSearch = - `${endpoint.domain} ${endpoint.name} ${endpoint.definition.method} ${endpoint.definition.path}` - .toLowerCase() - .includes(normalized); - return matchesSearch; + return `${endpoint.domain} ${endpoint.name} ${endpoint.displayName} ${endpoint.definition.method} ${endpoint.definition.path}` + .toLowerCase() + .includes(normalized); }); - }, [methodFilter, search]); + }, [domainFilter, methodFilter, search]); const selectedEndpoint = useMemo(() => { - return ALL_ENDPOINTS.find((endpoint) => `${endpoint.domain}:${endpoint.name}` === selectedKey) ?? null; + return ALL_ENDPOINTS.find((endpoint) => getEndpointKey(endpoint) === selectedKey) ?? null; }, [selectedKey]); + const selectedIsMultipart = selectedEndpoint?.definition.bodyType === "multipart-form"; + const selectedIsMutating = selectedEndpoint ? MUTATING_METHODS.has(selectedEndpoint.definition.method) : false; + const showRemoteWarning = isRemoteApiServer(apiServerUrl); + + useEffect(() => { + setEditorState(buildEditorState(selectedEndpoint)); + setRequestResult(null); + setEditorError(null); + }, [selectedEndpoint]); + + const updateEditorState = (key: keyof EditorState, value: string) => { + setEditorState((prev) => ({ ...prev, [key]: value })); + setEditorError(null); + }; + const handleResetEditors = () => { - setPathParamsText("{}"); - setQueryParamsText("{}"); - setBodyText("{}"); - setHeadersText("{}"); + setEditorState(buildEditorState(selectedEndpoint)); setRequestResult(null); + setEditorError(null); }; const handleCopyResponse = async () => { @@ -229,27 +224,42 @@ export function BrunoApiPageContent() { return; } + if (selectedIsMultipart) { + toast.warning("파일 업로드 API는 현재 테스트베드에서 실행할 수 없습니다."); + return; + } + + if (selectedIsMutating) { + const confirmed = window.confirm( + `${selectedEndpoint.definition.method} ${selectedEndpoint.definition.path}\n\n실제 API에 변경 요청을 보냅니다. 계속할까요?`, + ); + + if (!confirmed) { + return; + } + } + try { setIsSending(true); + setEditorError(null); - const pathParams = parseJsonRecord(pathParamsText, "Path Params"); - const queryParams = parseJsonRecord(queryParamsText, "Query Params"); - const headers = toStringRecord(parseJsonRecord(headersText, "Headers")); - const body = parseJsonValue(bodyText, "Body"); + const pathParams = parseJsonRecord(editorState.pathParamsText, "Path Params"); + const queryParams = parseJsonRecord(editorState.queryParamsText, "Query Params"); + const headers = toStringRecord(parseJsonRecord(editorState.headersText, "Headers")); + const body = parseJsonValue(editorState.bodyText, "Body"); const resolvedPath = resolvePath(selectedEndpoint.definition.path, pathParams); const { path, inlineQuery } = splitPathAndInlineQuery(resolvedPath); const mergedQueryParams = { ...inlineQuery, ...queryParams }; + const method: DefinitionMethod = selectedEndpoint.definition.method; + const shouldSendBody = !METHODS_WITHOUT_BODY.has(method); const startedAt = performance.now(); const response = await axiosInstance.request({ url: path, - method: selectedEndpoint.definition.method, + method, params: mergedQueryParams, - data: - selectedEndpoint.definition.method === "GET" || selectedEndpoint.definition.method === "HEAD" - ? undefined - : body, + data: shouldSendBody ? body : undefined, headers, validateStatus: () => true, }); @@ -271,6 +281,7 @@ export function BrunoApiPageContent() { toast.success("요청이 완료되었습니다."); } catch (error) { const message = error instanceof Error ? error.message : "요청 중 오류가 발생했습니다."; + setEditorError(message); toast.error(message); } finally { setIsSending(false); @@ -279,73 +290,110 @@ export function BrunoApiPageContent() { return ( -
+
Bruno API 목록 setSearch(event.target.value)} /> -
- {METHOD_FILTERS.map((method) => { - const active = methodFilter === method; - return ( - - ); - })} + {visibleEndpoints.length > 0 ? ( + visibleEndpoints.map((endpoint) => { + const key = getEndpointKey(endpoint); + const active = key === selectedKey; + + return ( + + ); + }) + ) : ( +
+ 표시할 API가 없습니다. +
+ )}
+ {showRemoteWarning ? ( +
+ +
+

원격 API 서버에 연결되어 있습니다.

+

{apiServerUrl}

+
+
+ ) : null} + -
+
요청 빌더
- @@ -353,47 +401,70 @@ export function BrunoApiPageContent() {
{selectedEndpoint ? (
-

{selectedEndpoint.definition.method}

-

{selectedEndpoint.definition.path}

+
+ + {selectedEndpoint.definition.method} + + {selectedEndpoint.displayName} +
+

+ {selectedEndpoint.definition.path} +

+ {selectedEndpoint.sourceFile ? ( +

{selectedEndpoint.sourceFile}

+ ) : null}
) : (

왼쪽에서 API를 선택해주세요.

)} + {selectedIsMultipart ? ( +
+ 파일 업로드 API는 v1 테스트베드에서 실행을 막아두었습니다. +
+ ) : null} + {editorError ? ( +
+ {editorError} +
+ ) : null} Path Params Query - Body + + Body + Headers