Skip to content

Commit

Permalink
feat: add custom dev proxy vite plugin & move fetchApi into Api m…
Browse files Browse the repository at this point in the history
…odel
  • Loading branch information
aube-dev committed Sep 15, 2024
1 parent 72395a6 commit 364d907
Show file tree
Hide file tree
Showing 22 changed files with 440 additions and 655 deletions.
11 changes: 8 additions & 3 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,16 +142,21 @@ module.exports = {
},

{
files: ['./server/**/*.{ts,tsx}'],
files: [
'./server/**/*.{ts,tsx}',
'./vite.config.ts',
'./vite.config.storybook.ts',
],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
// https://github.com/vitejs/vite/issues/10063
group: ['@/*', '@server/*', 'app/*', 'server/*'],
message: 'server 디렉토리 안에서는 상대 경로를 사용해 주세요.',
group: ['@/*', '@server', 'app/*', 'server/*'],
message:
'server 디렉토리, 또는 Vite config 파일에서는 상대 경로를 사용해 주세요.',
},
],
},
Expand Down
2 changes: 1 addition & 1 deletion app/apis/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Api } from '@server';
import { Api } from '@/models/api';

export const api_loginWithKakao = new Api<
{ code: string; state: string },
Expand Down
6 changes: 3 additions & 3 deletions app/hooks/useErrorToast.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { create } from 'zustand';
import { type FrontendErrorResponse } from '@server';
import type { JsonValue, FrontendErrorResponse } from '@/types/api';

interface ErrorToastStore {
error: FrontendErrorResponse<unknown> | null;
setError: (newError: FrontendErrorResponse<unknown>) => void;
error: FrontendErrorResponse<JsonValue> | null;
setError: (newError: FrontendErrorResponse<JsonValue>) => void;
clearError: () => void;
}

Expand Down
14 changes: 13 additions & 1 deletion app/hooks/useTypedFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,20 @@ import {
type FrontendErrorResponse,
type FrontendSuccessResponse,
type JsonValue,
} from '@server';
} from '@/types/api';

/**
* `@remix-run/react`의 `useFetcher` wrapper
* - `FrontendSuccessResponse` 또는 `FrontendErrorResponse` 형태를 반환하는 `action`만 허용
* - 에러 발생 시 에러 토스트 띄우는 로직 적용
* @example
* ```
* const Component = () => {
* const fetcher = useTypedFetcher<typeof action>();
* // ...
* };
* ```
*/
const useTypedFetcher = <
T extends (
...params: unknown[]
Expand Down
258 changes: 258 additions & 0 deletions app/models/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { type AppLoadContext } from '@remix-run/cloudflare';
import type {
ApiOptions,
ApiRequest,
ApiReturnType,
ApiSuccessReturnType,
BackendError,
} from '@/types/api';
import { getAuthToken } from '@server';

const COMMON_ERROR: {
errorByStatus: Record<
number,
{
message: string;
}
>;
} = {
errorByStatus: {
401: {
message: '로그인이 필요합니다',
},
},
};

export class Api<Variables, Result> {
public method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
public endpoint: `/${string}`;
public needToLogin: boolean;
public baseUrl?: string;
public errorMessage?: {
messageByStatus?: Record<number, { message: string }>;
};
public request: (variables: Variables) => ApiRequest;

constructor(apiInfo: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
endpoint: `/${string}`;
needToLogin?: boolean;
baseUrl?: string;
errorMessage?: {
messageByStatus?: Record<number, { message: string }>;
};
request: (variables: Variables) => ApiRequest;
}) {
this.method = apiInfo.method;
this.endpoint = apiInfo.endpoint;
this.needToLogin = apiInfo.needToLogin ?? false;
this.baseUrl = apiInfo.baseUrl;
this.errorMessage = apiInfo.errorMessage;
this.request = apiInfo.request;
}

getFetchInfo(
variables: Variables,
accessToken?: string,
): {
pathname: string;
method: Api<Variables, Result>['method'];
headers: ApiRequest['headers'];
body?: string | FormData;
request: ApiRequest;
} {
const parsedRequest = this.request(variables);

const pathString =
parsedRequest.pathParams?.reduce<string>(
(prev, cur) => `${prev}/${cur}`,
'',
) ?? '';

const params = parsedRequest.queryParams ?? {};
const queryString = Object.keys(params).reduce(
(prev, cur) =>
`${prev}${
params[cur] !== null && params[cur] !== undefined
? `&${cur}=${params[cur]}`
: ''
}`,
'',
);

const pathname = `${this.endpoint}${pathString}${
queryString ? `?${queryString.slice(1)}` : ''
}`;

const authorizationHeader = accessToken
? {
Authorization: `Bearer ${accessToken}`,
}
: undefined;

return {
pathname,
method: this.method,
headers: {
'Content-Type':
parsedRequest.body instanceof FormData
? 'multipart/form-data'
: 'application/json',
...authorizationHeader,
...parsedRequest.headers,
},
body:
// eslint-disable-next-line no-nested-ternary
parsedRequest.body !== undefined
? parsedRequest.body instanceof FormData
? parsedRequest.body
: JSON.stringify(parsedRequest.body)
: undefined,
request: parsedRequest,
};
}

async fetch(
variables: Variables,
context: AppLoadContext,
options?: ApiOptions & { throwOnError?: false },
): Promise<ApiReturnType<Result>>;
async fetch(
variables: Variables,
context: AppLoadContext,
options?: ApiOptions & { throwOnError: true },
): Promise<ApiSuccessReturnType<Result>>;
async fetch(
variables: Variables,
context: AppLoadContext,
options?: ApiOptions,
): Promise<ApiReturnType<Result>> {
try {
const baseUrl = this.baseUrl ?? context.API_URL;

const token = context.authSession
? await getAuthToken(context.authSession, context.API_URL)
: null;

const fetchInfo = this.getFetchInfo(variables, token?.accessToken);

const fetchUrl = `${baseUrl}${fetchInfo.pathname}`;

if (!token?.accessToken && this.needToLogin) {
throw new ApiError({
status: 401,
api: this,
request: fetchInfo.request,
...COMMON_ERROR.errorByStatus[401],
});
}

const response = await fetch(fetchUrl, {
method: fetchInfo.method,
body: fetchInfo.body,
headers: fetchInfo.headers,
});

// `Result`가 `null`인 경우가 있지만 이는 try-catch에 의한 것으로, 타입 체계상에서는 분기처리할 수 없음
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any;
try {
result = await response.json<Result>();
} catch (error) {
console.log(error, `${this.method} ${this.endpoint}`);
result = null;
}

if (response.ok) {
return {
isSuccess: true,
response: result,
};
}

const backendError: BackendError | null = result;

const error: {
message?: string;
} = {};

// common error message (by status) - 3rd priority
error.message =
COMMON_ERROR.errorByStatus[response.status]?.message ?? error.message;

// error message by status - 2nd priority
error.message =
this.errorMessage?.messageByStatus?.[response.status]?.message ??
error.message;

// error message from server - 1st priority
error.message = backendError?.error ?? error.message;

throw new ApiError({
...error,
status: response.status,
api: this,
backendError: backendError ?? undefined,
request: fetchInfo.request,
});
} catch (error) {
// 이미 처리된 에러는 그대로 반환
if (error instanceof ApiError) {
console.log(error.serverError);
if (options?.throwOnError) {
throw error;
}
return {
isSuccess: false,
error: error,
};
}

// TODO: Sentry 등 에러 로깅 솔루션 추가
console.error(error, this, variables);
const apiError = new ApiError({
api: this,
request: this.request(variables),
frontendError: error,
});
if (options?.throwOnError) {
throw apiError;
}
return {
isSuccess: false,
error: apiError,
};
}
}
}

