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);
  }
}