Skip to content
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

[FE] 클라이언트 영역 에러 핸들링 개선 (토스트 UI) #419

Merged
merged 9 commits into from
Oct 24, 2024
54 changes: 30 additions & 24 deletions frontend/src/apis/_common/fetchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { BASE_URL } from '@constants/api';

export type HTTPMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';

interface FetchOption {
export interface FetchOption {
path: string;
method: HTTPMethod;
errorMessage?: string;
body?: object;
headers?: HeadersInit;
/**
* @Yoonkyoungme
* isAuthRequire: 인증이 필요한 API 요청인지를 나타내는 플래그
Expand All @@ -21,29 +21,35 @@ interface FetchOption {

// TODO: TypeError: Failed to Fetch에 대한 에러 처리는 어떻게 할 예정인지.
const createFetchClient = (baseUrl: string) => {
return async <T>({ path, method, body, isAuthRequire }: FetchOption): Promise<T> => {
const url = `${baseUrl}${path}`;
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : null,
credentials: isAuthRequire ? 'include' : 'omit',
});

if (response.status === 401) {
throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.');
return async ({ path, method, body, isAuthRequire, headers }: FetchOption) => {
try {
const url = `${baseUrl}${path}`;
const response: Response = await fetch(url, {
method,
headers: {
...headers,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : null,
credentials: isAuthRequire ? 'include' : 'omit',
});

// response 객체는 에러가 발생하면 데이터는 응답 객체가 되고, 정상적인 응답이 오면 데이터 객체가 된다.
if (!response.ok) {
// 응답이 에러 객체인 경우 ResponseError 객체를 생성 -> QueryClientManager 컴포넌트에서 에러 상태를 업데이트
const errorData = await response.json();
throw new ResponseError(errorData);
}

return response;
} catch (error) {
// catch network error
if (error instanceof Error) {
throw error;
}

throw error;
}

// 현재 응답 결과로 받아오는 데이터가 모두 data로 감싸서 전달받는 형태이므로 아래와 같이 구현(@낙타)
const data = await response.json();

if (!response.ok) {
throw new ResponseError(data);
}

return data.data as T;
};
};

Expand Down
31 changes: 31 additions & 0 deletions frontend/src/apis/_common/fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { FetchOption } from './fetchClient';
import { fetchClient } from './fetchClient';

type FetcherArgs = Omit<FetchOption, 'method'>;

export const fetcher = {
get: async <T>({ path, isAuthRequire }: FetcherArgs): Promise<T> => {
const response = await fetchClient({
path,
method: 'GET',
isAuthRequire,
});

const data = await response.json();

return data.data as T;
Comment on lines +14 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3]

이 부분이 중복되는데, 아래처럼 함수로 분리해볼 수도 있을 것 같아요~!

const parseResponse = async <T>(response: Response): Promise<T> => {
  const data = await response.json();

  return data.data as T;
};

},
post: async ({ path, body, isAuthRequire = false }: FetcherArgs) => {
await fetchClient({ path, method: 'POST', body, isAuthRequire });
},
postWithResponse: async <T>({ path, body, isAuthRequire = false }: FetcherArgs): Promise<T> => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3]

현재 모든 함수에서 isAuthRequire = false가 기본값으로 설정되어 있네요.
fetchClient 내부에서 처리하면, fetcher에서는 isAuthRequire 관련 중복 설정을 제거할 수 있을 것 같아요!

  • fetchClient.ts
const createFetchClient = (baseUrl: string) => {
  return async ({ path, method, body, isAuthRequire = false, headers }: FetchOption) => {
    ...
  };
};

const response = await fetchClient({ path, method: 'POST', body, isAuthRequire });

const data = await response.json();

return data.data as T;
},
delete: async ({ path, isAuthRequire = false }: FetcherArgs) => {
await fetchClient({ path, method: 'DELETE', isAuthRequire });
},
};
24 changes: 4 additions & 20 deletions frontend/src/apis/meetings/confirms.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { BASE_URL } from '@constants/api';

import { fetchClient } from '../_common/fetchClient';
import { fetcher } from '../_common/fetcher';
import type { MeetingType } from './meetings';

export interface ConfirmDates {
Expand All @@ -25,9 +23,8 @@ export interface GetConfirmedMeetingInfoResponse extends ConfirmDates {
}

export const postMeetingConfirm = async ({ uuid, requests }: PostMeetingConfirmRequest) => {
const data = await fetchClient({
const data = await fetcher.post({
path: `/${uuid}/confirm`,
method: 'POST',
body: requests,
isAuthRequire: true,
});
Expand All @@ -36,24 +33,11 @@ export const postMeetingConfirm = async ({ uuid, requests }: PostMeetingConfirmR
};

export const getConfirmedMeetingInfo = async (uuid: string) => {
const data = await fetchClient<Promise<GetConfirmedMeetingInfoResponse>>({
path: `/${uuid}/confirm`,
method: 'GET',
});
const data = await fetcher.get<GetConfirmedMeetingInfoResponse>({ path: `/${uuid}/confirm` });

return data;
};

export const deleteFixedMeeting = async (uuid: string) => {
const response = await fetch(`${BASE_URL}/${uuid}/confirm`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});

if (!response.ok) {
throw new Error('약속을 확정 취소하는데 실패했어요. :(');
}
await fetcher.delete({ path: `/${uuid}/confirm`, isAuthRequire: true });
};
15 changes: 4 additions & 11 deletions frontend/src/apis/meetings/meetings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ResponseError } from '@utils/responseError';

import { BASE_URL } from '@constants/api';

import { fetchClient } from '../_common/fetchClient';
import { fetcher } from '../_common/fetcher';

export type MeetingType = 'DAYSONLY' | 'DATETIME';

Expand Down Expand Up @@ -51,10 +51,7 @@ interface PostMeetingResponse {
export const getMeetingBase = async (uuid: string): Promise<MeetingBase> => {
const path = `/${uuid}`;

const data = await fetchClient<MeetingBaseResponse>({
path,
method: 'GET',
});
const data = await fetcher.get<MeetingBaseResponse>({ path });

return {
meetingName: data.meetingName,
Expand Down Expand Up @@ -89,9 +86,8 @@ interface PostMeetingResponse {
}

export const postMeeting = async (request: PostMeetingRequest): Promise<PostMeetingResult> => {
const data = await fetchClient<PostMeetingResponse>({
const data = await fetcher.postWithResponse<PostMeetingResponse>({
path: '',
method: 'POST',
body: request,
isAuthRequire: true,
});
Expand Down Expand Up @@ -144,10 +140,7 @@ interface MeetingEntranceDetails {
}

export const getMeetingEntranceDetails = async (uuid: string) => {
const data = await fetchClient<MeetingEntranceDetails>({
path: `/${uuid}/home`,
method: 'GET',
});
const data = await fetcher.get<MeetingEntranceDetails>({ path: `/${uuid}/home` });

return data;
};
10 changes: 3 additions & 7 deletions frontend/src/apis/meetings/recommends.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetchClient } from '../_common/fetchClient';
import { fetcher } from '../_common/fetcher';
import type { MeetingType } from './meetings';

interface GetMeetingRecommendRequest {
Expand Down Expand Up @@ -35,10 +35,7 @@ export const getMeetingTimeRecommends = async ({

const path = `/${uuid}/recommended-schedules?${urlParams.toString()}`;

const data = await fetchClient<GetMeetingRecommendResponse>({
path,
method: 'GET',
});
const data = await fetcher.get<GetMeetingRecommendResponse>({ path });

return data;
};
Expand All @@ -54,9 +51,8 @@ export const getMeetingAttendees = async ({
}): Promise<MeetingAttendees> => {
const path = `/${uuid}/attendees`;

const data = await fetchClient<GetMeetingAttendeesResponse>({
const data = await fetcher.get<GetMeetingAttendeesResponse>({
path,
method: 'GET',
});

return data;
Expand Down
36 changes: 9 additions & 27 deletions frontend/src/apis/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,21 @@ import type {
MeetingSingleSchedule,
} from 'types/schedule';

import { ResponseError } from '@utils/responseError';

import { BASE_URL } from '@constants/api';

import { fetchClient } from './_common/fetchClient';
import { fetcher } from './_common/fetcher';

export interface PostScheduleRequest {
uuid: string;
requestData: MeetingSingeScheduleItem[];
}

export const postSchedule = async ({ uuid, requestData }: PostScheduleRequest) => {
const response = await fetch(`${BASE_URL}/${uuid}/schedules`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
await fetcher.post({
path: `/${uuid}/schedules`,
body: {
dateTimes: requestData,
}),
credentials: 'include',
},
isAuthRequire: true,
});

if (!response.ok) {
const data = await response.json();

throw new ResponseError(data);
}
};

export const createMeetingSchedulesRequestUrl = (uuid: string, attendeeName: string) => {
Expand All @@ -50,10 +37,7 @@ interface MeetingAllSchedulesResponse {
const getMeetingAllSchedules = async (uuid: string): Promise<MeetingAllSchedules> => {
const path = `/${uuid}/schedules`;

const data = await fetchClient<MeetingAllSchedulesResponse>({
path,
method: 'GET',
});
const data = await fetcher.get<MeetingAllSchedulesResponse>({ path });

return {
schedules: data.schedules,
Expand All @@ -74,9 +58,8 @@ const getMeetingSingleSchedule = async ({
}): Promise<MeetingSingleSchedule> => {
const path = createMeetingSchedulesRequestUrl(uuid, attendeeName);

const data = await fetchClient<MeetingSingleScheduleResponse>({
const data = await fetcher.get<MeetingSingleScheduleResponse>({
path,
method: 'GET',
});

return {
Expand All @@ -88,9 +71,8 @@ const getMeetingSingleSchedule = async ({
export const getMeetingMySchedule = async (uuid: string): Promise<MeetingSingleSchedule> => {
const path = `/${uuid}/attendees/me/schedules`;

const data = await fetchClient<MeetingSingleScheduleResponse>({
const data = await fetcher.get<MeetingSingleScheduleResponse>({
path,
method: 'GET',
isAuthRequire: true,
});

Expand Down
26 changes: 3 additions & 23 deletions frontend/src/apis/users.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { ResponseError } from '@utils/responseError';

import { BASE_URL } from '@constants/api';

import { fetchClient } from './_common/fetchClient';
import { fetcher } from './_common/fetcher';

interface UserLoginRequest {
uuid: string;
Expand All @@ -13,9 +11,8 @@ interface UserLoginRequest {
}

export const postUserLogin = async ({ uuid, request }: UserLoginRequest) => {
const data = await fetchClient<string>({
const data = await fetcher.postWithResponse<string>({
path: `/${uuid}/login`,
method: 'POST',
body: request,
isAuthRequire: true,
});
Expand All @@ -30,22 +27,5 @@ export const postUserLogin = async ({ uuid, request }: UserLoginRequest) => {
* TODO: 응답 데이터가 없을 때도 대응 가능한 fetchClient 함수를 만들어야 함
*/
Comment on lines 27 to 28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3]

이 주석은 이제 지워도 될 것 같네요 :)

