Refactor code structure for improved readability and maintainability

This commit is contained in:
Firman Ramdhani
2026-05-22 17:53:35 +07:00
parent af0fe70ac6
commit 4260e240a0
37 changed files with 2915 additions and 74 deletions
@@ -0,0 +1,116 @@
import axios, { type AxiosInstance, type AxiosError } from 'axios';
import type { HttpClientConfig, InterceptorHooks } from './types';
import { noopObservabilityAdapter } from '../observability/noop.adapter';
import { ApiError } from '../errors/api-error';
/**
* Creates an isolated Axios instance with per-app configuration.
*
* **CRITICAL**: This function creates a NEW AxiosInstance on every call.
* It NEVER touches `axios.defaults` or `axios.interceptors`. Each consumer
* receives a fully autonomous client with its own interceptor chain.
*
* @param config - Base configuration (URL, timeout, headers, observability).
* @param hooks - Optional per-app interceptor hooks for auth, error handling, etc.
* @returns A configured, isolated AxiosInstance.
*
* @example
* ```ts
* // apps/web — full auth + telemetry
* const apiClient = createHttpClient(
* { baseURL: 'https://api.eigen.co/v1', observability: otelAdapter },
* {
* onRequest: async (config) => {
* config.headers.Authorization = `Bearer ${getToken()}`;
* return config;
* },
* onResponseError: async (error) => {
* if (error.response?.status === 401) redirect('/login');
* throw error;
* },
* },
* );
*
* // apps/landing — minimal public client
* const publicClient = createHttpClient({
* baseURL: 'https://api.eigen.co/public/v1',
* });
* ```
*/
export function createHttpClient(
config: HttpClientConfig,
hooks?: InterceptorHooks,
): AxiosInstance {
const observability = config.observability ?? noopObservabilityAdapter;
// ── Create isolated instance ──────────────────────────────────
const instance = axios.create({
baseURL: config.baseURL,
timeout: config.timeout ?? 15000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(config.defaultHeaders ?? {}),
},
});
// ── Request Interceptor Chain ─────────────────────────────────
instance.interceptors.request.use(
async (reqConfig) => {
// 1. Observability hook (tracing span start)
// Wrapped in try-catch: adapter failures must never block the request
try {
observability.onRequestStart(reqConfig);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestStart:', adapterError);
}
// 2. App-specific hook (e.g., inject auth token)
if (hooks?.onRequest) {
return hooks.onRequest(reqConfig);
}
return reqConfig;
},
(error) => Promise.reject(error),
);
// ── Response Interceptor Chain ────────────────────────────────
instance.interceptors.response.use(
(response) => {
// 1. Observability hook (tracing span end)
try {
observability.onRequestEnd(response);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestEnd:', adapterError);
}
// 2. App-specific response transform
if (hooks?.onResponse) {
return hooks.onResponse(response);
}
return response;
},
async (error: AxiosError) => {
// 1. Observability hook (tracing error record)
// CRITICAL: Wrapped in try-catch so adapter crashes never
// swallow the original API error from the UI.
try {
observability.onRequestError(error);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestError:', adapterError);
}
// 2. App-specific error handler (e.g., 401 redirect)
if (hooks?.onResponseError) {
return hooks.onResponseError(error);
}
// 3. Default: wrap in structured ApiError
throw ApiError.fromAxiosError(error);
},
);
return instance;
}
@@ -0,0 +1,12 @@
export { createHttpClient } from './create-http-client';
export type {
HttpClientConfig,
InterceptorHooks,
ApiResponse,
TelemetryContext,
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from './types';
+123
View File
@@ -0,0 +1,123 @@
import type {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import type { IObservabilityAdapter } from '../observability/types';
// ─── Factory Configuration ──────────────────────────────────────
/**
* Configuration for creating an isolated HTTP client instance.
* Each app provides its own config — no globals are shared.
*/
export interface HttpClientConfig {
/** Base URL for all requests (e.g., 'https://api.eigen.co/v1'). */
baseURL: string;
/** Default request timeout in milliseconds. @default 15000 */
timeout?: number;
/** Default headers applied to every outgoing request. */
defaultHeaders?: Record<string, string>;
/**
* Observability adapter for tracing, logging, and metrics.
* If not provided, a zero-overhead No-Op adapter is used.
*/
observability?: IObservabilityAdapter;
}
// ─── Interceptor Hooks ──────────────────────────────────────────
/**
* Per-app hooks for customizing request/response behavior.
*
* These hooks are the app's "autonomy layer" — each app decides
* how to inject tokens, handle 401s, transform responses, etc.
*/
export interface InterceptorHooks {
/**
* Called before every request is dispatched.
* Use this to inject authentication tokens, tenant headers, etc.
*/
onRequest?: (
config: InternalAxiosRequestConfig,
) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
/**
* Called on every successful response (2xx status).
* Use this to normalize response shapes if needed.
*/
onResponse?: (response: AxiosResponse) => AxiosResponse;
/**
* Called on every failed response (non-2xx or network error).
* Use this for app-specific error handling (e.g., redirect on 401).
* MUST throw or return a rejected promise.
*/
onResponseError?: (error: AxiosError) => Promise<never>;
}
// ─── Standardized API Response ──────────────────────────────────
/**
* Type-safe API response wrapper.
*
* Replaces the legacy `ResponseEntity` and the lost-in-callback
* `Promise<void>` return type with a fully typed contract.
*/
export interface ApiResponse<T = unknown> {
/** The parsed response body. */
data: T;
/** The HTTP status code. */
status: number;
}
// ─── Per-Request Telemetry Context ──────────────────────────────
/**
* Advanced escape hatch for per-request telemetry enrichment.
*
* Attach this to any request to push custom spans, tags, or
* business events into the observability pipeline.
*
* @example
* ```ts
* await bookingServices.getMany({
* telemetryContext: {
* customSpanName: 'booking.list.fetch',
* tags: { region: 'asia', priority: 'high' },
* pushEventOnSuccess: 'booking_list_loaded',
* },
* });
* ```
*/
export interface TelemetryContext {
/** Custom keys/tags to enrich the Faro log/error or OTel Span. */
tags?: Record<string, string | number | boolean>;
/** If provided, manually starts a custom OTel span wrapping this request. */
customSpanName?: string;
/** Force an explicit business event to be pushed to Faro on success. */
pushEventOnSuccess?: string;
}
// ─── Re-export Axios types consumers frequently need ────────────
export type {
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from 'axios';
// ─── Augment Axios to carry TelemetryContext ────────────────────
declare module 'axios' {
interface AxiosRequestConfig {
/** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext;
}
}