Skip to content

Instantly share code, notes, and snippets.

@Juan-Severiano
Created November 7, 2025 20:07
Show Gist options
  • Select an option

  • Save Juan-Severiano/08a9d5d285323001d387c8f29013dbfa to your computer and use it in GitHub Desktop.

Select an option

Save Juan-Severiano/08a9d5d285323001d387c8f29013dbfa to your computer and use it in GitHub Desktop.
import { env } from "@/config/env";
const DEFAULT_HEADERS: Record<string, string> = {
Accept: "application/json",
};
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export type QueryParams = Record<
string,
string | number | boolean | null | undefined
>;
export interface HttpRequestConfig {
headers?: Record<string, string>;
query?: QueryParams;
signal?: AbortSignal;
credentials?: RequestCredentials;
}
export interface HttpRequestOptions<TBody> extends HttpRequestConfig {
method: HttpMethod;
body?: TBody;
}
export class HttpError<T = unknown> extends Error {
readonly status: number;
readonly data: T;
constructor(status: number, message: string, data: T) {
super(message);
this.status = status;
this.data = data;
}
}
let userToken: string | null = null;
export const setHttpClientUserToken = (token: string | null) => {
userToken = token;
};
export const revalidateApiAccessToken = (token: string | null) => {
setHttpClientUserToken(token);
};
const FALLBACK_BASE_URL = "http://localhost";
const normalizedBaseUrl = (env.VITE_API_URL || FALLBACK_BASE_URL).replace(
/\/$/,
""
);
const BASE_URL = `${normalizedBaseUrl}`.replace(/\/$/, "");
const toSearchParams = (query?: QueryParams) => {
if (!query) {
return "";
}
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
params.set(key, String(value));
});
const serialized = params.toString();
return serialized ? `?${serialized}` : "";
};
const buildUrl = (path: string, query?: QueryParams) => {
const normalizedBase = BASE_URL.replace(/\/$/, "");
const isAbsolute = /^https?:\/\//i.test(path);
if (isAbsolute) {
const url = new URL(path);
if (query) {
const params = new URLSearchParams(url.search);
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
params.set(key, String(value));
});
url.search = params.toString();
}
return url.toString();
}
const formattedPath = path.startsWith("/") ? path : `/${path}`;
const base = normalizedBase || FALLBACK_BASE_URL;
const url = new URL(`${base}${formattedPath}${toSearchParams(query)}`);
return url.toString();
};
const isJsonBody = (
body: unknown
): body is Record<string, unknown> | unknown[] =>
body !== null &&
typeof body === "object" &&
!(body instanceof FormData) &&
!(body instanceof Blob);
async function request<TResponse, TBody = unknown>(
path: string,
{
method,
body,
headers,
query,
signal,
credentials,
}: HttpRequestOptions<TBody>
): Promise<TResponse> {
const url = buildUrl(path, query);
const mergedHeaders = new Headers({
...DEFAULT_HEADERS,
...headers,
});
if (userToken && !mergedHeaders.has("Authorization")) {
mergedHeaders.set("Authorization", `Bearer ${userToken}`);
}
const config: RequestInit = {
method,
signal,
credentials,
headers: mergedHeaders,
};
if (body !== undefined) {
if (body instanceof FormData || body instanceof Blob) {
config.body = body as BodyInit;
} else if (isJsonBody(body)) {
mergedHeaders.set("Content-Type", "application/json");
config.body = JSON.stringify(body);
} else {
config.body = body as BodyInit;
}
}
try {
const response = await fetch(url, config);
const contentType = response.headers.get("content-type") ?? "";
let data: unknown = null;
if (contentType.includes("application/json")) {
data = await response.json();
} else if (contentType.includes("text/")) {
data = await response.text();
}
if (!response.ok) {
const errorMessage =
typeof data === "object" && data && "message" in data
? String((data as { message: unknown }).message)
: response.statusText || "HTTP request failed";
throw new HttpError(response.status, errorMessage, data);
}
return data as TResponse;
} catch (error) {
if (error instanceof HttpError) {
throw error;
}
throw error;
}
}
export interface HttpClient {
get<T>(path: string, config?: HttpRequestConfig): Promise<T>;
post<T, B = unknown>(
path: string,
body?: B,
config?: HttpRequestConfig
): Promise<T>;
put<T, B = unknown>(
path: string,
body?: B,
config?: HttpRequestConfig
): Promise<T>;
patch<T, B = unknown>(
path: string,
body?: B,
config?: HttpRequestConfig
): Promise<T>;
delete<T, B = unknown>(
path: string,
body?: B,
config?: HttpRequestConfig
): Promise<T>;
}
const createHttpClient = (): HttpClient => ({
get: (path, config) => request(path, { method: "GET", ...(config ?? {}) }),
post: (path, body, config) =>
request(path, { method: "POST", body, ...(config ?? {}) }),
put: (path, body, config) =>
request(path, { method: "PUT", body, ...(config ?? {}) }),
patch: (path, body, config) =>
request(path, { method: "PATCH", body, ...(config ?? {}) }),
delete: (path, body, config) =>
request(path, { method: "DELETE", body, ...(config ?? {}) }),
});
export const httpClient = createHttpClient();
export { createHttpClient };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment