diff --git a/src/api/core/auth/index.ts b/src/api/core/auth/index.ts new file mode 100644 index 00000000..5dcd3d0a --- /dev/null +++ b/src/api/core/auth/index.ts @@ -0,0 +1,43 @@ +import axios from 'axios'; + +import { getAccessToken } from '@/lib/auth/token'; +import { CommonErrorResponse } from '@/types/service/common'; + +import { createApiHelper } from '../lib/apiHelper'; + +const authInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + timeout: 20000, + withCredentials: true, +}); + +authInstance.interceptors.request.use(async (config) => { + const isServer = typeof window === 'undefined'; + + if (isServer) { + const { cookies } = await import('next/headers'); + const cookieStore = await cookies(); + const refreshToken = cookieStore.get('refreshToken')?.value; + if (refreshToken) { + config.headers.Cookie = `refreshToken=${refreshToken}`; + } + } + + const accessToken = await getAccessToken(); + if (accessToken && config.headers) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}); + +authInstance.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + return new CommonErrorResponse(error.response?.data); + }, +); + +export const authAPI = createApiHelper(authInstance); diff --git a/src/api/core/base/index.ts b/src/api/core/base/index.ts new file mode 100644 index 00000000..dbcd63d8 --- /dev/null +++ b/src/api/core/base/index.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; + +import { getAccessToken } from '@/lib/auth/token'; +import { CommonErrorResponse } from '@/types/service/common'; + +import { API } from '../..'; +import { createApiHelper } from '../lib/apiHelper'; + +const baseInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + timeout: 20000, +}); + +baseInstance.interceptors.request.use(async (config) => { + const token = await getAccessToken(); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +baseInstance.interceptors.response.use( + (response) => response, + async (error) => { + const isServer = typeof window === 'undefined'; + + const errorResponse = new CommonErrorResponse(error.response?.data); + const status = errorResponse.status; + const originalRequest = error.config; + + if (status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + try { + await API.authService.refresh(); + return baseInstance(originalRequest); + } catch (refreshError) { + if (isServer) { + throw refreshError; + } else { + const currentPath = window.location.pathname + window.location.search; + window.location.href = `/login?error=unauthorized&path=${encodeURIComponent(currentPath)}`; + } + } + } + if (status === 404) { + if (isServer) { + const { notFound } = await import('next/navigation'); + notFound(); + } + } + + throw errorResponse; + }, +); + +export const baseAPI = createApiHelper(baseInstance); diff --git a/src/api/core/index.ts b/src/api/core/index.ts index d785b493..69564dbb 100644 --- a/src/api/core/index.ts +++ b/src/api/core/index.ts @@ -1,124 +1,2 @@ -import axios from 'axios'; - -import { CommonErrorResponse, CommonSuccessResponse } from '@/types/service/common'; - -import { API } from '..'; - -export const baseAPI = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, - timeout: 20000, -}); - -baseAPI.interceptors.request.use(async (config) => { - const isServer = typeof window === 'undefined'; - - if (isServer) { - // // Server 환경 - const { cookies } = await import('next/headers'); - const cookieStore = await cookies(); - const token = cookieStore.get('accessToken')?.value; - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; - } - } else { - // Client 환경 - const match = document.cookie.match(new RegExp('(^| )accessToken=([^;]+)')); - const token = match ? decodeURIComponent(match[2]) : undefined; - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; - } - } - - return config; -}); - -baseAPI.interceptors.response.use( - (response) => { - return response; - }, - async (error) => { - const errorResponse: CommonErrorResponse = error.response?.data || { - type: 'about:blank', - title: 'Network Error', - status: 0, - detail: '서버와 연결할 수 없습니다.', - instance: error.config?.url || '', - errorCode: 'NETWORK_ERROR', - }; - - const status = error.response?.status ?? errorResponse.status; - const isServer = typeof window === 'undefined'; - const originalRequest = error.config; - - // skipAuthRedirect flag가 지정되어있지 않으면 항상 redirect 되도록 - if (originalRequest.skipAuthRedirect === undefined) { - originalRequest.skipAuthRedirect = true; - } - - if (status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - try { - // refresh - set cookie는 클라이언트 요청만 동작함 - if (!isServer) { - await API.authService.refresh(originalRequest.skipAuthRedirect); - } - return baseAPI(originalRequest); - } catch (refreshError) { - if (!originalRequest.skipAuthRedirect) throw refreshError; - if (isServer) { - const { redirect } = await import('next/navigation'); - redirect('/login'); - } else { - if (window.location.pathname === '/login') { - throw errorResponse; - } - const currentPath = window.location.pathname + window.location.search; - window.location.href = `/login?error=unauthorized&path=${encodeURIComponent(currentPath)}`; - } - } - } - if (status === 404) { - if (isServer) { - const { notFound } = await import('next/navigation'); - notFound(); - } - } - - throw errorResponse; - }, -); - -type ApiVersionType = 'v1' | 'v2'; - -// 공통 응답 형식 처리를 위한 api 헬퍼 -const apiHelper = (v: ApiVersionType = 'v1') => ({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - get: async (url: string, config?: any): Promise => { - const response = await baseAPI.get>(`/api/${v}${url}`, config); - return response.data.data; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - post: async (url: string, data?: any, config?: any): Promise => { - const response = await baseAPI.post>(`/api/${v}${url}`, data, config); - return response.data.data; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - put: async (url: string, data?: any, config?: any): Promise => { - const response = await baseAPI.put>(`/api/${v}${url}`, data, config); - return response.data.data; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete: async (url: string, config?: any): Promise => { - const response = await baseAPI.delete>(`/api/${v}${url}`, config); - return response.data.data; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - patch: async (url: string, data?: any, config?: any): Promise => { - const response = await baseAPI.patch>(`/api/${v}${url}`, data, config); - return response.data.data; - }, -}); - -export const api = apiHelper('v1'); // breaking change 방지용 -export const apiV1 = apiHelper('v1'); -export const apiV2 = apiHelper('v2'); +export { authAPI } from './auth'; +export { baseAPI } from './base'; diff --git a/src/api/core/lib/apiHelper.ts b/src/api/core/lib/apiHelper.ts new file mode 100644 index 00000000..d377363c --- /dev/null +++ b/src/api/core/lib/apiHelper.ts @@ -0,0 +1,26 @@ +import { AxiosInstance, AxiosRequestConfig } from 'axios'; + +import { CommonSuccessResponse } from '@/types/service/common'; + +export const createApiHelper = (axios: AxiosInstance) => ({ + get: async (url: string, config?: AxiosRequestConfig): Promise => { + const response = await axios.get>(url, config); + return response.data.data; + }, + post: async (url: string, data?: unknown, config?: AxiosRequestConfig): Promise => { + const response = await axios.post>(url, data, config); + return response.data.data; + }, + put: async (url: string, data?: unknown, config?: AxiosRequestConfig): Promise => { + const response = await axios.put>(url, data, config); + return response.data.data; + }, + delete: async (url: string, config?: AxiosRequestConfig): Promise => { + const response = await axios.delete>(url, config); + return response.data.data; + }, + patch: async (url: string, data?: unknown, config?: AxiosRequestConfig): Promise => { + const response = await axios.patch>(url, data, config); + return response.data.data; + }, +}); diff --git a/src/api/service/auth-service/index.ts b/src/api/service/auth-service/index.ts index 8201df94..79f1f990 100644 --- a/src/api/service/auth-service/index.ts +++ b/src/api/service/auth-service/index.ts @@ -1,4 +1,4 @@ -import { api } from '@/api/core'; +import { authAPI } from '@/api/core'; import { clearAccessToken, setAccessToken } from '@/lib/auth/token'; import { GoogleOAuthExchangeRequest, @@ -13,47 +13,42 @@ import { export const authServiceRemote = () => ({ // 로그인 login: async (payload: LoginRequest) => { - const data = await api.post('/auth/login', payload, { withCredentials: true }); + const data = await authAPI.post('/api/v1/auth/login', payload); setAccessToken(data.accessToken, data.expiresIn); return data; }, // 회원가입 - signup: (payload: SignupRequest) => api.post(`/auth/signup`, payload), + signup: async (payload: SignupRequest) => { + return authAPI.post(`/api/v1/auth/signup`, payload); + }, // 로그아웃 logout: async () => { - await api.post('/auth/logout', null, { withCredentials: true }); + await authAPI.post('/api/v1/auth/logout', null); clearAccessToken(); }, // 액세스 토큰 재발급 - refresh: async (redirect: boolean = true) => { - const data = await api.post( - '/auth/refresh', - {}, - { _retry: true, withCredentials: true, skipAuthRedirect: redirect }, - ); - + refresh: async () => { + //prettier-ignore + const data = await authAPI.post('/api/v1/auth/refresh', {}); setAccessToken(data.accessToken, data.expiresIn); return data; }, // 회원 탈퇴 withdraw: async () => { - await api.delete('/auth/withdraw', { withCredentials: true }); + await authAPI.delete('/api/v1/auth/withdraw'); clearAccessToken(); }, // 구글 OAuth 코드 교환 exchangeGoogleCode: async (payload: GoogleOAuthExchangeRequest) => { - const data = await api.post('/auth/google', payload, { - withCredentials: true, - }); + const data = await authAPI.post('/api/v1/auth/google', payload); setAccessToken(data.accessToken, data.expiresIn); - return data; }, }); diff --git a/src/api/service/chat-service/index.ts b/src/api/service/chat-service/index.ts index 421a9e2f..29e8a0b2 100644 --- a/src/api/service/chat-service/index.ts +++ b/src/api/service/chat-service/index.ts @@ -1,4 +1,4 @@ -import { apiV1 } from '@/api/core'; +import { baseAPI } from '@/api/core'; import { ChattingRoom, CreateDMPayloads, @@ -17,17 +17,17 @@ import { export const chatServiceRemote = () => ({ // 채팅방 목록 조회 getChatRooms: async () => { - return apiV1.get('/chat/rooms'); + return baseAPI.get('/api/v1/chat/rooms'); }, // 1:1(DM) 채팅방 생성 createDMChatRoom: async (payloads: CreateDMPayloads) => { - return apiV1.post('/chat/dm', payloads); + return baseAPI.post('/api/v1/chat/dm', payloads); }, // 메세지 이력 조회 getChatMessages: async ({ roomId, cursor, size }: GetChatMessagesParams) => { - return apiV1.get(`/chat/rooms/${roomId}/messages`, { + return baseAPI.get(`/api/v1/chat/rooms/${roomId}/messages`, { params: { cursor, size, @@ -37,21 +37,21 @@ export const chatServiceRemote = () => ({ // 메세지 읽음 처리 readMessages: async ({ roomId }: ReadMessagesParams) => { - return apiV1.put(`/chat/rooms/${roomId}/read`); + return baseAPI.put(`/api/v1/chat/rooms/${roomId}/read`); }, // 채팅방 상세 조회 getChatRoom: async ({ roomId }: GetChatRoomParams) => { - return apiV1.get(`/chat/rooms/${roomId}`); + return baseAPI.get(`/api/v1/chat/rooms/${roomId}`); }, // 참여자 목록 조회 getParticipants: async ({ roomId }: GetParticipantsParams) => { - return apiV1.get(`/chat/rooms/${roomId}/participants`); + return baseAPI.get(`/api/v1/chat/rooms/${roomId}/participants`); }, // 추방하기 kickUser: async (roomId: number, payload: KickUserPayloads) => { - return apiV1.post(`/chat/rooms/${roomId}/kick`, payload); + return baseAPI.post(`/api/v1/chat/rooms/${roomId}/kick`, payload); }, }); diff --git a/src/api/service/follower-service/index.ts b/src/api/service/follower-service/index.ts index 9e4c1916..d820fa6c 100644 --- a/src/api/service/follower-service/index.ts +++ b/src/api/service/follower-service/index.ts @@ -1,11 +1,11 @@ -import { api } from '@/api/core'; +import { baseAPI } from '@/api/core'; import { GetFollowParams, GetFollowResponse } from '@/types/service/follow'; import { FollowPathParams } from '@/types/service/user'; export const followerServiceRemote = () => ({ // 팔로워 목록 조회 getFollowers: async ({ userId, cursor, size = 20 }: GetFollowParams) => { - return api.get(`/users/${userId}/follow`, { + return baseAPI.get(`/api/v1/users/${userId}/follow`, { params: { cursor, size, @@ -15,21 +15,21 @@ export const followerServiceRemote = () => ({ getFollowerList: async (params: GetFollowParams) => { const { userId, ...restParams } = params; - return await api.get(`/users/${userId}/follower`, { + return await baseAPI.get(`/api/v1/users/${userId}/follower`, { params: { ...restParams }, }); }, getFolloweeList: async (params: GetFollowParams) => { const { userId, ...restParams } = params; - return await api.get(`/users/${userId}/follow`, { + return await baseAPI.get(`/api/v1/users/${userId}/follow`, { params: { ...restParams }, }); }, // 팔로워 등록 addFollower: async (params: FollowPathParams) => { - return api.post(`/users/follow`, null, { + return baseAPI.post(`/api/v1/users/follow`, null, { params: { followNickname: params.followNickname }, }); }, diff --git a/src/api/service/group-service/index.ts b/src/api/service/group-service/index.ts index 1edeace1..72fcd31b 100644 --- a/src/api/service/group-service/index.ts +++ b/src/api/service/group-service/index.ts @@ -1,4 +1,4 @@ -import { apiV2 } from '@/api/core'; +import { baseAPI } from '@/api/core'; import { AttendGroupPayload, CreateGroupPayload, @@ -27,7 +27,7 @@ export const groupServiceRemote = () => ({ } params.append('size', payload.size.toString()); - return apiV2.get(`/groups?${params.toString()}`); + return baseAPI.get(`/api/v2/groups?${params.toString()}`); }, // 내 모임 목록 조회 (GET /api/v2/groups/me) :스케줄러 페이지 getMyGroups: async (payload: GetMyGroupsPayload): Promise => { @@ -56,42 +56,45 @@ export const groupServiceRemote = () => ({ }); } - return apiV2.get(`/groups/me?${params.toString()}`); + return baseAPI.get(`/api/v2/groups/me?${params.toString()}`); }, // 모임 이미지 사전 업로드 (POST /groups/images/upload) - multipart/form-data createGroup: (payload: CreateGroupPayload) => { - return apiV2.post('/groups/create', payload); + return baseAPI.post('/api/v2/groups/create', payload); }, editGroup: (params: GroupIdParams, payload: CreateGroupPayload) => { - return apiV2.patch(`/groups/${params.groupId}`, payload); + return baseAPI.patch(`/api/v2/groups/${params.groupId}`, payload); }, getGroupDetails: (params: GroupIdParams) => { - return apiV2.get(`/groups/${params.groupId}`); + return baseAPI.get(`/api/v2/groups/${params.groupId}`); }, attendGroup: (params: GroupIdParams, payload?: AttendGroupPayload) => { // 승인제 모임 신청 시 message 포함해서 API 요청 if (payload) { - return apiV2.post(`/groups/${params.groupId}/attend`, payload); + return baseAPI.post( + `/api/v2/groups/${params.groupId}/attend`, + payload, + ); } - return apiV2.post(`/groups/${params.groupId}/attend`); + return baseAPI.post(`/api/v2/groups/${params.groupId}/attend`); }, leaveGroup: (params: GroupIdParams) => { - return apiV2.post(`/groups/${params.groupId}/left`); + return baseAPI.post(`/api/v2/groups/${params.groupId}/left`); }, deleteGroup: (params: GroupIdParams) => { - return apiV2.delete(`/groups/${params.groupId}`); + return baseAPI.delete(`/api/v2/groups/${params.groupId}`); }, kickGroupMember: (params: KickGroupMemberParams) => { - return apiV2.post( - `/groups/${params.groupId}/attendance/${params.targetUserId}/kick`, + return baseAPI.post( + `/api/v2/groups/${params.groupId}/attendance/${params.targetUserId}/kick`, ); }, @@ -99,26 +102,26 @@ export const groupServiceRemote = () => ({ getJoinRequests: (params: GroupIdParams, status: string = 'PENDING') => { const queryParams = new URLSearchParams(); queryParams.append('status', status); - return apiV2.get( - `/groups/${params.groupId}/attendance?${queryParams.toString()}`, + return baseAPI.get( + `/api/v2/groups/${params.groupId}/attendance?${queryParams.toString()}`, ); }, // 승인 (POST /api/v2/groups/{groupId}/attendance/{targetUserId}/approve) approveJoinRequest: (params: KickGroupMemberParams) => { - return apiV2.post( - `/groups/${params.groupId}/attendance/${params.targetUserId}/approve`, + return baseAPI.post( + `/api/v2/groups/${params.groupId}/attendance/${params.targetUserId}/approve`, ); }, // 거절 (POST /api/v2/groups/{groupId}/attendance/{targetUserId}/reject) rejectJoinRequest: (params: KickGroupMemberParams) => { - return apiV2.post( - `/groups/${params.groupId}/attendance/${params.targetUserId}/reject`, + return baseAPI.post( + `/api/v2/groups/${params.groupId}/attendance/${params.targetUserId}/reject`, ); }, uploadGroupImages: (payload: FormData) => { - return apiV2.post('/groups/images/upload', payload); + return baseAPI.post('/api/v2/groups/images/upload', payload); }, }); diff --git a/src/api/service/notification-service/index.ts b/src/api/service/notification-service/index.ts index 6626ed0d..5301e5ee 100644 --- a/src/api/service/notification-service/index.ts +++ b/src/api/service/notification-service/index.ts @@ -1,22 +1,22 @@ -import { apiV1 } from '@/api/core'; +import { baseAPI } from '@/api/core'; import { GetNotificationListQueryParams, NotificationList } from '@/types/service/notification'; export const notificationServiceRemote = () => ({ updateRead: async (notificationId: number) => { - await apiV1.post(`/notifications/${notificationId}/read`); + await baseAPI.post(`/api/v1/notifications/${notificationId}/read`); }, updateReadAll: async () => { - await apiV1.post(`/notifications/read-all`); + await baseAPI.post(`/api/v1/notifications/read-all`); }, getList: async (queryParams: GetNotificationListQueryParams) => { - return await apiV1.get(`/notifications`, { + return await baseAPI.get(`/api/v1/notifications`, { params: { ...queryParams }, }); }, getUnreadCount: async () => { - return await apiV1.get(`/notifications/unread-count`); + return await baseAPI.get(`/api/v1/notifications/unread-count`); }, }); diff --git a/src/api/service/user-service/index.ts b/src/api/service/user-service/index.ts index b2c882f3..5d4a7738 100644 --- a/src/api/service/user-service/index.ts +++ b/src/api/service/user-service/index.ts @@ -1,4 +1,4 @@ -import { apiV1 } from '@/api/core'; +import { baseAPI } from '@/api/core'; import { Availability, FollowPathParams, @@ -15,33 +15,33 @@ import { export const userServiceRemote = () => ({ // 1. 사용자 팔로우 followUser: async (pathParams: FollowPathParams) => { - return apiV1.post(`/users/follow`, null, { + return baseAPI.post(`/api/v1/users/follow`, null, { params: { followNickname: pathParams.followNickname }, }); }, // 2. 유저 프로필 변경 updateMyInfo: async (payloads: UpdateMyInfoPayloads) => { - return apiV1.patch('/users/profile', payloads); + return baseAPI.patch('/api/v1/users/profile', payloads); }, // 3. 프로필 이미지 변경 updateMyImage: async (payloads: UpdateMyImagePayloads) => { const formData = new FormData(); formData.append('file', payloads.file); - return apiV1.patch(`/users/profile-image`, formData); + return baseAPI.patch(`/api/v1/users/profile-image`, formData); }, // 4. 알림 설정 변경 updateMyNotification: async (queryParams: UpdateMyNotificationQueryParams) => { - return apiV1.patch(`/users/notification`, null, { + return baseAPI.patch(`/api/v1/users/notification`, null, { params: { ...queryParams }, }); }, // 5. 유저 프로필 조회 getUser: async (pathParams: GetUserPathParams) => { - return apiV1.get(`/users/${pathParams.userId}`); + return baseAPI.get(`/api/v1/users/${pathParams.userId}`); }, // 6. 팔로우 리스트 조회 @@ -49,31 +49,31 @@ export const userServiceRemote = () => ({ // 7. 닉네임 중복 검사 getNicknameAvailability: async (queryParams: GetNicknameAvailabilityQueryParams) => { - return apiV1.get(`/users/nickname/availability`, { + return baseAPI.get(`/api/v1/users/nickname/availability`, { params: { nickname: queryParams.nickName }, }); }, // 8. 본인 프로필 조회 getMe: async () => { - return apiV1.get(`/users/me`); + return baseAPI.get(`/api/v1/users/me`); }, // 8-1. 본인 프로필 조회(redirect skip) getMeSkipRedirect: async () => { - return apiV1.get(`/users/me`, { skipAuthRedirect: false }); + return baseAPI.get(`/api/v1/users/me`); }, // 9. 이메일 중복 검사 getEmailAvailability: async (queryParams: GetEmailAvailabilityQueryParams) => { - return apiV1.get(`/users/email/availability`, { + return baseAPI.get(`/api/v1/users/email/availability`, { params: { email: queryParams.email }, }); }, // 10. 사용자 언팔로우 unfollowUser: async (params: UnfollowQueryParams) => { - return apiV1.delete(`/users/unfollow`, { + return baseAPI.delete(`/api/v1/users/unfollow`, { params: { unFollowNickname: params.unFollowNickname }, }); }, diff --git a/src/lib/auth/token.ts b/src/lib/auth/token.ts index e8994b8d..9fe6d91f 100644 --- a/src/lib/auth/token.ts +++ b/src/lib/auth/token.ts @@ -30,3 +30,15 @@ export const clearAccessToken = () => { document.cookie = `${ACCESS_TOKEN_KEY}=; Max-Age=0; path=/`; }; + +export const getAccessToken = async () => { + const isServer = typeof window === 'undefined'; + if (isServer) { + const { cookies } = await import('next/headers'); + const cookieStore = await cookies(); + return cookieStore.get(ACCESS_TOKEN_KEY)?.value; + } else { + const match = document.cookie.match(new RegExp(`(^| )${ACCESS_TOKEN_KEY}=([^;]+)`)); + return match ? decodeURIComponent(match[2]) : undefined; + } +}; diff --git a/src/types/service/common.ts b/src/types/service/common.ts index 221e7c85..dde13b93 100644 --- a/src/types/service/common.ts +++ b/src/types/service/common.ts @@ -1,18 +1,23 @@ export class CommonErrorResponse { - constructor( - public type: string, - public title: string, - public status: number, - public detail: string, - public instance: string, - public errorCode?: string, - ) {} + type: string; + title: string; + status: number; + detail: string; + instance: string; + errorCode: string; + + constructor(data?: Partial) { + this.type = data?.type ?? 'about:blank'; + this.title = data?.title ?? 'Network Error'; + this.status = data?.status ?? 0; + this.detail = data?.detail ?? '서버와 연결할 수 없습니다.'; + this.instance = data?.instance ?? ''; + this.errorCode = data?.errorCode ?? 'NETWORK_ERROR'; + } } -export class CommonSuccessResponse { - constructor( - public status: number, - public success: boolean, - public data: T, - ) {} +export interface CommonSuccessResponse { + status: number; + success: boolean; + data: T; }