Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions api/api-client.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<string | null>;

export interface ApiRequestOptions extends Omit<RequestInit, 'body' | 'headers'> {
body?: unknown;
headers?: Record<string, string>;
token?: string;
}

export async function getClerkSessionToken(getToken: ClerkTokenGetter): Promise<string> {
const token = await getToken();

if (!token) {
throw new Error('Missing Clerk session token');
}

return token;
}

export async function publicApiRequest<T>(
path: string,
options: Omit<ApiRequestOptions, 'token'> = {},
): Promise<T> {
return apiRequest<T>(path, options);
}

export async function authenticatedApiRequest<T>(
getToken: ClerkTokenGetter,
path: string,
options: Omit<ApiRequestOptions, 'token'> = {},
): Promise<T> {
const token = await getClerkSessionToken(getToken);

return apiRequest<T>(path, { ...options, token });
}

export async function apiRequest<T>(
path: string,
{ body, headers, token, method = body === undefined ? 'GET' : 'POST', ...init }: ApiRequestOptions = {},
): Promise<T> {
const requestHeaders: Record<string, string> = {
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<T>(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<unknown> {
const responseText = await response.text();

if (!responseText) {
return null;
}

try {
return JSON.parse(responseText);
} catch {
return responseText;
}
}

function unwrapApiResponse<T>(body: unknown): T {
if (isApiResponse<T>(body)) {
return body.data;
}

return body as T;
}

function isApiResponse<T>(body: unknown): body is ApiResponse<T> {
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;
}
41 changes: 2 additions & 39 deletions api/terms.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,40 +14,12 @@ export interface TermsResponse {
updatedAt: string;
}

interface ApiResponse<T> {
data: T;
}

interface ApiErrorResponse {
code?: string;
message?: string;
}

export async function fetchTerms(type: TermsType, signal?: AbortSignal): Promise<TermsResponse> {
const response = await fetch(`${apiBaseUrl}/api/v1/terms/${type}`, {
const terms = await publicApiRequest<TermsResponse>(`/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> | TermsResponse;
const terms = 'data' in body ? body.data : body;

if (!terms?.title || !terms.content) {
throw new Error('약관 정보를 불러오지 못했습니다.');
}
Expand Down
36 changes: 5 additions & 31 deletions services/auth-api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { Platform } from 'react-native';
import { API_BASE_URL, apiRequest, getClerkSessionToken, type ClerkTokenGetter } from '@/api/api-client';

type ApiResponse<T> = {
data: T;
};

type MeResponse = {
export type MeResponse = {
publicId: string;
};

Expand All @@ -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('.');
Expand All @@ -40,28 +29,13 @@ function decodeJwtClaims(token: string): JwtClaims | null {
}
}

export async function syncAuthenticatedMember(getToken: () => Promise<string | null>) {
const token = await getToken();

if (!token) {
throw new Error('Missing Clerk session token');
}
export async function syncAuthenticatedMember(getToken: ClerkTokenGetter): Promise<MeResponse> {
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<ApiResponse<MeResponse>>;
return apiRequest<MeResponse>('/api/v1/auth/me', { token });
}
Loading