Skip to content

Commit 8cc7d06

Browse files
committed
feat: bruno 코드젠 확장과 어드민 전역 인증 가드 적용
1 parent 0758f62 commit 8cc7d06

File tree

23 files changed

+695
-436
lines changed

23 files changed

+695
-436
lines changed

apps/admin/src/lib/api/client.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import {
1414
saveAccessToken,
1515
} from "@/lib/utils/localStorage";
1616

17+
const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();
18+
19+
if (!API_SERVER_URL) {
20+
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
21+
}
22+
1723
const tokenStorage: TokenStorageAdapter = {
1824
loadAccessToken,
1925
loadRefreshToken,
@@ -22,12 +28,6 @@ const tokenStorage: TokenStorageAdapter = {
2228
removeRefreshToken,
2329
};
2430

25-
const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();
26-
27-
if (!API_SERVER_URL) {
28-
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
29-
}
30-
3131
configureApiClientRuntime({
3232
baseURL: API_SERVER_URL,
3333
tokenStorage,

apps/admin/src/routes/__root.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
import { TanStackDevtools } from "@tanstack/react-devtools";
2-
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
2+
import { createRootRoute, HeadContent, redirect, Scripts } from "@tanstack/react-router";
33
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
44
import { Toaster } from "sonner";
55
import { QueryProvider } from "@/components/providers/QueryProvider";
6+
import { isTokenExpired } from "@/lib/utils/jwtUtils";
7+
import { loadAccessToken } from "@/lib/utils/localStorage";
68

79
import appCss from "../styles.css?url";
810

11+
const PUBLIC_PATHS = new Set(["/auth/login", "/login"]);
12+
913
export const Route = createRootRoute({
14+
beforeLoad: ({ location }) => {
15+
if (typeof window === "undefined") {
16+
return;
17+
}
18+
19+
const pathname = location.pathname;
20+
const isPublicPath = PUBLIC_PATHS.has(pathname);
21+
const accessToken = loadAccessToken();
22+
const isAuthenticated = accessToken !== null && !isTokenExpired(accessToken);
23+
24+
if (!isAuthenticated && !isPublicPath) {
25+
throw redirect({ to: "/auth/login" });
26+
}
27+
28+
if (isAuthenticated && isPublicPath) {
29+
throw redirect({ to: "/scores" });
30+
}
31+
},
1032
head: () => ({
1133
meta: [
1234
{

apps/admin/src/routes/bruno/index.tsx

Lines changed: 85 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createFileRoute, redirect } from "@tanstack/react-router";
1+
import { createFileRoute } from "@tanstack/react-router";
22
import { Copy, Play, RotateCcw } from "lucide-react";
33
import { useMemo, useState } from "react";
44
import { toast } from "sonner";
@@ -11,10 +11,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
1111
import { Textarea } from "@/components/ui/textarea";
1212
import { axiosInstance } from "@/lib/api/client";
1313
import { cn } from "@/lib/utils";
14-
import { isTokenExpired } from "@/lib/utils/jwtUtils";
15-
import { loadAccessToken } from "@/lib/utils/localStorage";
1614

17-
type DefinitionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
15+
type DefinitionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE" | "CONNECT";
1816

1917
interface ApiDefinitionEntry {
2018
method: DefinitionMethod;
@@ -25,6 +23,10 @@ interface ApiDefinitionEntry {
2523
response: unknown;
2624
}
2725

26+
interface ApiDefinitionModule {
27+
[key: string]: unknown;
28+
}
29+
2830
interface EndpointItem {
2931
domain: string;
3032
name: string;
@@ -38,48 +40,64 @@ interface RequestResult {
3840
body: unknown;
3941
}
4042

41-
const definitionFileContents = import.meta.glob("../../../../../packages/api-schema/src/apis/*/apiDefinitions.ts", {
42-
eager: true,
43-
query: "?raw",
44-
import: "default",
45-
}) as Record<string, string>;
43+
const definitionModules = import.meta.glob(
44+
"../../../../../packages/api-client/src/generated/apis/*/apiDefinitions.ts",
45+
{
46+
eager: true,
47+
import: "*",
48+
},
49+
) as Record<string, ApiDefinitionModule>;
4650

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

53+
const isDefinitionMethod = (value: unknown): value is DefinitionMethod => {
54+
if (typeof value !== "string") {
55+
return false;
56+
}
57+
58+
return ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT"].includes(value);
59+
};
60+
61+
const isApiDefinitionEntry = (value: unknown): value is ApiDefinitionEntry => {
62+
if (typeof value !== "object" || value === null) {
63+
return false;
64+
}
65+
66+
const candidate = value as Partial<ApiDefinitionEntry>;
67+
return isDefinitionMethod(candidate.method) && typeof candidate.path === "string";
68+
};
69+
4970
const parseDefinitionRegistry = (): EndpointItem[] => {
5071
const endpoints: EndpointItem[] = [];
5172

52-
for (const [modulePath, fileContent] of Object.entries(definitionFileContents)) {
73+
for (const [modulePath, module] of Object.entries(definitionModules)) {
5374
const domainMatch = modulePath.match(/apis\/([^/]+)\/apiDefinitions\.ts$/);
5475
if (!domainMatch) {
5576
continue;
5677
}
5778

5879
const domain = domainMatch[1];
59-
const endpointPattern =
60-
/^\s*([^:\n]+):\s*\{\s*\n\s*method:\s*'([A-Z]+)'\s+as const,\s*\n\s*path:\s*'([^']+)'\s+as const,/gm;
6180

62-
for (const match of fileContent.matchAll(endpointPattern)) {
63-
const endpointName = match[1]?.trim();
64-
const method = match[2]?.trim() as DefinitionMethod | undefined;
65-
const path = match[3]?.trim();
81+
for (const [exportName, exportValue] of Object.entries(module)) {
82+
if (!exportName.endsWith("ApiDefinitions")) {
83+
continue;
84+
}
6685

67-
if (!endpointName || !method || !path) {
86+
if (typeof exportValue !== "object" || exportValue === null) {
6887
continue;
6988
}
7089

71-
endpoints.push({
72-
domain,
73-
name: endpointName,
74-
definition: {
75-
method,
76-
path,
77-
pathParams: {},
78-
queryParams: {},
79-
body: {},
80-
response: {},
81-
},
82-
});
90+
for (const [endpointName, endpointDefinition] of Object.entries(exportValue)) {
91+
if (!isApiDefinitionEntry(endpointDefinition)) {
92+
continue;
93+
}
94+
95+
endpoints.push({
96+
domain,
97+
name: endpointName,
98+
definition: endpointDefinition,
99+
});
100+
}
83101
}
84102
}
85103

@@ -95,12 +113,24 @@ const ALL_ENDPOINTS = parseDefinitionRegistry();
95113

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

98-
const parseJsonRecord = (text: string, label: string): Record<string, unknown> => {
116+
const parseJsonValue = (text: string, label: string): unknown => {
99117
if (!text.trim()) {
118+
return undefined;
119+
}
120+
121+
try {
122+
return JSON.parse(text) as unknown;
123+
} catch {
124+
throw new Error(`${label}는 유효한 JSON이어야 합니다.`);
125+
}
126+
};
127+
128+
const parseJsonRecord = (text: string, label: string): Record<string, unknown> => {
129+
const parsed = parseJsonValue(text, label);
130+
if (parsed === undefined) {
100131
return {};
101132
}
102133

103-
const parsed = JSON.parse(text) as unknown;
104134
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
105135
throw new Error(`${label}는 JSON 객체여야 합니다.`);
106136
}
@@ -115,10 +145,10 @@ const toStringRecord = (value: Record<string, unknown>): Record<string, string>
115145
const resolvePath = (rawPath: string, pathParams: Record<string, unknown>) => {
116146
const withoutBaseToken = rawPath.replace("{{URL}}", "");
117147

118-
return withoutBaseToken.replace(/\{\{([^}]+)\}\}/g, (_full, tokenName: string) => {
148+
const findPathParam = (tokenName: string): unknown => {
119149
const exact = pathParams[tokenName];
120150
if (exact !== undefined && exact !== null) {
121-
return encodeURIComponent(String(exact));
151+
return exact;
122152
}
123153

124154
const normalizedToken = normalizeTokenKey(tokenName);
@@ -127,22 +157,30 @@ const resolvePath = (rawPath: string, pathParams: Record<string, unknown>) => {
127157
);
128158

129159
if (similarEntry) {
130-
return encodeURIComponent(String(similarEntry[1]));
160+
return similarEntry[1];
131161
}
132162

133163
throw new Error(`경로 파라미터 '${tokenName}' 값이 필요합니다.`);
164+
};
165+
166+
let resolved = withoutBaseToken;
167+
168+
resolved = resolved.replace(/\{\{([^}]+)\}\}/g, (_full, tokenName: string) => {
169+
return encodeURIComponent(String(findPathParam(tokenName)));
134170
});
171+
172+
resolved = resolved.replace(/:([a-zA-Z0-9_-]+)/g, (_full, tokenName: string) => {
173+
return encodeURIComponent(String(findPathParam(tokenName)));
174+
});
175+
176+
resolved = resolved.replace(/\{([a-zA-Z0-9_-]+)\}/g, (_full, tokenName: string) => {
177+
return encodeURIComponent(String(findPathParam(tokenName)));
178+
});
179+
180+
return resolved;
135181
};
136182

137183
export const Route = createFileRoute("/bruno/")({
138-
beforeLoad: () => {
139-
if (typeof window !== "undefined") {
140-
const token = loadAccessToken();
141-
if (!token || isTokenExpired(token)) {
142-
throw redirect({ to: "/auth/login" });
143-
}
144-
}
145-
},
146184
component: BrunoApiPage,
147185
});
148186

@@ -204,7 +242,7 @@ function BrunoApiPage() {
204242
const pathParams = parseJsonRecord(pathParamsText, "Path Params");
205243
const queryParams = parseJsonRecord(queryParamsText, "Query Params");
206244
const headers = toStringRecord(parseJsonRecord(headersText, "Headers"));
207-
const body = parseJsonRecord(bodyText, "Body");
245+
const body = parseJsonValue(bodyText, "Body");
208246

209247
const path = resolvePath(selectedEndpoint.definition.path, pathParams);
210248
const startedAt = performance.now();
@@ -213,7 +251,10 @@ function BrunoApiPage() {
213251
url: path,
214252
method: selectedEndpoint.definition.method,
215253
params: queryParams,
216-
data: selectedEndpoint.definition.method === "GET" ? undefined : body,
254+
data:
255+
selectedEndpoint.definition.method === "GET" || selectedEndpoint.definition.method === "HEAD"
256+
? undefined
257+
: body,
217258
headers,
218259
validateStatus: () => true,
219260
});

apps/admin/src/routes/scores/index.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,12 @@
1-
import { createFileRoute, redirect } from "@tanstack/react-router";
1+
import { createFileRoute } from "@tanstack/react-router";
22
import { useId, useState } from "react";
33
import { GpaScoreTable } from "@/components/features/scores/GpaScoreTable";
44
import { LanguageScoreTable } from "@/components/features/scores/LanguageScoreTable";
55
import { AdminSidebar } from "@/components/layout/AdminSidebar";
66
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
7-
import { isTokenExpired } from "@/lib/utils/jwtUtils";
8-
import { loadAccessToken } from "@/lib/utils/localStorage";
97
import type { VerifyStatus } from "@/types/scores";
108

119
export const Route = createFileRoute("/scores/")({
12-
beforeLoad: () => {
13-
if (typeof window !== "undefined") {
14-
const token = loadAccessToken();
15-
if (!token || isTokenExpired(token)) {
16-
throw redirect({ to: "/auth/login" });
17-
}
18-
}
19-
},
2010
component: ScoresPage,
2111
});
2212

0 commit comments

Comments
 (0)