feat(ai-character): 관리자 인증 셸 구현

This commit is contained in:
Yu Sung
2026-07-27 15:04:03 +09:00
parent ba43dd829e
commit 5356bd1e6a
82 changed files with 6017 additions and 18 deletions

131
src/shared/api/client.ts Normal file
View 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;
}
}
}
},
};
}