From c8f095297e2ca43b16a7d6243905a35f3ec1f015 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 19 May 2026 16:22:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Clerk=20=ED=86=A0=ED=81=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B3=B4=ED=98=B8=20API=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/api-client.ts | 162 +++++++++++++++++++++++++++++++++++++++++++ api/terms.ts | 41 +---------- services/auth-api.ts | 36 ++-------- 3 files changed, 169 insertions(+), 70 deletions(-) create mode 100644 api/api-client.ts diff --git a/api/api-client.ts b/api/api-client.ts new file mode 100644 index 0000000..e13fbd0 --- /dev/null +++ b/api/api-client.ts @@ -0,0 +1,162 @@ +import { Platform } from 'react-native'; + +const DEFAULT_API_BASE_URL = + Platform.select({ + android: 'http://10.0.2.2:8080', + default: 'http://localhost:8080', + }) ?? 'http://localhost:8080'; + +export const API_BASE_URL = ( + process.env.EXPO_PUBLIC_API_BASE_URL ?? DEFAULT_API_BASE_URL +).replace(/\/$/, ''); + +export interface ApiResponse { + data: T; +} + +interface ApiErrorResponse { + code?: string; + message?: string; + timestamp?: string; + errors?: unknown; +} + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code?: string, + readonly details?: unknown, + ) { + super(message); + this.name = 'ApiError'; + } +} + +export type ClerkTokenGetter = () => Promise; + +export interface ApiRequestOptions extends Omit { + body?: unknown; + headers?: Record; + token?: string; +} + +export async function getClerkSessionToken(getToken: ClerkTokenGetter): Promise { + const token = await getToken(); + + if (!token) { + throw new Error('Missing Clerk session token'); + } + + return token; +} + +export async function publicApiRequest( + path: string, + options: Omit = {}, +): Promise { + return apiRequest(path, options); +} + +export async function authenticatedApiRequest( + getToken: ClerkTokenGetter, + path: string, + options: Omit = {}, +): Promise { + const token = await getClerkSessionToken(getToken); + + return apiRequest(path, { ...options, token }); +} + +export async function apiRequest( + path: string, + { body, headers, token, method = body === undefined ? 'GET' : 'POST', ...init }: ApiRequestOptions = {}, +): Promise { + const requestHeaders: Record = { + Accept: 'application/json', + ...headers, + }; + + const requestInit: RequestInit = { + ...init, + method, + headers: requestHeaders, + }; + + if (token) { + requestHeaders.Authorization = `Bearer ${token}`; + } + + if (body !== undefined) { + requestHeaders['Content-Type'] = requestHeaders['Content-Type'] ?? 'application/json'; + requestInit.body = JSON.stringify(body); + } + + const response = await fetch(buildApiUrl(path), requestInit); + const responseBody = await parseResponseBody(response); + + if (!response.ok) { + throw buildApiError(response, responseBody); + } + + if (response.status === 204) { + return undefined as T; + } + + return unwrapApiResponse(responseBody); +} + +function buildApiUrl(path: string) { + if (/^https?:\/\//.test(path)) { + return path; + } + + return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`; +} + +async function parseResponseBody(response: Response): Promise { + const responseText = await response.text(); + + if (!responseText) { + return null; + } + + try { + return JSON.parse(responseText); + } catch { + return responseText; + } +} + +function unwrapApiResponse(body: unknown): T { + if (isApiResponse(body)) { + return body.data; + } + + return body as T; +} + +function isApiResponse(body: unknown): body is ApiResponse { + return typeof body === 'object' && body !== null && 'data' in body; +} + +function buildApiError(response: Response, body: unknown) { + if (isApiErrorResponse(body)) { + return new ApiError( + body.message ?? `API request failed with status ${response.status}`, + response.status, + body.code, + body.errors, + ); + } + + if (typeof body === 'string' && body.length > 0) { + return new ApiError(body, response.status); + } + + return new ApiError(`API request failed with status ${response.status}`, response.status); +} + +function isApiErrorResponse(body: unknown): body is ApiErrorResponse { + return typeof body === 'object' && body !== null; +} diff --git a/api/terms.ts b/api/terms.ts index f3f159b..5962238 100644 --- a/api/terms.ts +++ b/api/terms.ts @@ -1,13 +1,4 @@ -import { Platform } from 'react-native'; - -const DEFAULT_API_BASE_URL = - Platform.select({ - android: 'http://10.0.2.2:8080', - default: 'http://localhost:8080', - }) ?? 'http://localhost:8080'; - -const apiBaseUrl = - process.env.EXPO_PUBLIC_API_BASE_URL?.replace(/\/$/, '') ?? DEFAULT_API_BASE_URL; +import { publicApiRequest } from '@/api/api-client'; export type TermsType = 'terms_of_service' | 'privacy_policy' | 'service_guide'; export type ContentFormat = 'markdown' | 'html' | 'plain_text'; @@ -23,40 +14,12 @@ export interface TermsResponse { updatedAt: string; } -interface ApiResponse { - data: T; -} - -interface ApiErrorResponse { - code?: string; - message?: string; -} - export async function fetchTerms(type: TermsType, signal?: AbortSignal): Promise { - const response = await fetch(`${apiBaseUrl}/api/v1/terms/${type}`, { + const terms = await publicApiRequest(`/api/v1/terms/${type}`, { method: 'GET', - headers: { - Accept: 'application/json', - }, signal, }); - if (!response.ok) { - let errorMessage = '약관 정보를 불러오지 못했습니다.'; - - try { - const error = (await response.json()) as ApiErrorResponse; - errorMessage = error.message ?? errorMessage; - } catch { - // JSON 응답이 아닌 경우 기본 안내 문구를 사용합니다. - } - - throw new Error(errorMessage); - } - - const body = (await response.json()) as ApiResponse | TermsResponse; - const terms = 'data' in body ? body.data : body; - if (!terms?.title || !terms.content) { throw new Error('약관 정보를 불러오지 못했습니다.'); } diff --git a/services/auth-api.ts b/services/auth-api.ts index f3c0307..1185e7b 100644 --- a/services/auth-api.ts +++ b/services/auth-api.ts @@ -1,10 +1,6 @@ -import { Platform } from 'react-native'; +import { API_BASE_URL, apiRequest, getClerkSessionToken, type ClerkTokenGetter } from '@/api/api-client'; -type ApiResponse = { - data: T; -}; - -type MeResponse = { +export type MeResponse = { publicId: string; }; @@ -16,13 +12,6 @@ type JwtClaims = { sub?: string; }; -const defaultApiBaseUrl = Platform.select({ - android: 'http://10.0.2.2:8080', - default: 'http://localhost:8080', -}); - -export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? defaultApiBaseUrl; - function decodeJwtClaims(token: string): JwtClaims | null { try { const [, payload] = token.split('.'); @@ -40,28 +29,13 @@ function decodeJwtClaims(token: string): JwtClaims | null { } } -export async function syncAuthenticatedMember(getToken: () => Promise) { - const token = await getToken(); - - if (!token) { - throw new Error('Missing Clerk session token'); - } +export async function syncAuthenticatedMember(getToken: ClerkTokenGetter): Promise { + const token = await getClerkSessionToken(getToken); if (__DEV__) { console.log('Clerk token claims', decodeJwtClaims(token)); console.log('Syncing authenticated member with API', API_BASE_URL); } - const response = await fetch(`${API_BASE_URL}/api/v1/auth/me`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - const responseText = await response.text(); - throw new Error(`Failed to sync authenticated member: ${response.status} ${responseText}`); - } - - return response.json() as Promise>; + return apiRequest('/api/v1/auth/me', { token }); }