feat(ai-character): 관리자 인증 셸 구현
This commit is contained in:
215
src/shared/api/__tests__/client-auth.test.ts
Normal file
215
src/shared/api/__tests__/client-auth.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { AccessDeniedError } from "../api-error";
|
||||
import { createApiClient } from "../client";
|
||||
import { server } from "../../test/server";
|
||||
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("authenticated API requests", () => {
|
||||
test("sends Korean language without bearer authentication to login", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
let acceptLanguage: string | null = null;
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, ({ request }) => {
|
||||
acceptLanguage = request.headers.get("Accept-Language");
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/admin/member/login",
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer caller-supplied-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "secret" }),
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(acceptLanguage).toBe("ko");
|
||||
expect(authorization).toBeNull();
|
||||
});
|
||||
|
||||
test("sends bearer authentication to a protected request", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/protected`, ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/api/v2/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(authorization).toBe("Bearer header.payload.signature");
|
||||
});
|
||||
|
||||
test("sends bearer authentication to logout", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/member/logout",
|
||||
method: "POST",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(authorization).toBe("Bearer header.payload.signature");
|
||||
});
|
||||
|
||||
test("clears the session and redirects once for each concurrent 401 burst", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const clearSession = vi.fn();
|
||||
const onAuthExpired = vi.fn();
|
||||
const client = createApiClient({
|
||||
getToken: () => "header.payload.signature",
|
||||
clearSession,
|
||||
onAuthExpired,
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/protected`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
},
|
||||
{ status: 401 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const firstBurst = await Promise.allSettled([
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
]);
|
||||
const secondBurst = await Promise.allSettled([
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
]);
|
||||
|
||||
// Then
|
||||
expect(firstBurst).toHaveLength(2);
|
||||
expect(secondBurst).toHaveLength(2);
|
||||
expect(clearSession).toHaveBeenCalledTimes(2);
|
||||
expect(onAuthExpired).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("surfaces access denied without clearing the session", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client, clearSession, onAuthExpired } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/protected`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
},
|
||||
{ status: 403 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(AccessDeniedError);
|
||||
expect(clearSession).not.toHaveBeenCalled();
|
||||
expect(onAuthExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not write sensitive request data to the console", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient("jwt.secret.value");
|
||||
const multipartBody = new FormData();
|
||||
multipartBody.append("request", JSON.stringify({ password: "secret-password" }));
|
||||
multipartBody.append("file", new File(["private-content"], "private.txt"));
|
||||
const consoleDebug = vi.spyOn(console, "debug");
|
||||
const consoleError = vi.spyOn(console, "error");
|
||||
const consoleInfo = vi.spyOn(console, "info");
|
||||
const consoleLog = vi.spyOn(console, "log");
|
||||
const consoleWarn = vi.spyOn(console, "warn");
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/api/v2/protected`, () =>
|
||||
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/api/v2/protected?signedUrl=https%3A%2F%2Fcdn.example.com%2Fprivate%3Fsignature%3Dabc",
|
||||
method: "POST",
|
||||
body: multipartBody,
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(consoleDebug).not.toHaveBeenCalled();
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
expect(consoleInfo).not.toHaveBeenCalled();
|
||||
expect(consoleLog).not.toHaveBeenCalled();
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
31
src/shared/api/__tests__/client-test-helpers.ts
Normal file
31
src/shared/api/__tests__/client-test-helpers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createApiClient } from "../client";
|
||||
|
||||
export const apiBaseUrl = "https://api.example.com";
|
||||
export const valueSchema = z.object({ value: z.string() });
|
||||
|
||||
export type TestClient = {
|
||||
readonly client: ReturnType<typeof createApiClient>;
|
||||
readonly clearSession: ReturnType<typeof vi.fn>;
|
||||
readonly onAuthExpired: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
export function createTestClient(token = "header.payload.signature"): TestClient {
|
||||
let currentToken: string | null = token;
|
||||
const clearSession = vi.fn(() => {
|
||||
currentToken = null;
|
||||
});
|
||||
const onAuthExpired = vi.fn();
|
||||
|
||||
return {
|
||||
client: createApiClient({
|
||||
getToken: () => currentToken,
|
||||
clearSession,
|
||||
onAuthExpired,
|
||||
}),
|
||||
clearSession,
|
||||
onAuthExpired,
|
||||
};
|
||||
}
|
||||
117
src/shared/api/__tests__/client.test.ts
Normal file
117
src/shared/api/__tests__/client.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "../api-error";
|
||||
import { server } from "../../test/server";
|
||||
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("API client", () => {
|
||||
test("accepts a successful envelope without errorProperty", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/public`, () =>
|
||||
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const response = await client.request({
|
||||
path: "/public",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response).toEqual({ value: "ok" });
|
||||
});
|
||||
|
||||
test("accepts a successful envelope with null errorProperty", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/public`, () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { value: "ok" },
|
||||
errorProperty: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const response = await client.request({
|
||||
path: "/public",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response).toEqual({ value: "ok" });
|
||||
});
|
||||
|
||||
test.each([400, 404, 405, 415, 500])(
|
||||
"preserves a Korean server error envelope for status %i",
|
||||
async (status) => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
const message = `서버 오류 ${status}`;
|
||||
const errorProperty = "request";
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/error`, () =>
|
||||
HttpResponse.json(
|
||||
{ success: false, message, data: null, errorProperty },
|
||||
{ status },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/error",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toMatchObject({ status, message, errorProperty });
|
||||
},
|
||||
);
|
||||
|
||||
test("exposes server errors as ApiError", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/error`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "잘못된 요청입니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/error",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
66
src/shared/api/__tests__/pagination.test.ts
Normal file
66
src/shared/api/__tests__/pagination.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createPageParams, type PageData } from "../pagination";
|
||||
|
||||
describe("createPageParams", () => {
|
||||
test("uses documented default page and size", () => {
|
||||
// Given
|
||||
const request = {};
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams).toEqual({ page: 0, size: 20 });
|
||||
});
|
||||
|
||||
test("clamps size to the documented lower bound", () => {
|
||||
// Given
|
||||
const request = { size: 1 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.size).toBe(20);
|
||||
});
|
||||
|
||||
test("clamps size to the documented upper bound", () => {
|
||||
// Given
|
||||
const request = { size: 51 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.size).toBe(50);
|
||||
});
|
||||
|
||||
test("keeps a provided page unchanged", () => {
|
||||
// Given
|
||||
const request = { page: 3, size: 20 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.page).toBe(3);
|
||||
});
|
||||
|
||||
test("defines the documented page response shape", () => {
|
||||
// Given
|
||||
const page: PageData<string> = {
|
||||
totalCount: 1,
|
||||
page: 0,
|
||||
size: 20,
|
||||
hasNext: false,
|
||||
items: ["루나"],
|
||||
};
|
||||
|
||||
// When
|
||||
const firstItem = page.items[0];
|
||||
|
||||
// Then
|
||||
expect(firstItem).toBe("루나");
|
||||
});
|
||||
});
|
||||
58
src/shared/api/__tests__/query-client.test.ts
Normal file
58
src/shared/api/__tests__/query-client.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ApiError } from "../api-error";
|
||||
import { createQueryClient, shouldRetryQuery } from "../query-client";
|
||||
|
||||
describe("TanStack Query defaults", () => {
|
||||
test("does not retry unauthenticated query errors", () => {
|
||||
// Given
|
||||
const error = new ApiError({
|
||||
status: 401,
|
||||
message: "인증 정보가 없습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
|
||||
// When
|
||||
const shouldRetry = shouldRetryQuery(0, error);
|
||||
|
||||
// Then
|
||||
expect(shouldRetry).toBe(false);
|
||||
});
|
||||
|
||||
test("does not retry access-denied query errors", () => {
|
||||
// Given
|
||||
const error = new ApiError({
|
||||
status: 403,
|
||||
message: "접근 권한이 없습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
|
||||
// When
|
||||
const shouldRetry = shouldRetryQuery(0, error);
|
||||
|
||||
// Then
|
||||
expect(shouldRetry).toBe(false);
|
||||
});
|
||||
|
||||
test("limits retries for other query errors", () => {
|
||||
// Given
|
||||
const error = new Error("network failure");
|
||||
|
||||
// When
|
||||
const shouldRetry = shouldRetryQuery(2, error);
|
||||
|
||||
// Then
|
||||
expect(shouldRetry).toBe(false);
|
||||
});
|
||||
|
||||
test("disables mutation retries", () => {
|
||||
// Given
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
// When
|
||||
const retry = queryClient.getDefaultOptions().mutations?.retry;
|
||||
|
||||
// Then
|
||||
expect(retry).toBe(false);
|
||||
});
|
||||
});
|
||||
21
src/shared/api/api-error.ts
Normal file
21
src/shared/api/api-error.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type ApiErrorOptions = {
|
||||
readonly status: number;
|
||||
readonly message: string;
|
||||
readonly errorProperty: string | null;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
override readonly name: string = "ApiError";
|
||||
readonly status: number;
|
||||
readonly errorProperty: string | null;
|
||||
|
||||
constructor(options: ApiErrorOptions) {
|
||||
super(options.message);
|
||||
this.status = options.status;
|
||||
this.errorProperty = options.errorProperty;
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessDeniedError extends ApiError {
|
||||
override readonly name: string = "AccessDeniedError";
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
24
src/shared/api/pagination.ts
Normal file
24
src/shared/api/pagination.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type PageData<Item> = {
|
||||
readonly totalCount: number;
|
||||
readonly page: number;
|
||||
readonly size: number;
|
||||
readonly hasNext: boolean;
|
||||
readonly items: readonly Item[];
|
||||
};
|
||||
|
||||
export type PageParams = {
|
||||
readonly page?: number;
|
||||
readonly size?: number;
|
||||
};
|
||||
|
||||
export function createPageParams(params: PageParams = {}): {
|
||||
readonly page: number;
|
||||
readonly size: number;
|
||||
} {
|
||||
const size = params.size ?? 20;
|
||||
|
||||
return {
|
||||
page: params.page ?? 0,
|
||||
size: Math.min(Math.max(size, 20), 50),
|
||||
};
|
||||
}
|
||||
22
src/shared/api/query-client.ts
Normal file
22
src/shared/api/query-client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError } from "./api-error";
|
||||
|
||||
export function shouldRetryQuery(failureCount: number, error: unknown): boolean {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 2;
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: shouldRetryQuery },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const queryClient = createQueryClient();
|
||||
34
src/shared/api/types.ts
Normal file
34
src/shared/api/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type ApiSuccessResponse<Data> = {
|
||||
readonly success: true;
|
||||
readonly message: null;
|
||||
readonly data: Data;
|
||||
readonly errorProperty?: null;
|
||||
};
|
||||
|
||||
export type ApiErrorResponse = {
|
||||
readonly success: false;
|
||||
readonly message: string;
|
||||
readonly data: null;
|
||||
readonly errorProperty: string | null;
|
||||
};
|
||||
|
||||
export type ApiResponse<Data> = ApiSuccessResponse<Data> | ApiErrorResponse;
|
||||
|
||||
export function createApiResponseSchema<Data>(dataSchema: z.ZodType<Data>) {
|
||||
return z.discriminatedUnion("success", [
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
message: z.null(),
|
||||
data: dataSchema,
|
||||
errorProperty: z.null().optional(),
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
message: z.string(),
|
||||
data: z.null(),
|
||||
errorProperty: z.string().nullable(),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user