feat(ai-character): 관리자 인증 셸 구현
This commit is contained in:
131
src/shared/api/client.ts
Normal file
131
src/shared/api/client.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { AccessDeniedError, ApiError } 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 {
|
||||
if (status === 403) {
|
||||
return new AccessDeniedError({
|
||||
status,
|
||||
message: response.message,
|
||||
errorProperty: response.errorProperty,
|
||||
});
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
status,
|
||||
message: response.message,
|
||||
errorProperty: response.errorProperty,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
|
||||
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
|
||||
await response.json(),
|
||||
);
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new ApiError({
|
||||
status: response.status,
|
||||
message: "API 응답 형식이 올바르지 않습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
}
|
||||
|
||||
const apiResponse = parsedResponse.data;
|
||||
|
||||
if (response.ok && apiResponse.success) {
|
||||
return apiResponse.data;
|
||||
}
|
||||
|
||||
if (!apiResponse.success) {
|
||||
if (response.status === 401 && isProtectedRequest) {
|
||||
if (!hasHandledAuthenticationExpiry) {
|
||||
hasHandledAuthenticationExpiry = true;
|
||||
dependencies.clearSession();
|
||||
dependencies.onAuthExpired();
|
||||
}
|
||||
}
|
||||
|
||||
throw toApiError(response.status, apiResponse);
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
status: response.status,
|
||||
message: "API 오류 응답 형식이 올바르지 않습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
} finally {
|
||||
if (isProtectedRequest) {
|
||||
activeProtectedRequestCount -= 1;
|
||||
|
||||
if (activeProtectedRequestCount === 0) {
|
||||
hasHandledAuthenticationExpiry = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user