From 3fd283ee0c5df19aa70d2aabf163091bd97434ee Mon Sep 17 00:00:00 2001 From: manNomi Date: Mon, 2 Mar 2026 18:39:14 +0900 Subject: [PATCH 1/5] feat: stabilize bruno schema generation and admin API explorer --- apps/admin/src/routes/bruno/index.tsx | 132 +++++++++++++----- packages/api-schema/package.json | 5 +- packages/api-schema/scripts/sync-bruno.mjs | 13 +- packages/api-schema/src/index.ts | 1 + packages/api-schema/tsconfig.generated.json | 5 + .../src/generator/apiClientGenerator.ts | 2 +- .../src/generator/apiDefinitionGenerator.ts | 15 +- .../src/generator/apiFactoryGenerator.ts | 6 +- .../src/generator/index.ts | 29 +++- .../src/generator/typeGenerator.ts | 79 +++++++++-- .../bruno-api-typescript/tests/cli.test.js | 45 ++++++ 11 files changed, 268 insertions(+), 64 deletions(-) create mode 100644 packages/api-schema/src/index.ts create mode 100644 packages/api-schema/tsconfig.generated.json diff --git a/apps/admin/src/routes/bruno/index.tsx b/apps/admin/src/routes/bruno/index.tsx index 868ecb19..52e74604 100644 --- a/apps/admin/src/routes/bruno/index.tsx +++ b/apps/admin/src/routes/bruno/index.tsx @@ -14,7 +14,8 @@ import { cn } from "@/lib/utils"; import { isTokenExpired } from "@/lib/utils/jwtUtils"; import { loadAccessToken } from "@/lib/utils/localStorage"; -type DefinitionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +type DefinitionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; +type MethodFilter = "ALL" | DefinitionMethod; interface ApiDefinitionEntry { method: DefinitionMethod; @@ -38,48 +39,66 @@ interface RequestResult { body: unknown; } -const definitionFileContents = import.meta.glob("../../../../../packages/api-schema/src/apis/*/apiDefinitions.ts", { +const definitionModules = import.meta.glob("../../../../../packages/api-schema/src/apis/*/apiDefinitions.ts", { eager: true, - query: "?raw", - import: "default", -}) as Record; +}) as Record>; const normalizeTokenKey = (value: string) => value.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isDefinitionMethod = (value: unknown): value is DefinitionMethod => + value === "GET" || + value === "POST" || + value === "PUT" || + value === "PATCH" || + value === "DELETE" || + value === "HEAD" || + value === "OPTIONS"; + +const isApiDefinitionEntry = (value: unknown): value is ApiDefinitionEntry => { + if (!isRecord(value)) { + return false; + } + + return ( + isDefinitionMethod(value.method) && + typeof value.path === "string" && + isRecord(value.pathParams) && + isRecord(value.queryParams) && + "body" in value && + "response" in value + ); +}; + const parseDefinitionRegistry = (): EndpointItem[] => { const endpoints: EndpointItem[] = []; - for (const [modulePath, fileContent] of Object.entries(definitionFileContents)) { + for (const [modulePath, moduleExports] of Object.entries(definitionModules)) { const domainMatch = modulePath.match(/apis\/([^/]+)\/apiDefinitions\.ts$/); if (!domainMatch) { continue; } const domain = domainMatch[1]; - const endpointPattern = - /^\s*([^:\n]+):\s*\{\s*\n\s*method:\s*'([A-Z]+)'\s+as const,\s*\n\s*path:\s*'([^']+)'\s+as const,/gm; - - for (const match of fileContent.matchAll(endpointPattern)) { - const endpointName = match[1]?.trim(); - const method = match[2]?.trim() as DefinitionMethod | undefined; - const path = match[3]?.trim(); - if (!endpointName || !method || !path) { + for (const exportedValue of Object.values(moduleExports)) { + if (!isRecord(exportedValue)) { continue; } - endpoints.push({ - domain, - name: endpointName, - definition: { - method, - path, - pathParams: {}, - queryParams: {}, - body: {}, - response: {}, - }, - }); + for (const [endpointName, endpointDefinition] of Object.entries(exportedValue)) { + if (!isApiDefinitionEntry(endpointDefinition)) { + continue; + } + + endpoints.push({ + domain, + name: endpointName, + definition: endpointDefinition, + }); + } } } @@ -95,12 +114,20 @@ const ALL_ENDPOINTS = parseDefinitionRegistry(); const toPrettyJson = (value: unknown) => JSON.stringify(value, null, 2); -const parseJsonRecord = (text: string, label: string): Record => { +const parseJsonValue = (text: string, label: string): unknown => { if (!text.trim()) { return {}; } - const parsed = JSON.parse(text) as unknown; + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error(`${label} JSON 형식이 올바르지 않습니다.`); + } +}; + +const parseJsonRecord = (text: string, label: string): Record => { + const parsed = parseJsonValue(text, label); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error(`${label}는 JSON 객체여야 합니다.`); } @@ -134,6 +161,14 @@ const resolvePath = (rawPath: string, pathParams: Record) => { }); }; +const splitPathAndInlineQuery = (pathWithInlineQuery: string) => { + const [path, queryString = ""] = pathWithInlineQuery.split("?"); + const inlineQuery = Object.fromEntries(new URLSearchParams(queryString)); + return { path, inlineQuery }; +}; + +const METHOD_FILTERS: MethodFilter[] = ["ALL", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + export const Route = createFileRoute("/bruno/")({ beforeLoad: () => { if (typeof window !== "undefined") { @@ -148,6 +183,7 @@ export const Route = createFileRoute("/bruno/")({ function BrunoApiPage() { const [search, setSearch] = useState(""); + const [methodFilter, setMethodFilter] = useState("ALL"); const [selectedKey, setSelectedKey] = useState( ALL_ENDPOINTS[0] ? `${ALL_ENDPOINTS[0].domain}:${ALL_ENDPOINTS[0].name}` : "", ); @@ -165,11 +201,14 @@ function BrunoApiPage() { } return ALL_ENDPOINTS.filter((endpoint) => { - return `${endpoint.domain} ${endpoint.name} ${endpoint.definition.method} ${endpoint.definition.path}` - .toLowerCase() - .includes(normalized); + const matchesSearch = + `${endpoint.domain} ${endpoint.name} ${endpoint.definition.method} ${endpoint.definition.path}` + .toLowerCase() + .includes(normalized); + const matchesMethod = methodFilter === "ALL" || endpoint.definition.method === methodFilter; + return matchesSearch && matchesMethod; }); - }, [search]); + }, [methodFilter, search]); const selectedEndpoint = useMemo(() => { return ALL_ENDPOINTS.find((endpoint) => `${endpoint.domain}:${endpoint.name}` === selectedKey) ?? null; @@ -204,16 +243,21 @@ function BrunoApiPage() { const pathParams = parseJsonRecord(pathParamsText, "Path Params"); const queryParams = parseJsonRecord(queryParamsText, "Query Params"); const headers = toStringRecord(parseJsonRecord(headersText, "Headers")); - const body = parseJsonRecord(bodyText, "Body"); + const body = parseJsonValue(bodyText, "Body"); - const path = resolvePath(selectedEndpoint.definition.path, pathParams); + const resolvedPath = resolvePath(selectedEndpoint.definition.path, pathParams); + const { path, inlineQuery } = splitPathAndInlineQuery(resolvedPath); + const mergedQueryParams = { ...inlineQuery, ...queryParams }; const startedAt = performance.now(); const response = await axiosInstance.request({ url: path, method: selectedEndpoint.definition.method, - params: queryParams, - data: selectedEndpoint.definition.method === "GET" ? undefined : body, + params: mergedQueryParams, + data: + selectedEndpoint.definition.method === "GET" || selectedEndpoint.definition.method === "HEAD" + ? undefined + : body, headers, validateStatus: () => true, }); @@ -256,6 +300,22 @@ function BrunoApiPage() { value={search} onChange={(event) => setSearch(event.target.value)} /> +
+ {METHOD_FILTERS.map((method) => { + const active = methodFilter === method; + return ( + + ); + })} +
diff --git a/packages/api-schema/package.json b/packages/api-schema/package.json index 21f48415..f17cf25d 100644 --- a/packages/api-schema/package.json +++ b/packages/api-schema/package.json @@ -6,7 +6,10 @@ "sync:bruno": "node ./scripts/sync-bruno.mjs", "sync:bruno:remote": "BRUNO_SOURCE_MODE=remote node ./scripts/sync-bruno.mjs", "build": "pnpm run sync:bruno", - "typecheck": "tsc --noEmit" + "typecheck": "pnpm run typecheck:base && pnpm run typecheck:generated", + "typecheck:base": "tsc --noEmit", + "typecheck:generated": "tsc --noEmit -p tsconfig.generated.json", + "verify:schema": "pnpm run sync:bruno && pnpm run typecheck:generated" }, "dependencies": { "axios": "^1.6.7" diff --git a/packages/api-schema/scripts/sync-bruno.mjs b/packages/api-schema/scripts/sync-bruno.mjs index e1280deb..c9592ada 100644 --- a/packages/api-schema/scripts/sync-bruno.mjs +++ b/packages/api-schema/scripts/sync-bruno.mjs @@ -20,6 +20,7 @@ const remoteRepoUrl = process.env.BRUNO_REPO_URL; const remoteRepoRef = process.env.BRUNO_REPO_REF ?? "main"; const remoteCollectionPath = process.env.BRUNO_COLLECTION_PATH ?? "Solid Connection"; const explicitCollectionDir = process.env.BRUNO_COLLECTION_DIR; +const forceRegeneration = /^(1|true)$/i.test(process.env.BRUNO_FORCE ?? ""); function loadEnvFiles(filePaths) { for (const filePath of filePaths) { @@ -113,7 +114,7 @@ function resolveCollectionDir() { const collectionDir = resolveCollectionDir(); run("pnpm", ["-C", "../bruno-api-typescript", "run", "build"]); -run("node", [ +const generateHooksArgs = [ "../bruno-api-typescript/dist/cli/index.js", "generate-hooks", "-i", @@ -121,5 +122,11 @@ run("node", [ "-o", "./src/apis", "--axios-path", - "../axiosInstance", -]); + "../../axiosInstance", +]; + +if (forceRegeneration) { + generateHooksArgs.push("--force"); +} + +run("node", generateHooksArgs); diff --git a/packages/api-schema/src/index.ts b/packages/api-schema/src/index.ts new file mode 100644 index 00000000..39402647 --- /dev/null +++ b/packages/api-schema/src/index.ts @@ -0,0 +1 @@ +export * from "./axiosInstance"; diff --git a/packages/api-schema/tsconfig.generated.json b/packages/api-schema/tsconfig.generated.json new file mode 100644 index 00000000..8b63c526 --- /dev/null +++ b/packages/api-schema/tsconfig.generated.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/axiosInstance.ts", "src/apis/**/*.ts"], + "exclude": [] +} diff --git a/packages/bruno-api-typescript/src/generator/apiClientGenerator.ts b/packages/bruno-api-typescript/src/generator/apiClientGenerator.ts index 229d0d8b..4518fff2 100644 --- a/packages/bruno-api-typescript/src/generator/apiClientGenerator.ts +++ b/packages/bruno-api-typescript/src/generator/apiClientGenerator.ts @@ -41,7 +41,7 @@ export function extractApiFunction(parsed: ParsedBrunoFile, filePath: string): A // HTTP 메서드 prefix 추가: signOut → postSignOut const methodPrefix = http.method.toLowerCase(); const functionName = `${methodPrefix}${baseFunctionName.charAt(0).toUpperCase()}${baseFunctionName.slice(1)}`; - const responseType = functionNameToTypeName(baseFunctionName); + const responseType = functionNameToTypeName(functionName); // URL에 파라미터가 있는지 확인 const hasParams = http.url.includes(':') || http.url.includes('{'); diff --git a/packages/bruno-api-typescript/src/generator/apiDefinitionGenerator.ts b/packages/bruno-api-typescript/src/generator/apiDefinitionGenerator.ts index d9ed2795..be0139cf 100644 --- a/packages/bruno-api-typescript/src/generator/apiDefinitionGenerator.ts +++ b/packages/bruno-api-typescript/src/generator/apiDefinitionGenerator.ts @@ -5,7 +5,7 @@ import { ParsedBrunoFile } from '../parser/bruParser'; import { ApiFunction } from './apiClientGenerator'; -import { toCamelCase } from './typeGenerator'; +import { toCamelCase, toObjectPropertyKey } from './typeGenerator'; export interface ApiDefinitionMeta { method: string; @@ -34,11 +34,14 @@ function extractPathParams(url: string): string[] { while ((match = brunoVarPattern.exec(processedUrl)) !== null) { const varName = match[1]; if (varName === 'URL') continue; - const camelVarName = varName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + const camelVarName = toCamelCase(varName); if (!params.includes(camelVarName)) { params.push(camelVarName); } } + + // Bruno 변수를 제거한 뒤 일반 URL 파라미터(:id, {id})를 추출해야 중복이 생기지 않음 + processedUrl = processedUrl.replace(/\{\{[^}]+\}\}/g, ''); const urlParamMatches = processedUrl.matchAll(/:(\w+)|\{(\w+)\}/g); for (const match of urlParamMatches) { @@ -117,14 +120,16 @@ export function generateApiDefinitionsFile( for (const { apiFunc, parsed } of apiFunctions) { const meta = generateApiDefinitionMeta(apiFunc, parsed); + const bodyValue = `undefined as unknown as ${meta.body}`; + const responseValue = `undefined as unknown as ${meta.response}`; - lines.push(` ${apiFunc.name}: {`); + lines.push(` ${toObjectPropertyKey(apiFunc.name)}: {`); lines.push(` method: '${meta.method}' as const,`); lines.push(` path: '${meta.path}' as const,`); lines.push(` pathParams: {} as ${meta.pathParams},`); lines.push(` queryParams: {} as ${meta.queryParams},`); - lines.push(` body: {} as ${meta.body},`); - lines.push(` response: {} as ${meta.response},`); + lines.push(` body: ${bodyValue},`); + lines.push(` response: ${responseValue},`); lines.push(` },`); } diff --git a/packages/bruno-api-typescript/src/generator/apiFactoryGenerator.ts b/packages/bruno-api-typescript/src/generator/apiFactoryGenerator.ts index 110df6e8..449b243b 100644 --- a/packages/bruno-api-typescript/src/generator/apiFactoryGenerator.ts +++ b/packages/bruno-api-typescript/src/generator/apiFactoryGenerator.ts @@ -5,7 +5,7 @@ import { ParsedBrunoFile, extractJsonFromDocs } from '../parser/bruParser'; import { ApiFunction } from './apiClientGenerator'; -import { generateTypeScriptInterface, toCamelCase, functionNameToTypeName } from './typeGenerator'; +import { generateTypeScriptInterface, toCamelCase, functionNameToTypeName, toObjectPropertyKey } from './typeGenerator'; /** * 빈 인터페이스를 Record 타입으로 변환 @@ -45,7 +45,7 @@ function generateApiFunctionForFactory(apiFunc: ApiFunction, parsed: ParsedBruno // URL 변수는 제외 if (varName === 'URL') continue; - const camelVarName = varName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + const camelVarName = toCamelCase(varName); if (!urlParams.includes(camelVarName) && !processedBrunoVars.has(camelVarName)) { urlParams.push(camelVarName); paramsList.push(`${camelVarName}: string | number`); @@ -208,7 +208,7 @@ export function generateApiFactory( const functionCode = generateApiFunctionForFactory(apiFunc, parsed); // 들여쓰기 추가 (2칸) const indentedCode = functionCode.split('\n').map((line, index) => { - if (index === 0) return ` ${apiFunc.name}: ${line}`; + if (index === 0) return ` ${toObjectPropertyKey(apiFunc.name)}: ${line}`; return ` ${line}`; }).join('\n'); lines.push(indentedCode + ','); diff --git a/packages/bruno-api-typescript/src/generator/index.ts b/packages/bruno-api-typescript/src/generator/index.ts index b0cb3d6c..7c29d9e2 100644 --- a/packages/bruno-api-typescript/src/generator/index.ts +++ b/packages/bruno-api-typescript/src/generator/index.ts @@ -11,7 +11,7 @@ import { generateMSWHandler, generateDomainHandlersIndex, generateMSWIndex } fro import { generateApiFactory } from './apiFactoryGenerator'; import { generateApiDefinitionsFile } from './apiDefinitionGenerator'; import { BrunoHashCache } from './brunoHashCache'; -import { toCamelCase } from './typeGenerator'; +import { functionNameToTypeName, toCamelCase } from './typeGenerator'; export interface GenerateHooksOptions { brunoDir: string; @@ -144,9 +144,26 @@ export async function generateHooks(options: GenerateHooksOptions): Promise>(); const domainDirs = new Set(); + const domainFunctionNameCounts = new Map(); for (const { filePath, parsed, domain } of allParsedFiles) { - const apiFunc = extractApiFunction(parsed, filePath); + const extractedApiFunc = extractApiFunction(parsed, filePath); + if (!extractedApiFunc) { + continue; + } + + const nameKey = `${domain}:${extractedApiFunc.name}`; + const duplicateCount = (domainFunctionNameCounts.get(nameKey) ?? 0) + 1; + domainFunctionNameCounts.set(nameKey, duplicateCount); + + const apiFunc = duplicateCount === 1 + ? extractedApiFunc + : { + ...extractedApiFunc, + name: `${extractedApiFunc.name}${duplicateCount}`, + responseType: functionNameToTypeName(`${extractedApiFunc.name}${duplicateCount}`), + }; + if (!apiFunc) { continue; } @@ -230,6 +247,14 @@ export async function generateHooks(options: GenerateHooksOptions): Promise `export * from './${domain}';`) + .join('\n') + '\n'; + const rootIndexPath = join(outputDir, 'index.ts'); + writeFileSync(rootIndexPath, rootIndexContent, 'utf-8'); + console.log(`✅ Generated: ${rootIndexPath}`); + hashCache.cleanup(); hashCache.save(); console.log(`\n💾 Hash cache saved: ${hashCache.getCachePath()}`); diff --git a/packages/bruno-api-typescript/src/generator/typeGenerator.ts b/packages/bruno-api-typescript/src/generator/typeGenerator.ts index 70badd2a..dc61c69f 100644 --- a/packages/bruno-api-typescript/src/generator/typeGenerator.ts +++ b/packages/bruno-api-typescript/src/generator/typeGenerator.ts @@ -53,7 +53,7 @@ function generateInterfaceContent(obj: Record, indent: number = 0): for (const [key, value] of Object.entries(obj)) { const type = inferTypeScriptType(value, toPascalCase(key), indent + 1); - properties.push(`${indentStr} ${key}: ${type};`); + properties.push(`${indentStr} ${toObjectPropertyKey(key)}: ${type};`); } return `{\n${properties.join('\n')}\n${indentStr}}`; @@ -68,6 +68,28 @@ export function generateTypeScriptInterface( ): TypeDefinition[] { const definitions: TypeDefinition[] = []; + if (Array.isArray(json)) { + if (json.length === 0) { + definitions.push({ name: interfaceName, content: `export type ${interfaceName} = any[];` }); + return definitions; + } + + extractNestedTypes(json, '', definitions, interfaceName, true); + + const itemTypes = new Set(); + for (const item of json) { + if (item === null || item === undefined) { + itemTypes.add('null'); + } else { + itemTypes.add(getPropertyType(item, 'Item', interfaceName)); + } + } + + const unionType = Array.from(itemTypes).join(' | '); + definitions.push({ name: interfaceName, content: `export type ${interfaceName} = (${unionType})[];` }); + return definitions; + } + // 중첩된 타입 추출 (메인 타입 제외) extractNestedTypes(json, '', definitions, interfaceName, true); @@ -96,7 +118,7 @@ export function generateTypeScriptInterface( type = 'null'; } - properties.push(` ${key}: ${type};`); + properties.push(` ${toObjectPropertyKey(key)}: ${type};`); } // 빈 인터페이스인 경우 Record 타입으로 생성 @@ -162,7 +184,7 @@ function extractNestedTypes( const propType = types.length === 1 ? types[0] : types.join(' | '); - properties.push(` ${key}: ${propType};`); + properties.push(` ${toObjectPropertyKey(key)}: ${propType};`); // 재귀적으로 중첩된 타입 추출 const val = itemType[key]; @@ -203,7 +225,7 @@ function extractNestedTypes( const propType = types.length === 1 ? types[0] : types.join(' | '); - properties.push(` ${key}: ${propType};`); + properties.push(` ${toObjectPropertyKey(key)}: ${propType};`); // 재귀적으로 중첩된 타입 추출 const val = value[key]; @@ -287,22 +309,53 @@ function getPropertyType(value: any, typeName: string, parentTypeName?: string): } } -/** - * 문자열을 PascalCase로 변환 - */ +function toIdentifierTokens(value: string): string[] { + const normalized = value + .normalize('NFKC') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + + const tokens = normalized + .split(/[^\p{L}\p{N}]+/u) + .map(token => token.trim()) + .filter(Boolean); + + return tokens.length > 0 ? tokens : ['value']; +} + +function uppercaseFirst(value: string): string { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +function ensureIdentifierStart(value: string, prefix: string): string { + if (!value) { + return prefix; + } + + return /^\d/u.test(value) ? `${prefix}${value}` : value; +} + function toPascalCase(str: string): string { - return str - .replace(/[-_](.)/g, (_, c) => c.toUpperCase()) - .replace(/^(.)/, (_, c) => c.toUpperCase()); + const tokens = toIdentifierTokens(str); + const pascal = tokens.map(token => uppercaseFirst(token)).join(''); + return ensureIdentifierStart(pascal, 'T'); } /** * 문자열을 camelCase로 변환 */ export function toCamelCase(str: string): string { - return str - .replace(/[-_](.)/g, (_, c) => c.toUpperCase()) - .replace(/^(.)/, (_, c) => c.toLowerCase()); + const tokens = toIdentifierTokens(str); + const [first, ...rest] = tokens; + const camel = `${first.toLowerCase()}${rest.map(token => uppercaseFirst(token)).join('')}`; + return ensureIdentifierStart(camel, 'v'); +} + +export function isValidTypeScriptIdentifier(value: string): boolean { + return /^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(value); +} + +export function toObjectPropertyKey(value: string): string { + return isValidTypeScriptIdentifier(value) ? value : JSON.stringify(value); } /** diff --git a/packages/bruno-api-typescript/tests/cli.test.js b/packages/bruno-api-typescript/tests/cli.test.js index bc778738..1ad0283a 100644 --- a/packages/bruno-api-typescript/tests/cli.test.js +++ b/packages/bruno-api-typescript/tests/cli.test.js @@ -558,4 +558,49 @@ get /test/no-docs }); }); +describe('식별자 안전성 테스트', () => { + test('한글/공백 파일명과 특수문자 JSON 키를 안전한 TS 코드로 생성', () => { + const identifierFixtureDir = join(TEST_OUTPUT_DIR, 'identifier-fixture'); + const adminDir = join(identifierFixtureDir, '7) 어드민 [Admin]'); + mkdirSync(adminDir, { recursive: true }); + + const filePath = join(adminDir, '권역 삭제.bru'); + const content = `meta { + name: 권역 삭제 + type: http +} + +delete /admin/regions/{{region-code}} + +docs { + \`\`\`json + { + "region-code": "KR", + "권역 이름": "동아시아" + } + \`\`\` +} +`; + require('fs').writeFileSync(filePath, content); + + const outputDir = join(TEST_OUTPUT_DIR, 'identifier-output'); + execSync(`node dist/cli/index.js generate-hooks -i ${identifierFixtureDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const apiFile = join(outputDir, 'Admin', 'api.ts'); + const definitionsFile = join(outputDir, 'Admin', 'apiDefinitions.ts'); + const apiContent = readFileSync(apiFile, 'utf-8'); + const definitionsContent = readFileSync(definitionsFile, 'utf-8'); + + assert.ok(apiContent.includes('delete권역삭제'), '공백 없는 안전한 함수명이 생성되어야 함'); + assert.ok(!apiContent.includes('delete권역 삭제'), '공백이 포함된 함수명은 생성되면 안 됨'); + assert.ok(apiContent.includes('"region-code": string;'), '하이픈 키는 문자열 키로 생성되어야 함'); + assert.ok(apiContent.includes('"권역 이름": string;'), '공백 포함 한글 키는 문자열 키로 생성되어야 함'); + assert.ok(definitionsContent.includes('delete권역삭제'), 'API 정의 키도 안전한 식별자여야 함'); + + console.log('✅ 식별자 안전성 테스트 통과'); + }); +}); + console.log('\n🎉 모든 테스트 완료!'); From e5df1c06319bf7c08e4c8d5d03e57e55c6d1f845 Mon Sep 17 00:00:00 2001 From: manNomi Date: Mon, 2 Mar 2026 19:02:48 +0900 Subject: [PATCH 2/5] feat(admin): add chat socket test console --- apps/admin/package.json | 11 +- .../src/components/layout/AdminSidebar.tsx | 5 +- apps/admin/src/routeTree.gen.ts | 37 +- apps/admin/src/routes/chat-socket/index.tsx | 365 ++++++++++++++++++ pnpm-lock.yaml | 14 + 5 files changed, 423 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/routes/chat-socket/index.tsx diff --git a/apps/admin/package.json b/apps/admin/package.json index 2f556981..91f23361 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -20,12 +20,13 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@stomp/stompjs": "^7.1.1", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", - "@tanstack/react-router": "^1.132.0", - "@tanstack/react-router-devtools": "^1.132.0", - "@tanstack/react-query": "^5.84.1", - "@tanstack/react-router-ssr-query": "^1.131.7", + "@tanstack/react-query": "^5.84.1", + "@tanstack/react-router": "^1.132.0", + "@tanstack/react-router-devtools": "^1.132.0", + "@tanstack/react-router-ssr-query": "^1.131.7", "@tanstack/react-start": "^1.132.0", "@tanstack/router-plugin": "^1.132.0", "axios": "^1.6.7", @@ -36,6 +37,7 @@ "nitro": "npm:nitro-nightly@latest", "react": "^19.2.0", "react-dom": "^19.2.0", + "sockjs-client": "^1.6.1", "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.6", @@ -49,6 +51,7 @@ "@types/node": "^22.10.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "@types/sockjs-client": "^1.5.4", "@vitejs/plugin-react": "^5.0.4", "jsdom": "^27.0.0", "typescript": "^5.7.2", diff --git a/apps/admin/src/components/layout/AdminSidebar.tsx b/apps/admin/src/components/layout/AdminSidebar.tsx index e4a16422..e2e0a3f9 100644 --- a/apps/admin/src/components/layout/AdminSidebar.tsx +++ b/apps/admin/src/components/layout/AdminSidebar.tsx @@ -1,8 +1,8 @@ -import { Building2, FileText, FlaskConical, UserCircle2 } from "lucide-react"; +import { Building2, FileText, FlaskConical, MessageSquare, UserCircle2 } from "lucide-react"; import { cn } from "@/lib/utils"; interface AdminSidebarProps { - activeMenu: "scores" | "bruno"; + activeMenu: "scores" | "bruno" | "chatSocket"; } const sideMenus = [ @@ -11,6 +11,7 @@ const sideMenus = [ { key: "user", label: "유저 관리", icon: UserCircle2 }, { key: "scores", label: "성적 관리", icon: FileText, to: "/scores" as const }, { key: "bruno", label: "Bruno API", icon: FlaskConical, to: "/bruno" as const }, + { key: "chatSocket", label: "채팅 소켓", icon: MessageSquare, to: "/chat-socket" as const }, ] as const; export function AdminSidebar({ activeMenu }: AdminSidebarProps) { diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index da49656e..2d7390e8 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as ScoresIndexRouteImport } from './routes/scores/index' +import { Route as ChatSocketIndexRouteImport } from './routes/chat-socket/index' import { Route as BrunoIndexRouteImport } from './routes/bruno/index' import { Route as AuthLoginRouteImport } from './routes/auth/login' @@ -30,6 +31,11 @@ const ScoresIndexRoute = ScoresIndexRouteImport.update({ path: '/scores/', getParentRoute: () => rootRouteImport, } as any) +const ChatSocketIndexRoute = ChatSocketIndexRouteImport.update({ + id: '/chat-socket/', + path: '/chat-socket/', + getParentRoute: () => rootRouteImport, +} as any) const BrunoIndexRoute = BrunoIndexRouteImport.update({ id: '/bruno/', path: '/bruno/', @@ -46,6 +52,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/auth/login': typeof AuthLoginRoute '/bruno/': typeof BrunoIndexRoute + '/chat-socket/': typeof ChatSocketIndexRoute '/scores/': typeof ScoresIndexRoute } export interface FileRoutesByTo { @@ -53,6 +60,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/auth/login': typeof AuthLoginRoute '/bruno': typeof BrunoIndexRoute + '/chat-socket': typeof ChatSocketIndexRoute '/scores': typeof ScoresIndexRoute } export interface FileRoutesById { @@ -61,14 +69,28 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/auth/login': typeof AuthLoginRoute '/bruno/': typeof BrunoIndexRoute + '/chat-socket/': typeof ChatSocketIndexRoute '/scores/': typeof ScoresIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' | '/auth/login' | '/bruno/' | '/scores/' + fullPaths: + | '/' + | '/login' + | '/auth/login' + | '/bruno/' + | '/chat-socket/' + | '/scores/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' | '/auth/login' | '/bruno' | '/scores' - id: '__root__' | '/' | '/login' | '/auth/login' | '/bruno/' | '/scores/' + to: '/' | '/login' | '/auth/login' | '/bruno' | '/chat-socket' | '/scores' + id: + | '__root__' + | '/' + | '/login' + | '/auth/login' + | '/bruno/' + | '/chat-socket/' + | '/scores/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -76,6 +98,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute AuthLoginRoute: typeof AuthLoginRoute BrunoIndexRoute: typeof BrunoIndexRoute + ChatSocketIndexRoute: typeof ChatSocketIndexRoute ScoresIndexRoute: typeof ScoresIndexRoute } @@ -102,6 +125,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ScoresIndexRouteImport parentRoute: typeof rootRouteImport } + '/chat-socket/': { + id: '/chat-socket/' + path: '/chat-socket' + fullPath: '/chat-socket/' + preLoaderRoute: typeof ChatSocketIndexRouteImport + parentRoute: typeof rootRouteImport + } '/bruno/': { id: '/bruno/' path: '/bruno' @@ -124,6 +154,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, AuthLoginRoute: AuthLoginRoute, BrunoIndexRoute: BrunoIndexRoute, + ChatSocketIndexRoute: ChatSocketIndexRoute, ScoresIndexRoute: ScoresIndexRoute, } export const routeTree = rootRouteImport diff --git a/apps/admin/src/routes/chat-socket/index.tsx b/apps/admin/src/routes/chat-socket/index.tsx new file mode 100644 index 00000000..b8b290d2 --- /dev/null +++ b/apps/admin/src/routes/chat-socket/index.tsx @@ -0,0 +1,365 @@ +import { Client, type IMessage, type StompHeaders, type StompSubscription } from "@stomp/stompjs"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { Link2, Plug, PlugZap, Send } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import SockJS from "sockjs-client"; +import { toast } from "sonner"; +import { AdminSidebar } from "@/components/layout/AdminSidebar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { isTokenExpired } from "@/lib/utils/jwtUtils"; +import { loadAccessToken } from "@/lib/utils/localStorage"; + +type ConnectionState = "DISCONNECTED" | "CONNECTING" | "CONNECTED" | "ERROR"; + +interface ReceivedMessage { + id: number; + destination: string; + receivedAt: string; + headers: StompHeaders; + rawBody: string; +} + +interface EventLog { + id: number; + type: "SYSTEM" | "ERROR"; + message: string; + createdAt: string; +} + +const defaultDestinationTemplate = "/publish/chat/{roomId}"; +const defaultTopicTemplate = "/topic/chat/{roomId}"; +const defaultJsonPayload = '{\n "content": "어드민 소켓 테스트 메시지"\n}'; + +const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, ""); + +const resolveRoomTemplate = (template: string, roomId: string) => template.replaceAll("{roomId}", roomId.trim()); + +const toNowIso = () => new Date().toISOString(); + +export const Route = createFileRoute("/chat-socket/")({ + beforeLoad: () => { + if (typeof window !== "undefined") { + const token = loadAccessToken(); + if (!token || isTokenExpired(token)) { + throw redirect({ to: "/auth/login" }); + } + } + }, + component: ChatSocketPage, +}); + +function ChatSocketPage() { + const clientRef = useRef(null); + const subscriptionRef = useRef(null); + + const [connectionState, setConnectionState] = useState("DISCONNECTED"); + const [serverUrl, setServerUrl] = useState(import.meta.env.VITE_API_SERVER_URL?.trim() ?? ""); + const [token, setToken] = useState(""); + const [roomId, setRoomId] = useState(""); + const [topicTemplate, setTopicTemplate] = useState(defaultTopicTemplate); + const [destinationTemplate, setDestinationTemplate] = useState(defaultDestinationTemplate); + const [jsonPayload, setJsonPayload] = useState(defaultJsonPayload); + const [receivedMessages, setReceivedMessages] = useState([]); + const [eventLogs, setEventLogs] = useState([]); + const [isPending, setIsPending] = useState(false); + + const socketUrl = useMemo(() => { + const normalized = normalizeBaseUrl(serverUrl); + if (!normalized || !token.trim()) { + return ""; + } + return `${normalized}/connect?token=${encodeURIComponent(token.trim())}`; + }, [serverUrl, token]); + + const appendLog = useCallback((type: EventLog["type"], message: string) => { + setEventLogs((prev) => + [{ id: Date.now() + Math.random(), type, message, createdAt: toNowIso() }, ...prev].slice(0, 100), + ); + }, []); + + useEffect(() => { + const accessToken = loadAccessToken(); + if (accessToken) { + setToken(accessToken); + } + }, []); + + const deactivateClient = useCallback( + async (emitLog: boolean) => { + setIsPending(true); + try { + if (subscriptionRef.current) { + subscriptionRef.current.unsubscribe(); + subscriptionRef.current = null; + } + + const currentClient = clientRef.current; + clientRef.current = null; + + if (currentClient?.active) { + await currentClient.deactivate(); + } + + setConnectionState("DISCONNECTED"); + if (emitLog) { + appendLog("SYSTEM", "소켓 연결을 종료했습니다."); + } + } catch (error) { + const message = error instanceof Error ? error.message : "소켓 종료 중 오류가 발생했습니다."; + setConnectionState("ERROR"); + appendLog("ERROR", message); + } finally { + setIsPending(false); + } + }, + [appendLog], + ); + + useEffect(() => { + return () => { + void deactivateClient(false); + }; + }, [deactivateClient]); + + const handleConnect = async () => { + if (!roomId.trim()) { + toast.error("Room ID를 입력해주세요."); + return; + } + + if (!socketUrl) { + toast.error("서버 URL과 토큰을 확인해주세요."); + return; + } + + if (clientRef.current?.active) { + await deactivateClient(false); + } + + setIsPending(true); + setConnectionState("CONNECTING"); + + try { + const nextClient = new Client({ + webSocketFactory: () => new SockJS(socketUrl), + reconnectDelay: 0, + heartbeatIncoming: 50000, + heartbeatOutgoing: 50000, + }); + + nextClient.onConnect = () => { + const resolvedTopic = resolveRoomTemplate(topicTemplate, roomId); + subscriptionRef.current = nextClient.subscribe(resolvedTopic, (message: IMessage) => { + setReceivedMessages((prev) => + [ + { + id: Date.now() + Math.random(), + destination: resolvedTopic, + receivedAt: toNowIso(), + headers: message.headers, + rawBody: message.body, + }, + ...prev, + ].slice(0, 100), + ); + }); + + setConnectionState("CONNECTED"); + setIsPending(false); + appendLog("SYSTEM", `연결 완료 및 구독 시작: ${resolvedTopic}`); + }; + + nextClient.onStompError = (frame) => { + setConnectionState("ERROR"); + setIsPending(false); + appendLog("ERROR", `Broker error: ${frame.headers.message ?? "unknown"}`); + }; + + nextClient.onWebSocketError = () => { + setConnectionState("ERROR"); + setIsPending(false); + appendLog("ERROR", "WebSocket 에러가 발생했습니다."); + }; + + nextClient.onWebSocketClose = () => { + if (connectionState !== "ERROR") { + setConnectionState("DISCONNECTED"); + } + }; + + clientRef.current = nextClient; + nextClient.activate(); + } catch (error) { + const message = error instanceof Error ? error.message : "소켓 연결 중 오류가 발생했습니다."; + setConnectionState("ERROR"); + setIsPending(false); + appendLog("ERROR", message); + } + }; + + const handleSendRaw = () => { + const client = clientRef.current; + if (!client?.connected) { + toast.error("소켓이 연결되지 않았습니다."); + return; + } + + if (!roomId.trim()) { + toast.error("Room ID를 입력해주세요."); + return; + } + + try { + const parsedPayload = JSON.parse(jsonPayload); + const destination = resolveRoomTemplate(destinationTemplate, roomId); + client.publish({ + destination, + body: JSON.stringify(parsedPayload), + }); + appendLog("SYSTEM", `메시지 전송 완료: ${destination}`); + } catch { + toast.error("Payload JSON 형식이 올바르지 않습니다."); + } + }; + + return ( +
+
+ + +
+
+ + + 채팅 소켓 연결 + + +
+

API 서버 URL

+ setServerUrl(event.target.value)} /> +
+
+

Access Token

+