- executePaginatedAction, buildApiUrl 유틸 모듈 분리 - QuoteCalculationReport, demoStore, export.ts 불필요 코드 삭제 - CLAUDE.md에 Zod 스키마 검증 및 Server Action 공통 유틸 규칙 추가 - package.json 의존성 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
6.6 KiB
TypeScript
248 lines
6.6 KiB
TypeScript
// lib/api/index.ts
|
|
// API 클라이언트 배럴 익스포트
|
|
|
|
export { ApiClient, withTokenRefresh } from './client';
|
|
export { serverFetch } from './fetch-wrapper';
|
|
export { AUTH_CONFIG } from './auth/auth-config';
|
|
|
|
// 공용 API 타입 및 페이지네이션 유틸리티
|
|
export {
|
|
toPaginationMeta,
|
|
type PaginatedApiResponse,
|
|
type PaginationMeta,
|
|
type PaginatedResult,
|
|
type SelectOption,
|
|
} from './types';
|
|
|
|
// 쿼리 파라미터 빌더 (신규 코드용)
|
|
export { buildQueryParams, buildApiUrl } from './query-params';
|
|
|
|
// 페이지네이션 조회 래퍼 (신규 코드용)
|
|
export { executePaginatedAction, type PaginatedActionResult } from './execute-paginated-action';
|
|
|
|
// 공용 룩업 헬퍼 (거래처/계좌 조회)
|
|
export {
|
|
fetchVendorOptions,
|
|
fetchBankAccountOptions,
|
|
fetchBankAccountDetailOptions,
|
|
type BankAccountOption,
|
|
} from './shared-lookups';
|
|
|
|
// 공통 코드 타입 및 유틸리티
|
|
export {
|
|
toCommonCodeOptions,
|
|
getCodeLabel,
|
|
type CommonCode,
|
|
type CommonCodeOption,
|
|
} from './common-codes';
|
|
|
|
// Server-side API 클라이언트
|
|
// 서버 액션에서 쿠키 기반 Bearer 토큰 자동 포함
|
|
import { cookies } from 'next/headers';
|
|
import { redirect } from 'next/navigation';
|
|
import { AUTH_CONFIG } from './auth/auth-config';
|
|
import { authenticatedFetch } from './authenticated-fetch';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
/**
|
|
* Server Actions 전용 API 클라이언트
|
|
*
|
|
* 특징:
|
|
* - 쿠키에서 access_token 자동 읽기
|
|
* - X-API-KEY + Bearer 토큰 자동 포함
|
|
* - 401 발생 시 authenticatedFetch 게이트웨이를 통한 자동 갱신
|
|
*/
|
|
class ServerApiClient {
|
|
private baseURL: string;
|
|
private apiKey: string;
|
|
|
|
constructor() {
|
|
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, '');
|
|
this.baseURL = `${apiUrl}/api/v1`;
|
|
this.apiKey = process.env.API_KEY || '';
|
|
}
|
|
|
|
/**
|
|
* 쿠키에서 인증 헤더 생성 (async)
|
|
*/
|
|
private async getAuthHeaders(token?: string): Promise<Record<string, string>> {
|
|
const cookieStore = await cookies();
|
|
const accessToken = token || cookieStore.get('access_token')?.value;
|
|
|
|
return {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-API-KEY': this.apiKey,
|
|
...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 토큰 쿠키 삭제 (인증 실패 시)
|
|
*/
|
|
private async clearTokenCookies() {
|
|
const cookieStore = await cookies();
|
|
cookieStore.delete('access_token');
|
|
cookieStore.delete('refresh_token');
|
|
cookieStore.delete('token_refreshed_at');
|
|
cookieStore.delete('is_authenticated');
|
|
}
|
|
|
|
/**
|
|
* 새 토큰을 쿠키에 저장
|
|
*/
|
|
private async setNewTokenCookies(tokens: {
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
expiresIn?: number;
|
|
}) {
|
|
const cookieStore = await cookies();
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
|
|
if (tokens.accessToken) {
|
|
cookieStore.set('access_token', tokens.accessToken, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: tokens.expiresIn || 7200,
|
|
});
|
|
|
|
cookieStore.set('token_refreshed_at', Date.now().toString(), {
|
|
httpOnly: false,
|
|
secure: isProduction,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 60,
|
|
});
|
|
}
|
|
|
|
if (tokens.refreshToken) {
|
|
cookieStore.set('refresh_token', tokens.refreshToken, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 604800,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* HTTP 요청 실행 (authenticatedFetch 게이트웨이를 통한 자동 갱신)
|
|
*/
|
|
private async request<T>(
|
|
endpoint: string,
|
|
options?: RequestInit & { skipAuthRetry?: boolean }
|
|
): Promise<T> {
|
|
try {
|
|
const cookieStore = await cookies();
|
|
const refreshToken = cookieStore.get('refresh_token')?.value;
|
|
const headers = await this.getAuthHeaders();
|
|
|
|
const url = `${this.baseURL}${endpoint}`;
|
|
|
|
// authenticatedFetch 게이트웨이로 요청 실행
|
|
// skipAuthRetry=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함
|
|
const { response, newTokens, authFailed } = await authenticatedFetch(
|
|
url,
|
|
{
|
|
...options,
|
|
headers: {
|
|
...headers,
|
|
...options?.headers,
|
|
},
|
|
cache: 'no-store',
|
|
},
|
|
options?.skipAuthRetry ? undefined : refreshToken,
|
|
'ServerApiClient'
|
|
);
|
|
|
|
// 새 토큰 → 쿠키 저장
|
|
if (newTokens) {
|
|
await this.setNewTokenCookies(newTokens);
|
|
}
|
|
|
|
// 인증 실패 → 쿠키 삭제 + 리다이렉트
|
|
if (authFailed && !options?.skipAuthRetry) {
|
|
await this.clearTokenCookies();
|
|
redirect('/login');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw {
|
|
status: response.status,
|
|
message: errorData.message || 'An error occurred',
|
|
errors: errorData.errors,
|
|
code: errorData.code,
|
|
};
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET 요청
|
|
*/
|
|
async get<T>(endpoint: string, options?: { params?: Record<string, string> }): Promise<T> {
|
|
let url = endpoint;
|
|
if (options?.params) {
|
|
const searchParams = new URLSearchParams(options.params);
|
|
url = `${endpoint}?${searchParams.toString()}`;
|
|
}
|
|
return this.request<T>(url, { method: 'GET' });
|
|
}
|
|
|
|
/**
|
|
* POST 요청
|
|
*/
|
|
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'POST',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PUT 요청
|
|
*/
|
|
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PUT',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PATCH 요청
|
|
*/
|
|
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PATCH',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* DELETE 요청
|
|
*/
|
|
async delete<T>(endpoint: string, options?: { data?: unknown }): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'DELETE',
|
|
body: options?.data ? JSON.stringify(options.data) : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 서버 액션용 API 클라이언트 인스턴스
|
|
export const apiClient = new ServerApiClient();
|