2025-09-18 16:23:56 +09:00
|
|
|
import createOpenApiClient from "openapi-fetch";
|
2025-09-25 17:42:36 +09:00
|
|
|
import type { Middleware, MiddlewareCallbackParams } from "openapi-fetch";
|
2025-09-17 18:43:43 +09:00
|
|
|
import type { paths } from "../__generated__/types";
|
2025-09-25 17:42:36 +09:00
|
|
|
import type { ApiResponse } from "../response-helpers";
|
2025-09-19 12:57:39 +09:00
|
|
|
export class ApiError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
message: string,
|
|
|
|
|
public readonly response: Response,
|
|
|
|
|
public readonly body?: unknown
|
|
|
|
|
) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "ApiError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError;
|
|
|
|
|
|
2025-09-19 12:58:00 +09:00
|
|
|
type StrictApiClient = ReturnType<typeof createOpenApiClient<paths>>;
|
|
|
|
|
|
|
|
|
|
type FlexibleApiMethods = {
|
2025-09-25 17:42:36 +09:00
|
|
|
GET<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
|
|
|
|
|
POST<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
|
|
|
|
|
PUT<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
|
|
|
|
|
PATCH<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
|
|
|
|
|
DELETE<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
|
2025-09-19 12:58:00 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ApiClient = StrictApiClient & FlexibleApiMethods;
|
2025-09-17 18:43:43 +09:00
|
|
|
|
2025-09-18 16:23:56 +09:00
|
|
|
export type AuthHeaderResolver = () => string | undefined;
|
|
|
|
|
|
2025-09-19 12:58:00 +09:00
|
|
|
type EnvKey =
|
|
|
|
|
| "NEXT_PUBLIC_API_BASE"
|
|
|
|
|
| "NEXT_PUBLIC_API_URL"
|
|
|
|
|
| "API_BASE_URL"
|
|
|
|
|
| "API_BASE"
|
|
|
|
|
| "API_URL";
|
|
|
|
|
|
|
|
|
|
const BASE_URL_ENV_KEYS: readonly EnvKey[] = [
|
|
|
|
|
"NEXT_PUBLIC_API_BASE",
|
|
|
|
|
"NEXT_PUBLIC_API_URL",
|
|
|
|
|
"API_BASE_URL",
|
|
|
|
|
"API_BASE",
|
|
|
|
|
"API_URL",
|
|
|
|
|
];
|
|
|
|
|
|
2025-09-27 16:59:25 +09:00
|
|
|
const DEFAULT_BASE_URL = "http://localhost:4000";
|
2025-09-19 12:58:00 +09:00
|
|
|
|
|
|
|
|
const normalizeBaseUrl = (value: string) => {
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return DEFAULT_BASE_URL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (trimmed === "/") {
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Avoid accidental double slashes when openapi-fetch joins with request path
|
|
|
|
|
return trimmed.replace(/\/+$/, "");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolveBaseUrlFromEnv = () => {
|
2025-09-26 18:28:47 +09:00
|
|
|
if (typeof process !== "undefined" && process.env) {
|
|
|
|
|
for (const key of BASE_URL_ENV_KEYS) {
|
|
|
|
|
const envValue = process.env[key];
|
|
|
|
|
if (typeof envValue === "string" && envValue.trim()) {
|
|
|
|
|
return normalizeBaseUrl(envValue);
|
|
|
|
|
}
|
2025-09-19 12:58:00 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DEFAULT_BASE_URL;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const resolveBaseUrl = (baseUrl?: string) => {
|
|
|
|
|
if (typeof baseUrl === "string" && baseUrl.trim()) {
|
|
|
|
|
return normalizeBaseUrl(baseUrl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolveBaseUrlFromEnv();
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-18 16:23:56 +09:00
|
|
|
export interface CreateClientOptions {
|
2025-09-19 12:58:00 +09:00
|
|
|
baseUrl?: string;
|
2025-09-18 16:23:56 +09:00
|
|
|
getAuthHeader?: AuthHeaderResolver;
|
2025-09-19 12:57:39 +09:00
|
|
|
handleError?: (response: Response) => void | Promise<void>;
|
2025-09-27 16:59:25 +09:00
|
|
|
enableCsrf?: boolean;
|
2025-09-19 12:57:39 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-25 18:59:07 +09:00
|
|
|
const getBodyMessage = (body: unknown): string | null => {
|
|
|
|
|
if (typeof body === "string") {
|
|
|
|
|
return body;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof body === "object" && body !== null && "message" in body) {
|
|
|
|
|
const maybeMessage = (body as { message?: unknown }).message;
|
|
|
|
|
if (typeof maybeMessage === "string") {
|
|
|
|
|
return maybeMessage;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-19 12:57:39 +09:00
|
|
|
async function defaultHandleError(response: Response) {
|
|
|
|
|
if (response.ok) return;
|
|
|
|
|
|
|
|
|
|
let body: unknown;
|
|
|
|
|
let message = response.statusText || `Request failed with status ${response.status}`;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const cloned = response.clone();
|
|
|
|
|
const contentType = cloned.headers.get("content-type");
|
|
|
|
|
if (contentType?.includes("application/json")) {
|
|
|
|
|
body = await cloned.json();
|
2025-09-25 18:59:07 +09:00
|
|
|
const jsonMessage = getBodyMessage(body);
|
|
|
|
|
if (jsonMessage) {
|
|
|
|
|
message = jsonMessage;
|
2025-09-19 12:57:39 +09:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const text = await cloned.text();
|
|
|
|
|
if (text) {
|
|
|
|
|
body = text;
|
|
|
|
|
message = text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore body parse errors; fall back to status text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new ApiError(message, response, body);
|
2025-09-17 18:43:43 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-27 16:59:25 +09:00
|
|
|
// CSRF token management
|
|
|
|
|
class CsrfTokenManager {
|
|
|
|
|
private token: string | null = null;
|
|
|
|
|
private tokenPromise: Promise<string> | null = null;
|
|
|
|
|
private baseUrl: string;
|
|
|
|
|
|
|
|
|
|
constructor(baseUrl: string) {
|
|
|
|
|
this.baseUrl = baseUrl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getToken(): Promise<string> {
|
|
|
|
|
if (this.token) {
|
|
|
|
|
return this.token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.tokenPromise) {
|
|
|
|
|
return this.tokenPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.tokenPromise = this.fetchToken();
|
|
|
|
|
try {
|
|
|
|
|
this.token = await this.tokenPromise;
|
|
|
|
|
return this.token;
|
|
|
|
|
} finally {
|
|
|
|
|
this.tokenPromise = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async fetchToken(): Promise<string> {
|
|
|
|
|
const response = await fetch(`${this.baseUrl}/api/security/csrf/token`, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Failed to fetch CSRF token: ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!data.success || !data.token) {
|
|
|
|
|
throw new Error('Invalid CSRF token response');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data.token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearToken(): void {
|
|
|
|
|
this.token = null;
|
|
|
|
|
this.tokenPromise = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async refreshToken(): Promise<string> {
|
|
|
|
|
this.clearToken();
|
|
|
|
|
return this.getToken();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-19 12:58:00 +09:00
|
|
|
export function createClient(options: CreateClientOptions = {}): ApiClient {
|
|
|
|
|
const baseUrl = resolveBaseUrl(options.baseUrl);
|
2025-09-19 12:57:39 +09:00
|
|
|
const client = createOpenApiClient<paths>({ baseUrl });
|
2025-09-18 16:23:56 +09:00
|
|
|
|
2025-09-19 12:57:39 +09:00
|
|
|
const handleError = options.handleError ?? defaultHandleError;
|
2025-09-27 16:59:25 +09:00
|
|
|
const enableCsrf = options.enableCsrf ?? true;
|
|
|
|
|
const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null;
|
2025-09-18 16:00:20 +09:00
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
|
2025-09-19 12:57:39 +09:00
|
|
|
if (typeof client.use === "function") {
|
2025-09-18 16:23:56 +09:00
|
|
|
const resolveAuthHeader = options.getAuthHeader;
|
|
|
|
|
|
2025-09-19 12:58:00 +09:00
|
|
|
const middleware: Middleware = {
|
2025-09-27 16:59:25 +09:00
|
|
|
async onRequest({ request }: MiddlewareCallbackParams) {
|
2025-09-26 15:51:07 +09:00
|
|
|
if (!request) return;
|
|
|
|
|
|
|
|
|
|
const nextRequest = new Request(request, {
|
|
|
|
|
credentials: "include",
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-27 16:59:25 +09:00
|
|
|
// Add CSRF token for non-safe methods
|
|
|
|
|
if (csrfManager && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
|
|
|
|
|
try {
|
|
|
|
|
const csrfToken = await csrfManager.getToken();
|
|
|
|
|
nextRequest.headers.set("X-CSRF-Token", csrfToken);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn("Failed to get CSRF token:", error);
|
|
|
|
|
// Continue without CSRF token - let the server handle the error
|
|
|
|
|
}
|
2025-09-26 15:51:07 +09:00
|
|
|
}
|
2025-09-17 18:43:43 +09:00
|
|
|
|
2025-09-27 16:59:25 +09:00
|
|
|
// Add auth header if available
|
|
|
|
|
if (resolveAuthHeader && typeof nextRequest.headers?.has === "function") {
|
|
|
|
|
if (!nextRequest.headers.has("Authorization")) {
|
|
|
|
|
const headerValue = resolveAuthHeader();
|
|
|
|
|
if (headerValue) {
|
|
|
|
|
nextRequest.headers.set("Authorization", headerValue);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-26 15:51:07 +09:00
|
|
|
}
|
2025-09-18 16:23:56 +09:00
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
return nextRequest;
|
2025-09-18 16:23:56 +09:00
|
|
|
},
|
2025-09-19 12:58:00 +09:00
|
|
|
async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) {
|
2025-09-27 16:59:25 +09:00
|
|
|
// Handle CSRF token refresh on 403 errors
|
|
|
|
|
if (response.status === 403 && csrfManager) {
|
|
|
|
|
try {
|
|
|
|
|
const errorText = await response.clone().text();
|
|
|
|
|
if (errorText.includes('CSRF') || errorText.includes('csrf')) {
|
|
|
|
|
// Clear the token so next request will fetch a new one
|
|
|
|
|
csrfManager.clearToken();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore errors when checking response body
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-19 12:57:39 +09:00
|
|
|
await handleError(response);
|
|
|
|
|
},
|
2025-09-19 12:58:00 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
client.use(middleware as never);
|
2025-09-18 16:23:56 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
const flexibleClient = client as ApiClient;
|
|
|
|
|
|
2025-09-27 16:59:25 +09:00
|
|
|
// Store references to original methods before overriding
|
|
|
|
|
const originalGET = client.GET.bind(client);
|
|
|
|
|
const originalPOST = client.POST.bind(client);
|
|
|
|
|
const originalPUT = client.PUT.bind(client);
|
|
|
|
|
const originalPATCH = client.PATCH.bind(client);
|
|
|
|
|
const originalDELETE = client.DELETE.bind(client);
|
|
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
flexibleClient.GET = (async (path: string, options?: unknown) => {
|
2025-09-27 16:59:25 +09:00
|
|
|
return (originalGET as FlexibleApiMethods["GET"])(path, options);
|
2025-09-26 15:51:07 +09:00
|
|
|
}) as ApiClient["GET"];
|
|
|
|
|
|
|
|
|
|
flexibleClient.POST = (async (path: string, options?: unknown) => {
|
2025-09-27 16:59:25 +09:00
|
|
|
return (originalPOST as FlexibleApiMethods["POST"])(path, options);
|
2025-09-26 15:51:07 +09:00
|
|
|
}) as ApiClient["POST"];
|
|
|
|
|
|
|
|
|
|
flexibleClient.PUT = (async (path: string, options?: unknown) => {
|
2025-09-27 16:59:25 +09:00
|
|
|
return (originalPUT as FlexibleApiMethods["PUT"])(path, options);
|
2025-09-26 15:51:07 +09:00
|
|
|
}) as ApiClient["PUT"];
|
|
|
|
|
|
|
|
|
|
flexibleClient.PATCH = (async (path: string, options?: unknown) => {
|
2025-09-27 16:59:25 +09:00
|
|
|
return (originalPATCH as FlexibleApiMethods["PATCH"])(path, options);
|
2025-09-26 15:51:07 +09:00
|
|
|
}) as ApiClient["PATCH"];
|
|
|
|
|
|
|
|
|
|
flexibleClient.DELETE = (async (path: string, options?: unknown) => {
|
2025-09-27 16:59:25 +09:00
|
|
|
return (originalDELETE as FlexibleApiMethods["DELETE"])(path, options);
|
2025-09-26 15:51:07 +09:00
|
|
|
}) as ApiClient["DELETE"];
|
|
|
|
|
|
|
|
|
|
return flexibleClient;
|
2025-09-18 16:23:56 +09:00
|
|
|
}
|
2025-09-19 12:57:39 +09:00
|
|
|
|
|
|
|
|
export type { paths };
|