export const postUserLogout = async (uuid: string) => {
try {
const response = await fetch(`${BASE_URL}/${uuid}/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});

if (!response.ok) {
const data = await response.json();

throw new ResponseError(data);
}
} catch (error) {
console.error('로그아웃 중 문제가 발생했습니다:', error);
throw error;
}
await fetcher.post({ path: `${BASE_URL}/${uuid}/logout`, isAuthRequire: true });
};
23 changes: 23 additions & 0 deletions frontend/src/components/ErrorToastNotifier/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { PropsWithChildren } from 'react';
import { useEffect, useRef } from 'react';

import useErrorState from '@hooks/useErrorState/useErrorState';
import useToast from '@hooks/useToast/useToast';

export default function ErrorToastNotifier({ children }: PropsWithChildren) {
const error = useErrorState();
const { addToast } = useToast();

const addToastCallbackRef = useRef<
(({ type, message, duration }: Parameters<typeof addToast>[0]) => void) | null
>(null);
addToastCallbackRef.current = addToast;

useEffect(() => {
if (!error || !addToastCallbackRef.current) return;

addToastCallbackRef.current({ type: 'warning', message: error.message, duration: 3000 });
}, [error]);

return children;
}
27 changes: 27 additions & 0 deletions frontend/src/components/QueryClientManager/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type PropsWithChildren } from 'react';

import useErrorDispatch from '@hooks/useErrorDispatch/useErrorDispatch';

import { ResponseError } from '@utils/responseError';

export default function QueryClientManager({ children }: PropsWithChildren) {
const setError = useErrorDispatch();

const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: true,
},
mutations: {
onError: (error: unknown) => {
if (error instanceof ResponseError) {
setError(error);
}
},
},
},
});

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
Loading