Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.132.0",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"axios": "^1.6.7",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions apps/admin/src/components/layout/AdminSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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) {
Expand Down
37 changes: 34 additions & 3 deletions apps/admin/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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/',
Expand All @@ -46,13 +52,15 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/auth/login': typeof AuthLoginRoute
'/bruno/': typeof BrunoIndexRoute
'/chat-socket/': typeof ChatSocketIndexRoute
'/scores/': typeof ScoresIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/auth/login': typeof AuthLoginRoute
'/bruno': typeof BrunoIndexRoute
'/chat-socket': typeof ChatSocketIndexRoute
'/scores': typeof ScoresIndexRoute
}
export interface FileRoutesById {
Expand All @@ -61,21 +69,36 @@ 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 {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
AuthLoginRoute: typeof AuthLoginRoute
BrunoIndexRoute: typeof BrunoIndexRoute
ChatSocketIndexRoute: typeof ChatSocketIndexRoute
ScoresIndexRoute: typeof ScoresIndexRoute
}

Expand All @@ -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'
Expand All @@ -124,6 +154,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute,
AuthLoginRoute: AuthLoginRoute,
BrunoIndexRoute: BrunoIndexRoute,
ChatSocketIndexRoute: ChatSocketIndexRoute,
ScoresIndexRoute: ScoresIndexRoute,
}
export const routeTree = rootRouteImport
Expand Down
145 changes: 105 additions & 40 deletions apps/admin/src/routes/bruno/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string>;
}) as Record<string, Record<string, unknown>>;

const normalizeTokenKey = (value: string) => value.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();

const isRecord = (value: unknown): value is Record<string, unknown> =>
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,
});
}
}
}

Expand All @@ -95,12 +114,20 @@ const ALL_ENDPOINTS = parseDefinitionRegistry();

const toPrettyJson = (value: unknown) => JSON.stringify(value, null, 2);

const parseJsonRecord = (text: string, label: string): Record<string, unknown> => {
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<string, unknown> => {
const parsed = parseJsonValue(text, label);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${label}는 JSON 객체여야 합니다.`);
}
Expand Down Expand Up @@ -134,6 +161,16 @@ const resolvePath = (rawPath: string, pathParams: Record<string, unknown>) => {
});
};

const splitPathAndInlineQuery = (pathWithInlineQuery: string) => {
const questionMarkIndex = pathWithInlineQuery.indexOf("?");
const path = questionMarkIndex >= 0 ? pathWithInlineQuery.slice(0, questionMarkIndex) : pathWithInlineQuery;
const queryString = questionMarkIndex >= 0 ? pathWithInlineQuery.slice(questionMarkIndex + 1) : "";
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") {
Expand All @@ -148,6 +185,7 @@ export const Route = createFileRoute("/bruno/")({

function BrunoApiPage() {
const [search, setSearch] = useState("");
const [methodFilter, setMethodFilter] = useState<MethodFilter>("ALL");
const [selectedKey, setSelectedKey] = useState(
ALL_ENDPOINTS[0] ? `${ALL_ENDPOINTS[0].domain}:${ALL_ENDPOINTS[0].name}` : "",
);
Expand All @@ -160,16 +198,22 @@ function BrunoApiPage() {

const visibleEndpoints = useMemo(() => {
const normalized = search.trim().toLowerCase();
if (!normalized) {
return ALL_ENDPOINTS;
}

return ALL_ENDPOINTS.filter((endpoint) => {
return `${endpoint.domain} ${endpoint.name} ${endpoint.definition.method} ${endpoint.definition.path}`
.toLowerCase()
.includes(normalized);
const matchesMethod = methodFilter === "ALL" || endpoint.definition.method === methodFilter;
if (!matchesMethod) {
return false;
}
if (!normalized) {
return true;
}

const matchesSearch =
`${endpoint.domain} ${endpoint.name} ${endpoint.definition.method} ${endpoint.definition.path}`
.toLowerCase()
.includes(normalized);
return matchesSearch;
});
}, [search]);
}, [methodFilter, search]);

const selectedEndpoint = useMemo(() => {
return ALL_ENDPOINTS.find((endpoint) => `${endpoint.domain}:${endpoint.name}` === selectedKey) ?? null;
Expand Down Expand Up @@ -204,16 +248,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,
});
Expand Down Expand Up @@ -256,6 +305,22 @@ function BrunoApiPage() {
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<div className="flex flex-wrap gap-1">
{METHOD_FILTERS.map((method) => {
const active = methodFilter === method;
return (
<Button
key={method}
type="button"
size="sm"
variant={active ? "default" : "outline"}
onClick={() => setMethodFilter(method)}
>
{method}
</Button>
);
})}
</div>
</CardHeader>
<CardContent className="max-h-[680px] overflow-auto pt-0">
<div className="space-y-2">
Expand Down
Loading
Loading