Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user