-
Notifications
You must be signed in to change notification settings - Fork 1
[Init] swagger-typescript-api 및 axios 초기 세팅 #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
b1c3e3d
2b26a45
beff28e
041ae11
a5b7c96
0a14386
a6a4e48
e58c220
3ee6729
f0d0595
ec2d168
c5776c8
64c41f7
ceb4899
e3e6396
afa8493
df79bed
b3e2e08
4615c18
e250385
b68840f
cd057ed
ab7973d
7edd120
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import axios from "axios"; | ||
|
|
||
| const SERVER_URL = import.meta.env.VITE_API_URL; | ||
|
|
||
| // TODO: 현재는 axios로 instance를 생성하지만, http-client.ts Api 클래스로 instance를 생성하도록 수정할 예정입니다. | ||
| export const api = axios.create({ | ||
| baseURL: `${SERVER_URL}`, | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| }); | ||
|
|
||
| api.interceptors.request.use( | ||
| async (config) => { | ||
| // TODO: 액세스 토큰 가져오는 로직은 utils/token로 대체 예정 | ||
| const accessToken = localStorage.getItem("access_token") || "temp"; | ||
| if (accessToken) { | ||
| config.headers["authorization"] = `Bearer ${accessToken}`; | ||
|
||
| } else { | ||
| // TODO: 로그인이 필요하다는 Toast Message | ||
| console.error("액세스 토큰이 존재하지 않습니다"); | ||
| window.location.replace("/login"); | ||
| throw new Error("액세스 토큰이 존재하지 않습니다"); | ||
| } | ||
| return config; | ||
| }, | ||
| (error) => Promise.reject(error) | ||
| ); | ||
|
|
||
| api.interceptors.response.use( | ||
| (response) => response, | ||
| async (error) => { | ||
| const originalRequest = error.config; | ||
|
|
||
| if (error.response?.status === 401 && !originalRequest._retry) { | ||
| originalRequest._retry = true; | ||
| try { | ||
| // TODO: refreshToken 관련은 차후 순위로 개발할 예정 | ||
| // 액세스 토큰 재발급 API | ||
| const { data } = await axios.post("토큰 재발급 path", null, { | ||
| withCredentials: true, // (토큰을 cookie에 넣었을 경우 사용됨) | ||
| }); | ||
|
|
||
| // 새로 발급 받은 액세스 토큰 저장 | ||
| const newAccessToken = data.accessToken; | ||
| localStorage.setItem("access_token", newAccessToken); | ||
| return api(originalRequest); // 이전 요청 재시도 | ||
| } catch (refreshError) { | ||
| // TODO: 재발급 실패시, 로그인 화면으로 이동 | ||
| console.error("리프레쉬 토큰 요청에 실패했습니다.", error); | ||
| localStorage.removeItem("access_token"); // TODO: 토큰, 유저 정보 모두 삭제 | ||
| window.location.replace("/login"); | ||
|
|
||
| return Promise.reject(refreshError); | ||
| } | ||
| } | ||
|
|
||
| return Promise.reject(error); | ||
| } | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 코드에서
이 부분일까요? 여러 번 읽어보았는데 "query"일 때 에러 페이지로 이동하는 흐름이 어떤 식으로 이루어지는지 잘 이해가 되지 않아서 호옥시 조금 더 자세히 설명해주실 수 있을지 여쭤봅니다. !! 😅
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 생략 //
throwOnError: true, // throwOnError 옵션을 true 설정
},
mutations: {
throwOnError: false,
},
},
queryCache: new QueryCache({
onError: (error: unknown) => {
handleApiError(error, "query");
},
}),
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아하~ 정확히 이해했습니다! mutation error의 경우는 사용자 경험 개선을 위해 alert 메시지로 처리해주기로 하였기 때문에, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { isAxiosError } from "axios"; | ||
|
|
||
| import { ERROR_MESSAGE } from "@/shared/api/types/response"; | ||
|
|
||
| import type { | ||
| ApiErrorResponse, | ||
| ApiErrorCode, | ||
| RequiredWith, | ||
| } from "@/shared/api/types/response"; | ||
| import type { AxiosError } from "axios"; | ||
|
|
||
| // 서버 정의 에러 타입 가드 | ||
| // 서버가 보낸 에러가 맞는지 확인하는 함수 | ||
| export const isAxiosErrorWithCustomCode = ( | ||
| error: unknown | ||
| ): error is RequiredWith<AxiosError<ApiErrorResponse>, "response"> => { | ||
| return ( | ||
| isAxiosError(error) && | ||
| !!error.response && | ||
| !!error.response.data && | ||
| typeof error.response.data.code === "string" && | ||
| error.response.data.code in ERROR_MESSAGE | ||
| ); | ||
| }; | ||
|
||
|
|
||
| // 공통 에러 핸들러 | ||
| export const handleApiError = (error: unknown) => { | ||
| if (isAxiosErrorWithCustomCode(error)) { | ||
| const { code } = error.response.data; | ||
| const message = ERROR_MESSAGE[code as ApiErrorCode]; | ||
| console.error(message); | ||
| return message; | ||
| } else if (isAxiosError(error)) { | ||
| console.error("서버/네트워크 오류입니다"); | ||
| // TODO: 에러 페이지 필요 | ||
| } else { | ||
| console.error("알 수 없는 에러 발생"); | ||
| // TODO: 에러 페이지 필요 | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| <% | ||
| const { apiConfig, routes, utils, config } = it; | ||
| const { info, servers, externalDocs } = apiConfig; | ||
| const { _, require, formatDescription } = utils; | ||
|
|
||
| const server = (servers && servers[0]) || { url: "" }; | ||
|
|
||
| const descriptionLines = _.compact([ | ||
| `@title ${info.title || "No title"}`, | ||
| info.version && `@version ${info.version}`, | ||
| info.license && `@license ${_.compact([ | ||
| info.license.name, | ||
| info.license.url && `(${info.license.url})`, | ||
| ]).join(" ")}`, | ||
| info.termsOfService && `@termsOfService ${info.termsOfService}`, | ||
| server.url && `@baseUrl // .env 파일을 참조해주세요`, | ||
| externalDocs.url && `@externalDocs ${externalDocs.url}`, | ||
| info.contact && `@contact ${_.compact([ | ||
| info.contact.name, | ||
| info.contact.email && `<${info.contact.email}>`, | ||
| info.contact.url && `(${info.contact.url})`, | ||
| ]).join(" ")}`, | ||
| info.description && " ", | ||
| info.description && _.replace(formatDescription(info.description), /\n/g, "\n * "), | ||
| ]); | ||
|
|
||
| %> | ||
|
|
||
| <% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %> | ||
|
|
||
| <% if (descriptionLines.length) { %> | ||
| /** | ||
| <% descriptionLines.forEach((descriptionLine) => { %> | ||
| * <%~ descriptionLine %> | ||
|
|
||
| <% }) %> | ||
| */ | ||
| <% } %> | ||
| export class <%~ config.apiClassName %><SecurityDataType extends unknown><% if (!config.singleHttpClient) { %> extends HttpClient<SecurityDataType> <% } %> { | ||
|
|
||
| <% if(config.singleHttpClient) { %> | ||
| http: HttpClient<SecurityDataType>; | ||
|
|
||
| constructor (http: HttpClient<SecurityDataType>) { | ||
| this.http = http; | ||
| } | ||
| <% } %> | ||
|
|
||
|
|
||
| <% if (routes.outOfModule) { %> | ||
| <% for (const route of routes.outOfModule) { %> | ||
|
|
||
| <%~ includeFile('./procedure-call.ejs', { ...it, route }) %> | ||
|
|
||
| <% } %> | ||
| <% } %> | ||
|
|
||
| <% if (routes.combined) { %> | ||
| <% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %> | ||
| <%~ moduleName %> = { | ||
| <% for (const route of combinedRoutes) { %> | ||
|
|
||
| <%~ includeFile('./procedure-call.ejs', { ...it, route }) %> | ||
|
|
||
| <% } %> | ||
| } | ||
| <% } %> | ||
| <% } %> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| <% | ||
| const { apiConfig, generateResponses, config } = it; | ||
| %> | ||
|
|
||
| import type { AxiosInstance, AxiosRequestConfig, HeadersDefaults, ResponseType } from "axios"; | ||
| import axios from "axios"; | ||
|
|
||
| export type QueryParamsType = Record<string | number, any>; | ||
|
|
||
| export interface FullRequestParams extends Omit<AxiosRequestConfig, "data" | "params" | "url" | "responseType"> { | ||
| /** set parameter to `true` for call `securityWorker` for this request */ | ||
| secure?: boolean; | ||
| /** request path */ | ||
| path: string; | ||
| /** content type of request body */ | ||
| type?: ContentType; | ||
| /** query params */ | ||
| query?: QueryParamsType; | ||
| /** format of response (i.e. response.json() -> format: "json") */ | ||
| format?: ResponseType; | ||
| /** request body */ | ||
| body?: unknown; | ||
| } | ||
|
|
||
| export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">; | ||
|
|
||
| export interface ApiConfig<SecurityDataType = unknown> extends Omit<AxiosRequestConfig, "data" | "cancelToken"> { | ||
| securityWorker?: (securityData: SecurityDataType | null) => Promise<AxiosRequestConfig | void> | AxiosRequestConfig | void; | ||
| secure?: boolean; | ||
| format?: ResponseType; | ||
| } | ||
|
|
||
| export enum ContentType { | ||
| Json = "application/json", | ||
| JsonApi = "application/vnd.api+json", | ||
| FormData = "multipart/form-data", | ||
| UrlEncoded = "application/x-www-form-urlencoded", | ||
| Text = "text/plain", | ||
| } | ||
|
|
||
| export class HttpClient<SecurityDataType = unknown> { | ||
| public instance: AxiosInstance; | ||
| private securityData: SecurityDataType | null = null; | ||
| private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"]; | ||
| private secure?: boolean; | ||
| private format?: ResponseType; | ||
|
|
||
| constructor({ securityWorker, secure, format, ...axiosConfig }: ApiConfig<SecurityDataType> = {}) { | ||
| this.instance = axios.create({ ...axiosConfig, baseURL: axiosConfig.baseURL || import.meta.env.VITE_API_URL }) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 생성 코드에 |
||
| this.secure = secure; | ||
| this.format = format; | ||
| this.securityWorker = securityWorker; | ||
| } | ||
|
|
||
| public setSecurityData = (data: SecurityDataType | null) => { | ||
| this.securityData = data | ||
| } | ||
|
|
||
| protected mergeRequestParams(params1: AxiosRequestConfig, params2?: AxiosRequestConfig): AxiosRequestConfig { | ||
| const method = params1.method || (params2 && params2.method) | ||
|
|
||
| return { | ||
| ...this.instance.defaults, | ||
| ...params1, | ||
| ...(params2 || {}), | ||
| headers: { | ||
| ...((method && this.instance.defaults.headers[method.toLowerCase() as keyof HeadersDefaults]) || {}), | ||
| ...(params1.headers || {}), | ||
| ...((params2 && params2.headers) || {}), | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| protected stringifyFormItem(formItem: unknown) { | ||
| if (typeof formItem === "object" && formItem !== null) { | ||
| return JSON.stringify(formItem); | ||
| } else { | ||
| return `${formItem}`; | ||
| } | ||
| } | ||
|
|
||
| protected createFormData(input: Record<string, unknown>): FormData { | ||
| if (input instanceof FormData) { | ||
| return input; | ||
| } | ||
| return Object.keys(input || {}).reduce((formData, key) => { | ||
| const property = input[key]; | ||
| const propertyContent: any[] = (property instanceof Array) ? property : [property] | ||
|
|
||
| for (const formItem of propertyContent) { | ||
| const isFileType = formItem instanceof Blob || formItem instanceof File; | ||
| formData.append( | ||
| key, | ||
| isFileType ? formItem : this.stringifyFormItem(formItem) | ||
| ); | ||
| } | ||
|
|
||
| return formData; | ||
| }, new FormData()); | ||
| } | ||
|
|
||
| public request = async <T = any, _E = any>({ | ||
| secure, | ||
| path, | ||
| type, | ||
| query, | ||
| format, | ||
| body, | ||
| ...params | ||
| <% if (config.unwrapResponseData) { %> | ||
| }: FullRequestParams): Promise<T> => { | ||
| <% } else { %> | ||
| }: FullRequestParams): Promise<T> => { | ||
| <% } %> | ||
| const secureParams = ((typeof secure === 'boolean' ? secure : this.secure) && this.securityWorker && (await this.securityWorker(this.securityData))) || {}; | ||
| const requestParams = this.mergeRequestParams(params, secureParams); | ||
| const responseFormat = (format || this.format) || undefined; | ||
|
|
||
| if (type === ContentType.FormData && body && body !== null && typeof body === "object") { | ||
| body = this.createFormData(body as Record<string, unknown>); | ||
| } | ||
|
|
||
| if (type === ContentType.Text && body && body !== null && typeof body !== "string") { | ||
| body = JSON.stringify(body); | ||
| } | ||
|
|
||
| return this.instance.request({ | ||
| ...requestParams, | ||
| headers: { | ||
| ...(requestParams.headers || {}), | ||
| ...(type ? { "Content-Type": type } : {}), | ||
| }, | ||
| params: query, | ||
| responseType: responseFormat, | ||
| data: body, | ||
| url: path, | ||
| <% if (config.unwrapResponseData) { %> | ||
| }).then(response => response.data); | ||
| <% } else { %> | ||
| }); | ||
| <% } %> | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| export interface ApiErrorResponse { | ||
| status: number; | ||
| code: ApiErrorCode; | ||
| message: string; | ||
| } | ||
|
|
||
| // 서버 정의 에러 (임시) | ||
| export type ApiErrorCode = | ||
| | "INVALID_ACCESS_TOKEN_VALUE" | ||
| | "EXPIRED_ACCESS_TOKEN"; | ||
|
|
||
| export const ERROR_MESSAGE: Record<ApiErrorCode, string> = { | ||
| INVALID_ACCESS_TOKEN_VALUE: "액세스 토큰의 값이 올바르지 않습니다.", | ||
| EXPIRED_ACCESS_TOKEN: "액세스 토큰이 만료되었습니다. 재발급 받아주세요.", | ||
| }; | ||
|
|
||
| // 옵셔널 타입을 필수로 지정 | ||
| export type RequiredWith<T, K extends keyof T> = T & { | ||
| [P in K]-?: T[P]; | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
토큰을 interceptor에서 주입하는 흐름 너무 좋은 것 같습니다 👍 다만 이전까지 합의되었던 내용은 토큰을☺️ 그리고 이렇게 맞춰두면 저장소(session/local) 정책이 변경되더라도 token 유틸 한 군데만 수정하면 되어서, 유지보수나 확장 측면에서도 이점이 있을 것 같아요!
shared/lib/auth/token.ts(storage 유틸)에서만 다루는 방향인걸로 기억해서, 추후 실제 적용 단계에서는 여기의localStorage.getItem을 tokenStorage 조회로 통일하면 팀 컨벤션과 더 잘 맞을 것 같습니다추후 컨벤션이 완전히 확정되면 더 적절한 방식으로 통일해보면 좋을 것 같아요 :)
추가로 지금은 임시 코드라
"temp"가 들어간 것 같은데 이렇게 되면 기본값 때문에 토큰이 없어도 Bearer temp가 붙어서 401을 유발할 수도 있을 것 같아서 실제 서비스 구현 시점에는 기본값을 제거하여null/undefined로 “토큰 없음 케이스”를 확실하게 처리하면 좋을 것 같습니다 :DThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
헉.. 제가 임시로 설정하고 올리기 전에 다른 값으로 대체한다는게 그대로 올려버렸네요.. 감사합니다ㅠ
스토리지 토큰에 접근하는 로직은 zustand PR이 머지되지 않았기도 했고 아직 axios instance를 사용하는 구현단계는 아니라 로그인 이슈 처리하면서 함께
shared/lib/auth/token.ts의 token 조회 유틸함수로 대체하도록 하겠습니다!