144 lines
3.9 KiB
TypeScript
144 lines
3.9 KiB
TypeScript
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<Data> = {
|
|
readonly path: string;
|
|
readonly responseSchema: z.ZodType<Data>;
|
|
readonly authentication: AuthenticationMode;
|
|
readonly method?: string;
|
|
readonly headers?: HeadersInit;
|
|
readonly body?: BodyInit | null;
|
|
};
|
|
|
|
export type ApiClient = {
|
|
readonly request: <Data>(options: ApiRequestOptions<Data>) => Promise<Data>;
|
|
};
|
|
|
|
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<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|