210 lines
5.9 KiB
TypeScript
Raw Normal View History

import createOpenApiClient from "openapi-fetch";
import type { Middleware, MiddlewareCallbackParams } from "openapi-fetch";
import type { paths } from "../__generated__/types";
import type { ApiResponse } from "../response-helpers";
export class ApiError extends Error {
constructor(
message: string,
public readonly response: Response,
public readonly body?: unknown
) {
super(message);
this.name = "ApiError";
}
}
export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError;
type StrictApiClient = ReturnType<typeof createOpenApiClient<paths>>;
type FlexibleApiMethods = {
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>>;
};
export type ApiClient = StrictApiClient & FlexibleApiMethods;
export type AuthHeaderResolver = () => string | undefined;
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",
];
const DEFAULT_BASE_URL = "http://localhost:4000/api";
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 = () => {
for (const key of BASE_URL_ENV_KEYS) {
const envValue = process.env?.[key];
if (typeof envValue === "string" && envValue.trim()) {
return normalizeBaseUrl(envValue);
}
}
return DEFAULT_BASE_URL;
};
export const resolveBaseUrl = (baseUrl?: string) => {
if (typeof baseUrl === "string" && baseUrl.trim()) {
return normalizeBaseUrl(baseUrl);
}
return resolveBaseUrlFromEnv();
};
export interface CreateClientOptions {
baseUrl?: string;
getAuthHeader?: AuthHeaderResolver;
handleError?: (response: Response) => void | Promise<void>;
}
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;
};
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();
const jsonMessage = getBodyMessage(body);
if (jsonMessage) {
message = jsonMessage;
}
} 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);
}
export function createClient(options: CreateClientOptions = {}): ApiClient {
const baseUrl = resolveBaseUrl(options.baseUrl);
const client = createOpenApiClient<paths>({ baseUrl });
const handleError = options.handleError ?? defaultHandleError;
const normalizePath = (path: string): string => {
if (!path) return "/api";
const ensured = path.startsWith("/") ? path : `/${path}`;
if (ensured === "/api" || ensured.startsWith("/api/")) {
return ensured;
}
return `/api${ensured}`;
};
if (typeof client.use === "function") {
const resolveAuthHeader = options.getAuthHeader;
const middleware: Middleware = {
onRequest({ request }: MiddlewareCallbackParams) {
if (!request) return;
const nextRequest = new Request(request, {
credentials: "include",
});
if (!resolveAuthHeader) {
return nextRequest;
}
if (typeof nextRequest.headers?.has !== "function") {
return nextRequest;
}
if (nextRequest.headers.has("Authorization")) {
return nextRequest;
}
const headerValue = resolveAuthHeader();
if (!headerValue) {
return nextRequest;
}
nextRequest.headers.set("Authorization", headerValue);
return nextRequest;
},
async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) {
await handleError(response);
},
};
client.use(middleware as never);
}
const flexibleClient = client as ApiClient;
flexibleClient.GET = (async (path: string, options?: unknown) => {
return (client.GET as FlexibleApiMethods["GET"])(normalizePath(path), options);
}) as ApiClient["GET"];
flexibleClient.POST = (async (path: string, options?: unknown) => {
return (client.POST as FlexibleApiMethods["POST"])(normalizePath(path), options);
}) as ApiClient["POST"];
flexibleClient.PUT = (async (path: string, options?: unknown) => {
return (client.PUT as FlexibleApiMethods["PUT"])(normalizePath(path), options);
}) as ApiClient["PUT"];
flexibleClient.PATCH = (async (path: string, options?: unknown) => {
return (client.PATCH as FlexibleApiMethods["PATCH"])(normalizePath(path), options);
}) as ApiClient["PATCH"];
flexibleClient.DELETE = (async (path: string, options?: unknown) => {
return (client.DELETE as FlexibleApiMethods["DELETE"])(normalizePath(path), options);
}) as ApiClient["DELETE"];
return flexibleClient;
}
export type { paths };