diff --git a/apps/admin/package.json b/apps/admin/package.json index 2d04c999..f5a41f65 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -12,6 +12,7 @@ "check": "biome check" }, "dependencies": { + "@solid-connect/api-client": "workspace:*", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/apps/admin/src/lib/api/auth.ts b/apps/admin/src/lib/api/auth.ts index 21c56dc3..1bbb973f 100644 --- a/apps/admin/src/lib/api/auth.ts +++ b/apps/admin/src/lib/api/auth.ts @@ -1,15 +1 @@ -import type { AxiosResponse } from "axios"; -import { publicAxiosInstance } from "@/lib/api/client"; -import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth"; - -export const adminSignInApi = (email: string, password: string): Promise> => - publicAxiosInstance.post("/auth/email/sign-in", { email, password }); - -export const reissueAccessTokenApi = (refreshToken: string): Promise> => - publicAxiosInstance.post( - "/admin/auth/reissue", - {}, - { - headers: { Authorization: `Bearer ${refreshToken}` }, - }, - ); +export { adminSignInApi, reissueAccessTokenApi } from "@solid-connect/api-client/generated/admin"; diff --git a/apps/admin/src/lib/api/client.ts b/apps/admin/src/lib/api/client.ts index ea475449..82531181 100644 --- a/apps/admin/src/lib/api/client.ts +++ b/apps/admin/src/lib/api/client.ts @@ -1,5 +1,10 @@ -import axios, { type AxiosInstance } from "axios"; import { reissueAccessTokenApi } from "@/lib/api/auth"; +import { + axiosInstance, + configureApiClientRuntime, + publicAxiosInstance, + type TokenStorageAdapter, +} from "@solid-connect/api-client/runtime"; import { isTokenExpired } from "@/lib/utils/jwtUtils"; import { loadAccessToken, @@ -9,80 +14,22 @@ import { saveAccessToken, } from "@/lib/utils/localStorage"; -const convertToBearer = (token: string) => `Bearer ${token}`; +const tokenStorage: TokenStorageAdapter = { + loadAccessToken, + loadRefreshToken, + saveAccessToken, + removeAccessToken, + removeRefreshToken, +}; -export const axiosInstance: AxiosInstance = axios.create({ +configureApiClientRuntime({ baseURL: import.meta.env.VITE_API_SERVER_URL, - withCredentials: true, -}); - -axiosInstance.interceptors.request.use( - async (config) => { - const newConfig = { ...config }; - let accessToken: string | null = loadAccessToken(); - - if (accessToken === null || isTokenExpired(accessToken)) { - const refreshToken = loadRefreshToken(); - if (refreshToken === null || isTokenExpired(refreshToken)) { - removeAccessToken(); - removeRefreshToken(); - return config; - } - - await reissueAccessTokenApi(refreshToken) - .then((res) => { - accessToken = res.data.accessToken; - saveAccessToken(accessToken); - }) - .catch((err) => { - removeAccessToken(); - removeRefreshToken(); - console.error("인증 토큰 갱신중 오류가 발생했습니다", err); - }); - } - - if (accessToken !== null) { - newConfig.headers.Authorization = convertToBearer(accessToken); - } - return newConfig; - }, - (error) => Promise.reject(error), -); - -axiosInstance.interceptors.response.use( - (response) => response, - async (error) => { - const newError = { ...error }; - if (error.response?.status === 401 || error.response?.status === 403) { - const refreshToken = loadRefreshToken(); - - if (refreshToken === null || isTokenExpired(refreshToken)) { - removeAccessToken(); - removeRefreshToken(); - throw newError; - } - - try { - const newAccessToken = await reissueAccessTokenApi(refreshToken).then((res) => res.data.accessToken); - saveAccessToken(newAccessToken); - - if (error?.config.headers === undefined) { - newError.config.headers = {}; - } - newError.config.headers.Authorization = convertToBearer(newAccessToken); - - return await axios.request(newError.config); - } catch (_err) { - removeAccessToken(); - removeRefreshToken(); - throw Error("로그인이 필요합니다"); - } - } else { - throw newError; - } + tokenStorage, + isTokenExpired, + reissueAccessToken: async (refreshToken: string) => { + const response = await reissueAccessTokenApi(refreshToken); + return response.data.accessToken; }, -); - -export const publicAxiosInstance: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_SERVER_URL, }); + +export { axiosInstance, publicAxiosInstance }; diff --git a/apps/admin/src/lib/api/scores.ts b/apps/admin/src/lib/api/scores.ts index 83b3be8e..0caaf00f 100644 --- a/apps/admin/src/lib/api/scores.ts +++ b/apps/admin/src/lib/api/scores.ts @@ -1,45 +1 @@ -import { axiosInstance } from "@/lib/api/client"; -import type { - GpaScoreUpdateRequest, - GpaScoreWithUser, - LanguageScoreWithUser, - LanguageTestScoreUpdateRequest, - LanguageTestType, - PageResponse, - ScoreSearchCondition, - VerifyStatus, -} from "@/types/scores"; - -export const scoreApi = { - // GPA 성적 조회 - getGpaScores: (condition: ScoreSearchCondition, page: number): Promise> => - axiosInstance.get("/admin/scores/gpas", { params: { ...condition, page } }).then((res) => res.data), - - // GPA 성적 수정 - updateGpaScore: (id: number, status: VerifyStatus, reason?: string, score?: GpaScoreWithUser) => { - if (!score) throw new Error("Score data is required"); - const request: GpaScoreUpdateRequest = { - gpa: score.gpaScoreStatusResponse.gpaResponse.gpa, - gpaCriteria: score.gpaScoreStatusResponse.gpaResponse.gpaCriteria, - verifyStatus: status, - rejectedReason: reason, - }; - return axiosInstance.put(`/admin/scores/gpas/${id}`, request); - }, - - // 어학성적 조회 - getLanguageScores: (condition: ScoreSearchCondition, page: number): Promise> => - axiosInstance.get("/admin/scores/language-tests", { params: { ...condition, page } }).then((res) => res.data), - - // 어학성적 수정 - updateLanguageScore: (id: number, status: VerifyStatus, reason?: string, score?: LanguageScoreWithUser) => { - if (!score) throw new Error("Score data is required"); - const request: LanguageTestScoreUpdateRequest = { - languageTestType: score.languageTestScoreStatusResponse.languageTestResponse.languageTestType as LanguageTestType, - languageTestScore: score.languageTestScoreStatusResponse.languageTestResponse.languageTestScore, - verifyStatus: status, - rejectedReason: reason, - }; - return axiosInstance.put(`/admin/scores/language-tests/${id}`, request); - }, -}; +export { scoreApi } from "@solid-connect/api-client/generated/admin"; diff --git a/apps/admin/src/types/auth.ts b/apps/admin/src/types/auth.ts index 73034795..f5d7f271 100644 --- a/apps/admin/src/types/auth.ts +++ b/apps/admin/src/types/auth.ts @@ -1,8 +1 @@ -export interface AdminSignInResponse { - accessToken: string; - refreshToken: string; -} - -export interface ReissueAccessTokenResponse { - accessToken: string; -} +export type { AdminSignInResponse, ReissueAccessTokenResponse } from "@solid-connect/api-client/generated/admin"; diff --git a/apps/admin/src/types/scores.ts b/apps/admin/src/types/scores.ts index 9e95bc1b..e5a18915 100644 --- a/apps/admin/src/types/scores.ts +++ b/apps/admin/src/types/scores.ts @@ -1,105 +1,13 @@ -export type VerifyStatus = "PENDING" | "APPROVED" | "REJECTED"; - -export interface ScoreSearchCondition { - verifyStatus?: VerifyStatus; -} - -export interface GpaResponse { - gpa: number; - gpaCriteria: number; - gpaReportUrl: string; -} - -export interface GpaScore { - verifyStatus: VerifyStatus; - rejectedReason?: string; -} - -export interface GpaScoreStatusResponse { - id: number; - gpaResponse: GpaResponse; - verifyStatus: VerifyStatus; - rejectedReason: string | null; - createdAt: string; - updatedAt: string; -} - -export interface SiteUserResponse { - id: number; - nickname: string; - profileImageUrl: string; -} - -export interface GpaScoreWithUser { - gpaScoreStatusResponse: GpaScoreStatusResponse; - siteUserResponse: SiteUserResponse; -} - -export interface PageResponse { - content: T[]; - pageNumber: number; - pageSize: number; - totalElements: number; - totalPages: number; -} - -export interface LanguageResponse { - languageType: string; - score: number; - testDate: string; - expireDate: string; - languageReportUrl: string; -} - -export interface LanguageTestResponse { - languageTestType: string; - languageTestScore: string; - languageTestReportUrl: string; -} - -export interface LanguageTestScore { - verifyStatus: VerifyStatus; - rejectedReason?: string; -} - -export interface LanguageTestScoreStatusResponse { - id: number; - languageTestResponse: LanguageTestResponse; - verifyStatus: VerifyStatus; - rejectedReason: string | null; - createdAt: string; - updatedAt: string; -} - -export interface LanguageScoreWithUser { - languageTestScoreStatusResponse: LanguageTestScoreStatusResponse; - siteUserResponse: SiteUserResponse; -} - -export type LanguageTestType = - | "TOEIC" - | "TOEFL_IBT" - | "TOEFL_ITP" - | "IELTS" - | "JLPT" - | "NEW_HSK" - | "ETC" - | "DALF" - | "CEFR" - | "TCF" - | "TEF" - | "DUOLINGO"; - -export interface GpaScoreUpdateRequest { - gpa: number; - gpaCriteria: number; - verifyStatus: VerifyStatus; - rejectedReason?: string; -} - -export interface LanguageTestScoreUpdateRequest { - languageTestType: LanguageTestType; - languageTestScore: string; - verifyStatus: VerifyStatus; - rejectedReason?: string; -} +export type { + GpaScoreStatusResponse, + GpaScoreUpdateRequest, + GpaScoreWithUser, + LanguageScoreWithUser, + LanguageTestScoreStatusResponse, + LanguageTestScoreUpdateRequest, + LanguageTestType, + PageResponse, + ScoreSearchCondition, + SiteUserResponse, + VerifyStatus, +} from "@solid-connect/api-client/generated/admin"; diff --git a/package.json b/package.json index eed44686..aee87509 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,16 @@ "dev": "turbo dev", "build": "turbo build", "sync:bruno": "turbo run sync:bruno", + "codegen:check": "turbo run codegen:check", "lint": "turbo lint", "typecheck": "turbo typecheck", "ci:check": "turbo ci:check", "prepare": "husky" }, "pnpm": { - "onlyBuiltDependencies": ["bruno-api-typescript"] + "onlyBuiltDependencies": [ + "bruno-api-typescript" + ] }, "devDependencies": { "@biomejs/biome": "^2.3.11", diff --git a/packages/api-client/package.json b/packages/api-client/package.json new file mode 100644 index 00000000..6ee2e9e9 --- /dev/null +++ b/packages/api-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@solid-connect/api-client", + "version": "0.0.0", + "private": true, + "scripts": { + "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", + "codegen:check": "pnpm run sync:bruno && git diff --exit-code src/generated/apis || (echo \"Generated API client is out of sync. Run: pnpm --filter @solid-connect/api-client run sync:bruno\" && exit 1)" + }, + "exports": { + ".": "./src/index.ts", + "./runtime": "./src/runtime/index.ts", + "./generated/admin": "./src/generated/admin/index.ts", + "./hooks": "./src/hooks/index.ts" + }, + "dependencies": { + "axios": "^1.6.7" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/api-client/scripts/sync-bruno.mjs b/packages/api-client/scripts/sync-bruno.mjs new file mode 100644 index 00000000..3208aabd --- /dev/null +++ b/packages/api-client/scripts/sync-bruno.mjs @@ -0,0 +1,125 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; + +const rootDir = resolve(process.cwd()); +const monorepoRootDir = resolve(rootDir, "../.."); + +loadEnvFiles([ + resolve(rootDir, ".env.local"), + resolve(rootDir, ".env"), + resolve(monorepoRootDir, ".env.local"), + resolve(monorepoRootDir, ".env"), +]); + +const defaultLocalCollectionDir = resolve(rootDir, "../../../api-docs/Solid Connection"); +const cacheRoot = resolve(rootDir, ".cache"); +const checkoutDir = resolve(cacheRoot, "bruno-source"); +const sourceMode = normalizeSourceMode(process.env.BRUNO_SOURCE_MODE ?? "remote"); +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; + +function loadEnvFiles(filePaths) { + for (const filePath of filePaths) { + loadEnvFile(filePath); + } +} + +function loadEnvFile(filePath) { + if (typeof process.loadEnvFile !== "function") { + return; + } + + try { + process.loadEnvFile(filePath); + } catch (error) { + if (error?.code === "ENOENT") { + return; + } + + throw error; + } +} + +function normalizeSourceMode(mode) { + const allowedModes = new Set(["auto", "local", "remote"]); + if (!allowedModes.has(mode)) { + throw new Error(`Invalid BRUNO_SOURCE_MODE: ${mode}. Expected one of auto, local, remote.`); + } + + return mode; +} + +function run(command, args, cwd = rootDir) { + const result = spawnSync(command, args, { + cwd, + stdio: "inherit", + env: process.env, + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function ensureRemoteCollectionDir() { + if (!remoteRepoUrl) { + throw new Error( + "BRUNO_REPO_URL is required for remote sync. Set it in packages/api-client/.env, repo root .env, or shell environment.", + ); + } + + mkdirSync(cacheRoot, { recursive: true }); + rmSync(checkoutDir, { recursive: true, force: true }); + run("git", ["clone", "--depth", "1", "--branch", remoteRepoRef, remoteRepoUrl, checkoutDir]); + + const collectionDir = resolve(checkoutDir, remoteCollectionPath); + if (!existsSync(collectionDir)) { + throw new Error(`Bruno collection path does not exist: ${collectionDir}`); + } + + return collectionDir; +} + +function resolveCollectionDir() { + if (explicitCollectionDir) { + const fullPath = resolve(rootDir, explicitCollectionDir); + if (!existsSync(fullPath)) { + throw new Error(`BRUNO_COLLECTION_DIR does not exist: ${fullPath}`); + } + return fullPath; + } + + if (sourceMode === "local") { + if (!existsSync(defaultLocalCollectionDir)) { + throw new Error(`Local Bruno collection directory does not exist: ${defaultLocalCollectionDir}`); + } + return defaultLocalCollectionDir; + } + + if (sourceMode === "remote") { + return ensureRemoteCollectionDir(); + } + + if (existsSync(defaultLocalCollectionDir)) { + return defaultLocalCollectionDir; + } + + return ensureRemoteCollectionDir(); +} + +const collectionDir = resolveCollectionDir(); + +run("pnpm", ["-C", "../bruno-api-typescript", "run", "build"]); +run("node", [ + "../bruno-api-typescript/dist/cli/index.js", + "generate-hooks", + "-i", + collectionDir, + "-o", + "./src/generated/apis", + "--axios-path", + "../../../runtime/axiosInstance", +]); diff --git a/packages/api-client/src/generated/admin/auth.ts b/packages/api-client/src/generated/admin/auth.ts new file mode 100644 index 00000000..eee4cd3a --- /dev/null +++ b/packages/api-client/src/generated/admin/auth.ts @@ -0,0 +1,15 @@ +import type { AxiosResponse } from "axios"; +import { publicAxiosInstance } from "../../runtime"; +import type { AdminSignInResponse, ReissueAccessTokenResponse } from "./types"; + +export const adminSignInApi = (email: string, password: string): Promise> => + publicAxiosInstance.post("/auth/email/sign-in", { email, password }); + +export const reissueAccessTokenApi = (refreshToken: string): Promise> => + publicAxiosInstance.post( + "/admin/auth/reissue", + {}, + { + headers: { Authorization: `Bearer ${refreshToken}` }, + }, + ); diff --git a/packages/api-client/src/generated/admin/index.ts b/packages/api-client/src/generated/admin/index.ts new file mode 100644 index 00000000..86e6d208 --- /dev/null +++ b/packages/api-client/src/generated/admin/index.ts @@ -0,0 +1,17 @@ +export { adminSignInApi, reissueAccessTokenApi } from "./auth"; +export { scoreApi } from "./scores"; +export type { + AdminSignInResponse, + GpaScoreStatusResponse, + GpaScoreUpdateRequest, + GpaScoreWithUser, + LanguageScoreWithUser, + LanguageTestScoreStatusResponse, + LanguageTestScoreUpdateRequest, + LanguageTestType, + PageResponse, + ReissueAccessTokenResponse, + ScoreSearchCondition, + SiteUserResponse, + VerifyStatus, +} from "./types"; diff --git a/packages/api-client/src/generated/admin/scores.ts b/packages/api-client/src/generated/admin/scores.ts new file mode 100644 index 00000000..5e00cdae --- /dev/null +++ b/packages/api-client/src/generated/admin/scores.ts @@ -0,0 +1,41 @@ +import { axiosInstance } from "../../runtime"; +import type { + GpaScoreUpdateRequest, + GpaScoreWithUser, + LanguageScoreWithUser, + LanguageTestScoreUpdateRequest, + LanguageTestType, + PageResponse, + ScoreSearchCondition, + VerifyStatus, +} from "./types"; + +export const scoreApi = { + getGpaScores: (condition: ScoreSearchCondition, page: number): Promise> => + axiosInstance.get("/admin/scores/gpas", { params: { ...condition, page } }).then((res) => res.data), + + updateGpaScore: (id: number, status: VerifyStatus, reason?: string, score?: GpaScoreWithUser) => { + if (!score) throw new Error("Score data is required"); + const request: GpaScoreUpdateRequest = { + gpa: score.gpaScoreStatusResponse.gpaResponse.gpa, + gpaCriteria: score.gpaScoreStatusResponse.gpaResponse.gpaCriteria, + verifyStatus: status, + rejectedReason: reason, + }; + return axiosInstance.put(`/admin/scores/gpas/${id}`, request); + }, + + getLanguageScores: (condition: ScoreSearchCondition, page: number): Promise> => + axiosInstance.get("/admin/scores/language-tests", { params: { ...condition, page } }).then((res) => res.data), + + updateLanguageScore: (id: number, status: VerifyStatus, reason?: string, score?: LanguageScoreWithUser) => { + if (!score) throw new Error("Score data is required"); + const request: LanguageTestScoreUpdateRequest = { + languageTestType: score.languageTestScoreStatusResponse.languageTestResponse.languageTestType as LanguageTestType, + languageTestScore: score.languageTestScoreStatusResponse.languageTestResponse.languageTestScore, + verifyStatus: status, + rejectedReason: reason, + }; + return axiosInstance.put(`/admin/scores/language-tests/${id}`, request); + }, +}; diff --git a/packages/api-client/src/generated/admin/types.ts b/packages/api-client/src/generated/admin/types.ts new file mode 100644 index 00000000..78457c32 --- /dev/null +++ b/packages/api-client/src/generated/admin/types.ts @@ -0,0 +1,96 @@ +export interface AdminSignInResponse { + accessToken: string; + refreshToken: string; +} + +export interface ReissueAccessTokenResponse { + accessToken: string; +} + +export type VerifyStatus = "PENDING" | "APPROVED" | "REJECTED"; + +export interface ScoreSearchCondition { + verifyStatus?: VerifyStatus; +} + +export interface GpaResponse { + gpa: number; + gpaCriteria: number; + gpaReportUrl: string; +} + +export interface GpaScoreStatusResponse { + id: number; + gpaResponse: GpaResponse; + verifyStatus: VerifyStatus; + rejectedReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SiteUserResponse { + id: number; + nickname: string; + profileImageUrl: string; +} + +export interface GpaScoreWithUser { + gpaScoreStatusResponse: GpaScoreStatusResponse; + siteUserResponse: SiteUserResponse; +} + +export interface PageResponse { + content: T[]; + pageNumber: number; + pageSize: number; + totalElements: number; + totalPages: number; +} + +export interface LanguageTestResponse { + languageTestType: string; + languageTestScore: string; + languageTestReportUrl: string; +} + +export interface LanguageTestScoreStatusResponse { + id: number; + languageTestResponse: LanguageTestResponse; + verifyStatus: VerifyStatus; + rejectedReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface LanguageScoreWithUser { + languageTestScoreStatusResponse: LanguageTestScoreStatusResponse; + siteUserResponse: SiteUserResponse; +} + +export type LanguageTestType = + | "TOEIC" + | "TOEFL_IBT" + | "TOEFL_ITP" + | "IELTS" + | "JLPT" + | "NEW_HSK" + | "ETC" + | "DALF" + | "CEFR" + | "TCF" + | "TEF" + | "DUOLINGO"; + +export interface GpaScoreUpdateRequest { + gpa: number; + gpaCriteria: number; + verifyStatus: VerifyStatus; + rejectedReason?: string; +} + +export interface LanguageTestScoreUpdateRequest { + languageTestType: LanguageTestType; + languageTestScore: string; + verifyStatus: VerifyStatus; + rejectedReason?: string; +} diff --git a/packages/api-client/src/hooks/index.ts b/packages/api-client/src/hooks/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/api-client/src/hooks/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts new file mode 100644 index 00000000..92b60985 --- /dev/null +++ b/packages/api-client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./generated/admin"; +export * from "./runtime"; diff --git a/packages/api-client/src/runtime/axiosInstance.ts b/packages/api-client/src/runtime/axiosInstance.ts new file mode 100644 index 00000000..1c8cd43a --- /dev/null +++ b/packages/api-client/src/runtime/axiosInstance.ts @@ -0,0 +1,126 @@ +import axios, { type AxiosError, AxiosHeaders, type AxiosInstance, type InternalAxiosRequestConfig } from "axios"; + +export interface TokenStorageAdapter { + loadAccessToken: () => string | null; + loadRefreshToken: () => string | null; + saveAccessToken: (token: string) => void; + removeAccessToken: () => void; + removeRefreshToken: () => void; +} + +export interface RuntimeConfig { + baseURL: string; + tokenStorage: TokenStorageAdapter; + isTokenExpired: (token: string) => boolean; + reissueAccessToken: (refreshToken: string) => Promise; +} + +export const convertToBearer = (token: string) => `Bearer ${token}`; + +export const axiosInstance: AxiosInstance = axios.create({ + withCredentials: true, +}); + +export const publicAxiosInstance: AxiosInstance = axios.create({ + withCredentials: true, +}); + +let runtimeConfig: RuntimeConfig | null = null; +let interceptorsBound = false; + +const attachAuthorization = (config: InternalAxiosRequestConfig, token: string) => { + if (!config.headers) { + config.headers = AxiosHeaders.from({}); + } + config.headers.Authorization = convertToBearer(token); +}; + +const clearTokens = (storage: TokenStorageAdapter) => { + storage.removeAccessToken(); + storage.removeRefreshToken(); +}; + +const bindInterceptors = () => { + if (interceptorsBound) { + return; + } + + axiosInstance.interceptors.request.use( + async (config) => { + if (!runtimeConfig) { + return config; + } + + const { tokenStorage, isTokenExpired, reissueAccessToken } = runtimeConfig; + let accessToken = tokenStorage.loadAccessToken(); + + if (accessToken === null || isTokenExpired(accessToken)) { + const refreshToken = tokenStorage.loadRefreshToken(); + if (refreshToken === null || isTokenExpired(refreshToken)) { + clearTokens(tokenStorage); + return config; + } + + try { + accessToken = await reissueAccessToken(refreshToken); + tokenStorage.saveAccessToken(accessToken); + } catch { + clearTokens(tokenStorage); + return config; + } + } + + if (accessToken !== null) { + attachAuthorization(config, accessToken); + } + + return config; + }, + (error) => Promise.reject(error), + ); + + axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (!runtimeConfig) { + throw error; + } + + const status = error.response?.status; + if (status !== 401 && status !== 403) { + throw error; + } + + const originalConfig = error.config; + if (!originalConfig) { + throw error; + } + + const { tokenStorage, isTokenExpired, reissueAccessToken } = runtimeConfig; + const refreshToken = tokenStorage.loadRefreshToken(); + if (refreshToken === null || isTokenExpired(refreshToken)) { + clearTokens(tokenStorage); + throw error; + } + + try { + const newAccessToken = await reissueAccessToken(refreshToken); + tokenStorage.saveAccessToken(newAccessToken); + attachAuthorization(originalConfig, newAccessToken); + return await axios.request(originalConfig); + } catch { + clearTokens(tokenStorage); + throw new Error("로그인이 필요합니다"); + } + }, + ); + + interceptorsBound = true; +}; + +export const configureApiClientRuntime = (config: RuntimeConfig) => { + runtimeConfig = config; + axiosInstance.defaults.baseURL = config.baseURL; + publicAxiosInstance.defaults.baseURL = config.baseURL; + bindInterceptors(); +}; diff --git a/packages/api-client/src/runtime/index.ts b/packages/api-client/src/runtime/index.ts new file mode 100644 index 00000000..d5d9489c --- /dev/null +++ b/packages/api-client/src/runtime/index.ts @@ -0,0 +1,7 @@ +export type { RuntimeConfig, TokenStorageAdapter } from "./axiosInstance"; +export { + axiosInstance, + configureApiClientRuntime, + convertToBearer, + publicAxiosInstance, +} from "./axiosInstance"; diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json new file mode 100644 index 00000000..714e9fae --- /dev/null +++ b/packages/api-client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "rootDir": "src", + "outDir": "dist", + "baseUrl": ".", + "paths": { + "../../../runtime/axiosInstance": ["src/runtime/axiosInstance.ts"] + }, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/generated/apis/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16ceef35..a3ef6a0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@solid-connect/api-client': + specifier: workspace:* + version: link:../../packages/api-client '@tailwindcss/vite': specifier: ^4.0.6 version: 4.1.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -257,6 +260,16 @@ importers: specifier: ^5.3.3 version: 5.9.3 + packages/api-client: + dependencies: + axios: + specifier: ^1.6.7 + version: 1.13.2 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.9.3 + packages/api-schema: dependencies: axios: diff --git a/turbo.json b/turbo.json index f2f03b7e..8f54bd66 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,12 @@ "env": ["NODE_ENV", "NEXT_PUBLIC_*"] }, "sync:bruno": { - "outputs": ["src/apis/**"], + "outputs": ["src/apis/**", "src/generated/apis/**"], + "cache": false + }, + "codegen:check": { + "dependsOn": ["sync:bruno"], + "outputs": [], "cache": false }, "lint": {