import type { z } from "zod"; import { AccessDeniedError, ApiError, UNKNOWN_API_ERROR_MESSAGE } from "./api-error"; import { getRuntimeEnv } from "../config/env"; import { createApiResponseSchema } from "./types"; type AuthenticationMode = "none" | "required"; export type ApiClientDependencies = { readonly getToken: () => string | null; readonly clearSession: () => void; readonly onAuthExpired: () => void; }; export type ApiRequestOptions = { readonly path: string; readonly responseSchema: z.ZodType; readonly authentication: AuthenticationMode; readonly method?: string; readonly headers?: HeadersInit; readonly body?: BodyInit | null; }; export type ApiClient = { readonly request: (options: ApiRequestOptions) => Promise; }; function toApiError( status: number, response: { readonly message: string; readonly errorProperty: string | null; }, ): ApiError { const message = response.message.trim().length === 0 ? UNKNOWN_API_ERROR_MESSAGE : response.message; if (status === 403) { return new AccessDeniedError({ status, message, errorProperty: response.errorProperty, }); } return new ApiError({ status, message, errorProperty: response.errorProperty, }); } function unknownApiError(status: number): ApiError { return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }); } export function createApiClient(dependencies: ApiClientDependencies): ApiClient { let hasHandledAuthenticationExpiry = false; let activeProtectedRequestCount = 0; return { async request(options: ApiRequestOptions): Promise { const isProtectedRequest = options.authentication === "required"; if (isProtectedRequest) { activeProtectedRequestCount += 1; } try { const headers = new Headers(options.headers); headers.set("Accept-Language", "ko"); headers.delete("Authorization"); if (isProtectedRequest) { const token = dependencies.getToken(); if (token !== null) { headers.set("Authorization", `Bearer ${token}`); } } const init: RequestInit = { headers }; if (options.method !== undefined) { init.method = options.method; } if (options.body !== undefined) { init.body = options.body; } let response: Response; try { response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init); } catch (error) { if (error instanceof TypeError) { throw unknownApiError(0); } throw error; } if (response.status === 401 && isProtectedRequest && !hasHandledAuthenticationExpiry && dependencies.getToken() !== null) { hasHandledAuthenticationExpiry = true; dependencies.clearSession(); dependencies.onAuthExpired(); } let responseJson: unknown; try { responseJson = await response.json(); } catch (error) { if (error instanceof SyntaxError) { throw unknownApiError(response.status); } throw error; } const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(responseJson); if (!parsedResponse.success) { throw unknownApiError(response.status); } const apiResponse = parsedResponse.data; if (response.ok && apiResponse.success) { return apiResponse.data; } if (!apiResponse.success) { throw toApiError(response.status, apiResponse); } throw unknownApiError(response.status); } finally { if (isProtectedRequest) { activeProtectedRequestCount -= 1; if (activeProtectedRequestCount === 0) { hasHandledAuthenticationExpiry = false; } } } }, }; }