export class ApiError extends Error {
public status?: number;

public serverError?: BackendError;

public api: Api<unknown, unknown>;

public request: ApiRequest;

public frontendError?: unknown;

constructor(error: {
status?: number;
message?: string;
backendError?: BackendError;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api: Api<any, any>;
request: ApiRequest;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
frontendError?: any;
}) {
super(error.message ?? '문제가 발생했습니다. 잠시 후 다시 시도해주세요.');
this.name = 'ApiError';
this.status = error.status ?? error.backendError?.status;
this.api = error.api;
this.request = error.request;
this.serverError = error.backendError;
this.frontendError = error.frontendError;
}
}
23 changes: 6 additions & 17 deletions app/routes/oauth.kakao.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
import { type LoaderFunctionArgs } from '@remix-run/cloudflare';
export const loader = async () => null;

export const loader = async ({ request }: LoaderFunctionArgs) => {
const cookieHeader = request.headers;
console.log(cookieHeader);

return null;
};

const KakaoRedirect = () => {
console.log('kakaoredirect');

return (
<div>
{/* <p className={textStyle.headline1B}>{fetcher.state}</p>
const KakaoRedirect = () => (
<div>
{/* <p className={textStyle.headline1B}>{fetcher.state}</p>
<fetcher.Form method="POST">
<button type="submit">test submit</button>
</fetcher.Form> */}
</div>
);
};
</div>
);

export default KakaoRedirect;
19 changes: 1 addition & 18 deletions server/types/api.ts → app/types/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import type { Api, ApiError } from '../constants/api';
import type { ApiError } from '@/models/api';

type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[] | readonly JsonValue[];
Expand Down Expand Up @@ -57,20 +57,3 @@ export type ApiReturnType<Result> =
export interface ApiOptions {
throwOnError?: boolean;
}

export type FetchApi = {
<Variables, Result>(
api: Api<Variables, Result>,
variables: Variables,
options?: ApiOptions & {
throwOnError?: false;
},
): Promise<ApiReturnType<Result>>;
<Variables, Result>(
api: Api<Variables, Result>,
variables: Variables,
options?: ApiOptions & {
throwOnError: true;
},
): Promise<ApiSuccessReturnType<Result>>;
};
Loading

0 comments on commit 364d907

Please sign in to comment.