http-api.ts, logging.ts
base class & loggers for building http api clients
http-api.ts
import { Logger, NoopLogger } from "./logging";
export interface HttpAPIConfig {
base?: string;
auth?: {
bearer?: string;
};
headers?: Record<string, string>;
logger?: Logger;
}
export abstract class HttpAPI {
protected readonly name: string = "base";
protected logger: Logger;
protected config: HttpAPIConfig;
constructor(config: HttpAPIConfig) {
config = config || {};
if (config.base) {
config.base = new URL(config.base).href;
}
if (config.auth && config.auth.bearer) {
config.headers = {
...config.headers,
Authorization: `Bearer ${config.auth.bearer}`,
};
}
this.config = config;
this.logger = config.logger || NoopLogger;
}
/**
* Low-level method to make HTTP requests.
* Uses the URL constructor for robust URL handling.
*/
protected async fetchRaw(
input: string,
init?: RequestInit,
): Promise<Response> {
let url: URL;
try {
// If input is relative and there's no base configured, throw an error.
if (!this.config.base && !/^https?:\/\//.test(input)) {
throw new Error(
"Base URL is not configured, and a relative URL was provided.",
);
}
// If config.base exists, resolve the relative URL against it; otherwise, assume input is an absolute URL.
url = this.config.base
? new URL(input, this.config.base)
: new URL(input);
} catch (err) {
throw new Error(
`Failed to construct URL with input: ${input}. Error: ${err}`,
);
}
const start =
typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
const response = await fetch(url.href, {
...init,
headers: {
...this.config.headers,
...(init?.headers || {}),
},
});
const end =
typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
const duration = end - start;
this.logger.debug(
`HTTP ${response.status} ${url.href} ${duration.toFixed(2)}ms`,
);
return response;
}
/**
* Low-level GET that returns the raw Response.
*/
protected async getRaw(
path: string,
options?: RequestInit,
): Promise<Response> {
this.logger.debug(`GET ${path}`);
return this.fetchRaw(path, {
...options,
method: "GET",
});
}
/**
* Low-level POST that returns the raw Response.
*/
protected async postRaw(
path: string,
body: unknown,
options?: RequestInit,
): Promise<Response> {
this.logger.debug(`POST ${path}`);
return this.fetchRaw(path, {
...options,
method: "POST",
body: JSON.stringify(body),
headers: {
...options?.headers,
"Content-Type": "application/json",
},
});
}
/**
* High-level fetch that automatically parses JSON and handles errors.
*/
protected async fetch<T>(input: string, init?: RequestInit): Promise<T> {
const response = await this.fetchRaw(input, init);
if (!response.ok) {
let errorText: string;
try {
errorText = await response.text();
} catch (err: unknown) {
errorText = err instanceof Error ? err.message : "Unknown error";
}
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
// Handle No Content (204) responses.
if (response.status === 204) {
return null as unknown as T;
}
return (await response.json()) as T;
}
/**
* High-level GET that parses JSON.
*/
protected async get<T>(path: string, options?: RequestInit): Promise<T> {
this.logger.debug(`GET ${path}`);
return this.fetch<T>(path, {
...options,
method: "GET",
});
}
/**
* High-level POST that parses JSON.
*/
protected async post<T>(
path: string,
body: unknown,
options?: RequestInit,
): Promise<T> {
this.logger.debug(`POST ${path}`);
return this.fetch<T>(path, {
...options,
method: "POST",
body: JSON.stringify(body),
headers: {
...options?.headers,
"Content-Type": "application/json",
},
});
}
}
logging.ts
export interface Logger {
info: (...args: Parameters<typeof console.info>) => void;
warn: (...args: Parameters<typeof console.warn>) => void;
error: (...args: Parameters<typeof console.error>) => void;
debug: (...args: Parameters<typeof console.debug>) => void;
}
export const ConsoleLogger: Logger = {
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
};
export const NoopLogger: Logger = {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
info: (...args: Parameters<typeof console.info>) => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
warn: (...args: Parameters<typeof console.warn>) => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
error: (...args: Parameters<typeof console.error>) => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
debug: (...args: Parameters<typeof console.debug>) => {},
};
export class NamespaceLogger implements Logger {
constructor(private readonly namespace: string) {}
info(...args: Parameters<typeof console.info>) {
console.info(`[${this.namespace}]`, ...args);
}
warn(...args: Parameters<typeof console.warn>) {
console.warn(`[${this.namespace}]`, ...args);
}
error(...args: Parameters<typeof console.error>) {
console.error(`[${this.namespace}]`, ...args);
}
debug(...args: Parameters<typeof console.debug>) {
console.debug(`[${this.namespace}]`, ...args);
}
}