Files
voiceon-character-admin/src/shared/api/client.ts

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;
}
}
}
},
};
}