Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import type {
|
||||
BaseEntity,
|
||||
ApiURLMap,
|
||||
RequestMethodMap,
|
||||
RequestDescriptor,
|
||||
ExecuteOptions,
|
||||
DataServicesConfig,
|
||||
} from './types';
|
||||
import type { ApiResponse } from '../http-client/types';
|
||||
import { interpolateUrl } from './url-builder';
|
||||
import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
|
||||
|
||||
/**
|
||||
* Abstract base class for remote data services.
|
||||
*
|
||||
* Provides a generic `execute()` method that eliminates the 22
|
||||
* near-identical methods from the legacy `BaseRemoteDataServices`.
|
||||
* Each operation is reduced to a one-liner calling `execute()`
|
||||
* with the appropriate descriptor.
|
||||
*
|
||||
* **Key architectural differences from legacy:**
|
||||
* - Receives an **injected** AxiosInstance (no global axios import)
|
||||
* - Returns `Promise<ApiResponse<T>>` (no callback-based onSuccess/onFailed)
|
||||
* - All operations are fully typed end-to-end
|
||||
* - Includes `customRequest<T>()` as an escape hatch for non-standard endpoints
|
||||
*
|
||||
* @typeParam E - The domain entity type (must extend BaseEntity)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class BookingDataServices extends BaseRemoteDataServices<BookingEntity> {}
|
||||
*
|
||||
* const services = new BookingDataServices(apiClient, {
|
||||
* apiUrl: '/bookings',
|
||||
* moduleKey: 'BOOKING',
|
||||
* });
|
||||
*
|
||||
* const { data, status } = await services.getOne<BookingEntity>('42');
|
||||
* ```
|
||||
*/
|
||||
export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity> {
|
||||
/** The injected, isolated HTTP client instance. */
|
||||
protected readonly httpClient: AxiosInstance;
|
||||
|
||||
/** Resolved URL map for all operations. */
|
||||
protected readonly urls: ApiURLMap;
|
||||
|
||||
/** Resolved HTTP method map for all operations. */
|
||||
protected readonly methods: RequestMethodMap;
|
||||
|
||||
/** Module key for the 'ex-module-key' audit header. */
|
||||
protected readonly moduleKey: string | undefined;
|
||||
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
|
||||
this.httpClient = httpClient;
|
||||
this.moduleKey = config.moduleKey;
|
||||
|
||||
this.urls = {
|
||||
...makeDefaultURLs(config.apiUrl ?? ''),
|
||||
...(config.urls ?? {}),
|
||||
};
|
||||
|
||||
this.methods = {
|
||||
...DEFAULT_METHODS,
|
||||
...(config.methods ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Generic Executor ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The single generic request executor.
|
||||
*
|
||||
* All standard operations delegate to this method with a
|
||||
* pre-defined descriptor. This is the engine that replaces
|
||||
* 22 near-identical legacy methods.
|
||||
*
|
||||
* @typeParam T - Expected response data type
|
||||
* @param descriptor - Defines which URL, method, and action to use
|
||||
* @param options - Dynamic URL params and additional Axios config
|
||||
* @returns Typed API response with data and status
|
||||
*/
|
||||
protected async execute<T = unknown>(
|
||||
descriptor: RequestDescriptor,
|
||||
options?: ExecuteOptions,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { urlKey, methodKey, action } = descriptor;
|
||||
|
||||
const response = await this.httpClient.request<T>({
|
||||
url: interpolateUrl(this.urls[urlKey], options?.variableURL),
|
||||
method: this.methods[methodKey],
|
||||
...(options?.config ?? {}),
|
||||
headers: {
|
||||
...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}),
|
||||
'ex-module-action': action,
|
||||
...(options?.config?.headers ?? {}),
|
||||
},
|
||||
telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext // FIXME,
|
||||
});
|
||||
|
||||
return { data: response.data, status: response.status };
|
||||
}
|
||||
|
||||
// ─── Escape Hatch ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a fully custom request that doesn't fit standard CRUD.
|
||||
*
|
||||
* Use this for non-standard endpoints like `/calculate-tax`,
|
||||
* custom aggregations, or third-party integrations.
|
||||
*
|
||||
* The request still flows through the injected httpClient, so
|
||||
* all interceptors (auth, observability, error handling) are
|
||||
* preserved automatically.
|
||||
*
|
||||
* @typeParam T - Expected response data type
|
||||
* @param config - Complete Axios request configuration
|
||||
* @returns Typed API response with data and status
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const tax = await services.customRequest<TaxResult>({
|
||||
* url: '/bookings/42/calculate-tax',
|
||||
* method: 'POST',
|
||||
* data: { items: [...] },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async customRequest<T = unknown>(
|
||||
config: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const response = await this.httpClient.request<T>({
|
||||
...config,
|
||||
headers: {
|
||||
...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}),
|
||||
...(config.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { data: response.data, status: response.status };
|
||||
}
|
||||
|
||||
// ─── CRUD Operations ───────────────────────────────────────────
|
||||
|
||||
/** Fetch a paginated list of entities. */
|
||||
getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.execute<T>(DESCRIPTORS.getMany, { config });
|
||||
}
|
||||
|
||||
/** Fetch a single entity by ID. */
|
||||
getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.execute<T>(DESCRIPTORS.getOne, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new entity. */
|
||||
create<T = E>(data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.execute<T>(DESCRIPTORS.create, {
|
||||
config: { ...config, data },
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing entity by ID. */
|
||||
edit<T = E>(id: string, data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.execute<T>(DESCRIPTORS.edit, {
|
||||
variableURL: { id },
|
||||
config: { ...config, data },
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a single entity by ID. */
|
||||
delete(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.delete, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete multiple entities by IDs. */
|
||||
batchDelete(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchDelete, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Activation Lifecycle ─────────────────────────────────────
|
||||
|
||||
/** Activate a single entity. */
|
||||
activate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.activate, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Activate multiple entities. */
|
||||
batchActivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchActivate, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Deactivate a single entity. */
|
||||
deactivate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.deactivate, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Deactivate multiple entities. */
|
||||
batchDeactivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Data Processing Lifecycle ────────────────────────────────
|
||||
|
||||
/** Confirm processing of a single data record. */
|
||||
confirmProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.confirmProcessData, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirm processing of multiple data records. */
|
||||
batchConfirmProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchConfirmProcessData, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel processing of a single data record. */
|
||||
cancelProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.cancelProcessData, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel processing of multiple data records. */
|
||||
batchCancelProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchCancelProcessData, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Transaction Lifecycle ────────────────────────────────────
|
||||
|
||||
/** Confirm a transaction. */
|
||||
confirmProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.confirmProcessTransaction, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirm multiple transactions. */
|
||||
batchConfirmProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchConfirmProcessTransaction, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel a transaction. */
|
||||
cancelProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.cancelProcessTransaction, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel multiple transactions. */
|
||||
batchCancelProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchCancelProcessTransaction, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Rollback a transaction. */
|
||||
rollbackProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.rollbackProcessTransaction, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Rollback multiple transactions. */
|
||||
batchRollbackProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchRollbackProcessTransaction, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Hold a transaction. */
|
||||
holdProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.holdProcessTransaction, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
/** Hold multiple transactions. */
|
||||
batchHoldProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||
return this.execute<void>(DESCRIPTORS.batchHoldProcessTransaction, {
|
||||
config: { ...config, data: { ids } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BaseEntity } from './types';
|
||||
import { BaseRemoteDataServices } from './base-remote.data-services';
|
||||
|
||||
/**
|
||||
* General-purpose remote data services.
|
||||
*
|
||||
* A concrete, non-abstract version of BaseRemoteDataServices that
|
||||
* can be instantiated directly for standard CRUD modules that don't
|
||||
* need additional custom methods.
|
||||
*
|
||||
* For modules requiring domain-specific operations beyond standard
|
||||
* CRUD + lifecycle, extend BaseRemoteDataServices instead and add
|
||||
* custom methods using `this.execute()` or `this.customRequest()`.
|
||||
*
|
||||
* @typeParam E - The domain entity type
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Direct instantiation for standard modules
|
||||
* const bookingServices = new CommonRemoteDataServices<BookingEntity>(
|
||||
* apiClient,
|
||||
* { apiUrl: '/bookings', moduleKey: 'BOOKING' },
|
||||
* );
|
||||
*
|
||||
* const { data } = await bookingServices.getMany();
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // For modules needing custom operations, extend the base:
|
||||
* class InvoiceDataServices extends BaseRemoteDataServices<InvoiceEntity> {
|
||||
* async calculateTax(invoiceId: string) {
|
||||
* return this.customRequest<TaxResult>({
|
||||
* url: `/invoices/${invoiceId}/calculate-tax`,
|
||||
* method: 'POST',
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CommonRemoteDataServices<
|
||||
E extends BaseEntity = BaseEntity,
|
||||
> extends BaseRemoteDataServices<E> {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { RequestMethodMap, RequestDescriptor, ApiURLMap } from './types';
|
||||
|
||||
// ─── Request Actions ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Constants for the 'ex-module-action' header.
|
||||
* Maps to backend permission/audit checks.
|
||||
*/
|
||||
export const REQUEST_ACTION = {
|
||||
VIEW: 'VIEW',
|
||||
CREATE: 'CREATE',
|
||||
EDIT: 'EDIT',
|
||||
DELETE: 'DELETE',
|
||||
CONFIRM_DATA: 'CONFIRM_DATA',
|
||||
CANCEL_DATA: 'CANCEL_DATA',
|
||||
CONFIRM_PROCESS_TRANSACTION: 'CONFIRM_PROCESS_TRANSACTION',
|
||||
CANCEL_PROCESS_TRANSACTION: 'CANCEL_PROCESS_TRANSACTION',
|
||||
} as const;
|
||||
|
||||
// ─── Default HTTP Methods ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sensible REST defaults for all operations.
|
||||
* Can be overridden per data-services instance.
|
||||
*/
|
||||
export const DEFAULT_METHODS: RequestMethodMap = {
|
||||
getManyMethod: 'GET',
|
||||
getOneMethod: 'GET',
|
||||
createMethod: 'POST',
|
||||
editMethod: 'PUT',
|
||||
deleteMethod: 'DELETE',
|
||||
batchDeleteMethod: 'POST',
|
||||
|
||||
activateMethod: 'PATCH',
|
||||
batchActivateMethod: 'POST',
|
||||
deactivateMethod: 'PATCH',
|
||||
batchDeactivateMethod: 'POST',
|
||||
|
||||
confirmProcessDataMethod: 'PATCH',
|
||||
batchConfirmProcessDataMethod: 'POST',
|
||||
cancelProcessDataMethod: 'PATCH',
|
||||
batchCancelProcessDataMethod: 'POST',
|
||||
|
||||
confirmProcessTransactionMethod: 'PATCH',
|
||||
batchConfirmProcessTransactionMethod: 'POST',
|
||||
cancelProcessTransactionMethod: 'PATCH',
|
||||
batchCancelProcessTransactionMethod: 'POST',
|
||||
rollbackProcessTransactionMethod: 'PATCH',
|
||||
batchRollbackProcessTransactionMethod: 'POST',
|
||||
holdProcessTransactionMethod: 'PATCH',
|
||||
batchHoldProcessTransactionMethod: 'POST',
|
||||
};
|
||||
|
||||
// ─── Operation Descriptors ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-defined descriptors for all standard operations.
|
||||
* Each descriptor maps an operation to its URL key, method key,
|
||||
* and action header — eliminating 22 boilerplate methods.
|
||||
*/
|
||||
export const DESCRIPTORS = {
|
||||
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
|
||||
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
|
||||
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
|
||||
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
|
||||
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
|
||||
batchDelete: { urlKey: 'batchDeleteUrl', methodKey: 'batchDeleteMethod', action: REQUEST_ACTION.DELETE },
|
||||
|
||||
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
||||
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
||||
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
||||
batchDeactivate: { urlKey: 'batchDeactivateUrl', methodKey: 'batchDeactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
||||
|
||||
confirmProcessData: { urlKey: 'confirmProcessDataUrl', methodKey: 'confirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
||||
batchConfirmProcessData: { urlKey: 'batchConfirmProcessDataUrl', methodKey: 'batchConfirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
||||
cancelProcessData: { urlKey: 'cancelProcessDataUrl', methodKey: 'cancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
|
||||
batchCancelProcessData: { urlKey: 'batchCancelProcessDataUrl', methodKey: 'batchCancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
|
||||
|
||||
confirmProcessTransaction: { urlKey: 'confirmProcessTransactionUrl', methodKey: 'confirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
||||
batchConfirmProcessTransaction: { urlKey: 'batchConfirmProcessTransactionUrl', methodKey: 'batchConfirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
||||
cancelProcessTransaction: { urlKey: 'cancelProcessTransactionUrl', methodKey: 'cancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
|
||||
batchCancelProcessTransaction: { urlKey: 'batchCancelProcessTransactionUrl', methodKey: 'batchCancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
|
||||
rollbackProcessTransaction: { urlKey: 'rollbackProcessTransactionUrl', methodKey: 'rollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
||||
batchRollbackProcessTransaction: { urlKey: 'batchRollbackProcessTransactionUrl', methodKey: 'batchRollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
||||
holdProcessTransaction: { urlKey: 'holdProcessTransactionUrl', methodKey: 'holdProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
||||
batchHoldProcessTransaction: { urlKey: 'batchHoldProcessTransactionUrl', methodKey: 'batchHoldProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
||||
} as const satisfies Record<string, RequestDescriptor>;
|
||||
|
||||
// ─── Default URL Factory ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates the complete API URL map from a base path.
|
||||
*
|
||||
* @param apiUrl - Base API path (e.g., '/bookings')
|
||||
* @returns Full ApiURLMap with all CRUD + lifecycle URLs
|
||||
*/
|
||||
export function makeDefaultURLs(apiUrl: string): ApiURLMap {
|
||||
return {
|
||||
getManyUrl: `${apiUrl}`,
|
||||
getOneUrl: `${apiUrl}/:id`,
|
||||
createUrl: `${apiUrl}`,
|
||||
editUrl: `${apiUrl}/:id`,
|
||||
|
||||
deleteUrl: `${apiUrl}/:id`,
|
||||
batchDeleteUrl: `${apiUrl}/batch-delete`,
|
||||
|
||||
activateUrl: `${apiUrl}/:id/active`,
|
||||
batchActivateUrl: `${apiUrl}/batch-active`,
|
||||
deactivateUrl: `${apiUrl}/:id/inactive`,
|
||||
batchDeactivateUrl: `${apiUrl}/batch-inactive`,
|
||||
|
||||
confirmProcessDataUrl: `${apiUrl}/:id/confirm`,
|
||||
batchConfirmProcessDataUrl: `${apiUrl}/batch-confirm`,
|
||||
cancelProcessDataUrl: `${apiUrl}/:id/cancel`,
|
||||
batchCancelProcessDataUrl: `${apiUrl}/batch-cancel`,
|
||||
|
||||
confirmProcessTransactionUrl: `${apiUrl}/:id/confirm-data`,
|
||||
batchConfirmProcessTransactionUrl: `${apiUrl}/batch-confirm-data`,
|
||||
cancelProcessTransactionUrl: `${apiUrl}/:id/cancel`,
|
||||
batchCancelProcessTransactionUrl: `${apiUrl}/batch-cancel`,
|
||||
rollbackProcessTransactionUrl: `${apiUrl}/:id/confirm-rollback`,
|
||||
batchRollbackProcessTransactionUrl: `${apiUrl}/batch-confirm-rollback`,
|
||||
holdProcessTransactionUrl: `${apiUrl}/:id/confirm-hold`,
|
||||
batchHoldProcessTransactionUrl: `${apiUrl}/batch-confirm-hold`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// ─── Classes ────────────────────────────────────────────────────
|
||||
export { BaseRemoteDataServices } from './base-remote.data-services';
|
||||
export { CommonRemoteDataServices } from './common-remote.data-services';
|
||||
|
||||
// ─── Utilities ──────────────────────────────────────────────────
|
||||
export { interpolateUrl } from './url-builder';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────
|
||||
export { REQUEST_ACTION, DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
export type {
|
||||
BaseEntity,
|
||||
ApiURLMap,
|
||||
RequestMethodMap,
|
||||
RequestDescriptor,
|
||||
ExecuteOptions,
|
||||
DataServicesConfig,
|
||||
} from './types';
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { TelemetryContext } from '../http-client/types';
|
||||
|
||||
// ─── Base Entity ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal entity contract. All domain entities must have
|
||||
* an optional `id` field for CRUD operations.
|
||||
*/
|
||||
export interface BaseEntity {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// ─── API URL Map ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Complete URL map for all standard CRUD and lifecycle operations.
|
||||
* Each key maps to a URL template string (e.g., '/bookings/:id').
|
||||
*/
|
||||
export interface ApiURLMap {
|
||||
getManyUrl: string;
|
||||
getOneUrl: string;
|
||||
createUrl: string;
|
||||
editUrl: string;
|
||||
deleteUrl: string;
|
||||
batchDeleteUrl: string;
|
||||
|
||||
activateUrl: string;
|
||||
batchActivateUrl: string;
|
||||
deactivateUrl: string;
|
||||
batchDeactivateUrl: string;
|
||||
|
||||
confirmProcessDataUrl: string;
|
||||
batchConfirmProcessDataUrl: string;
|
||||
cancelProcessDataUrl: string;
|
||||
batchCancelProcessDataUrl: string;
|
||||
|
||||
confirmProcessTransactionUrl: string;
|
||||
batchConfirmProcessTransactionUrl: string;
|
||||
cancelProcessTransactionUrl: string;
|
||||
batchCancelProcessTransactionUrl: string;
|
||||
rollbackProcessTransactionUrl: string;
|
||||
batchRollbackProcessTransactionUrl: string;
|
||||
holdProcessTransactionUrl: string;
|
||||
batchHoldProcessTransactionUrl: string;
|
||||
}
|
||||
|
||||
// ─── HTTP Method Map ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* HTTP method overrides for each operation.
|
||||
* Defaults to sensible REST conventions (GET, POST, PUT, DELETE, PATCH).
|
||||
*/
|
||||
export interface RequestMethodMap {
|
||||
getManyMethod: string;
|
||||
getOneMethod: string;
|
||||
createMethod: string;
|
||||
editMethod: string;
|
||||
deleteMethod: string;
|
||||
batchDeleteMethod: string;
|
||||
|
||||
activateMethod: string;
|
||||
batchActivateMethod: string;
|
||||
deactivateMethod: string;
|
||||
batchDeactivateMethod: string;
|
||||
|
||||
confirmProcessDataMethod: string;
|
||||
batchConfirmProcessDataMethod: string;
|
||||
cancelProcessDataMethod: string;
|
||||
batchCancelProcessDataMethod: string;
|
||||
|
||||
confirmProcessTransactionMethod: string;
|
||||
batchConfirmProcessTransactionMethod: string;
|
||||
cancelProcessTransactionMethod: string;
|
||||
batchCancelProcessTransactionMethod: string;
|
||||
rollbackProcessTransactionMethod: string;
|
||||
batchRollbackProcessTransactionMethod: string;
|
||||
holdProcessTransactionMethod: string;
|
||||
batchHoldProcessTransactionMethod: string;
|
||||
}
|
||||
|
||||
// ─── Request Descriptor ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Describes a single operation in terms of its URL, method, and
|
||||
* action header. Used by the generic `execute()` method.
|
||||
*/
|
||||
export interface RequestDescriptor {
|
||||
/** Key into the ApiURLMap. */
|
||||
urlKey: keyof ApiURLMap;
|
||||
/** Key into the RequestMethodMap. */
|
||||
methodKey: keyof RequestMethodMap;
|
||||
/** Value for the 'ex-module-action' header. */
|
||||
action: string;
|
||||
}
|
||||
|
||||
// ─── Execute Options ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Options passed to the generic `execute()` method.
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
/** Dynamic URL parameters (e.g., `{ id: '42' }`). */
|
||||
variableURL?: Record<string, string>;
|
||||
/** Additional Axios request config (params, data, headers, etc). */
|
||||
config?: AxiosRequestConfig;
|
||||
/** Per-request telemetry context for custom spans, tags, events. */
|
||||
telemetryContext?: TelemetryContext;
|
||||
}
|
||||
|
||||
// ─── Data Services Constructor ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Configuration for constructing a BaseRemoteDataServices instance.
|
||||
*/
|
||||
export interface DataServicesConfig {
|
||||
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
|
||||
apiUrl?: string;
|
||||
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
|
||||
moduleKey?: string;
|
||||
/** Override specific URL templates. */
|
||||
urls?: Partial<ApiURLMap>;
|
||||
/** Override specific HTTP methods. */
|
||||
methods?: Partial<RequestMethodMap>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Interpolates dynamic URL parameters.
|
||||
*
|
||||
* Replaces `:paramName` segments in a URL template with values
|
||||
* from the provided variables object.
|
||||
*
|
||||
* @param template - URL template (e.g., '/bookings/:id/confirm')
|
||||
* @param variables - Key-value map of parameter names to values
|
||||
* @returns The interpolated URL string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interpolateUrl('/bookings/:id/confirm', { id: '42' });
|
||||
* // => '/bookings/42/confirm'
|
||||
*
|
||||
* interpolateUrl('/orgs/:orgId/users/:userId', { orgId: 'a', userId: 'b' });
|
||||
* // => '/orgs/a/users/b'
|
||||
* ```
|
||||
*/
|
||||
export function interpolateUrl(
|
||||
template: string,
|
||||
variables?: Record<string, string>,
|
||||
): string {
|
||||
if (!variables || Object.keys(variables).length === 0) {
|
||||
return template;
|
||||
}
|
||||
|
||||
return template
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
if (segment.startsWith(':')) {
|
||||
const key = segment.slice(1);
|
||||
const value = variables[key];
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`,
|
||||
);
|
||||
}
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
return segment;
|
||||
})
|
||||
.join('/');
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { AxiosError, type AxiosResponse } from 'axios';
|
||||
import { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
|
||||
|
||||
/**
|
||||
* Structured API error that normalizes Axios errors into a
|
||||
* predictable, serializable format.
|
||||
*
|
||||
* Replaces the legacy `ErrorRequest` class with richer metadata.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* try {
|
||||
* await apiClient.get('/users');
|
||||
* } catch (err) {
|
||||
* if (err instanceof ApiError) {
|
||||
* console.log(err.code); // ApiErrorCode.UNAUTHORIZED
|
||||
* console.log(err.status); // 401
|
||||
* console.log(err.data); // { message: "Token expired" }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
/** Structured error code for programmatic handling. */
|
||||
readonly code: ApiErrorCode;
|
||||
|
||||
/** HTTP status code (0 if no response, e.g., network error). */
|
||||
readonly status: number;
|
||||
|
||||
/** Raw response body from the server, if available. */
|
||||
readonly data: unknown;
|
||||
|
||||
/** The original Axios error, preserved for debugging. */
|
||||
readonly cause: AxiosError | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiErrorCode,
|
||||
status: number,
|
||||
data?: unknown,
|
||||
cause?: AxiosError,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
this.cause = cause;
|
||||
|
||||
// Maintain proper prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, ApiError.prototype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: creates an ApiError from an AxiosError.
|
||||
* Automatically resolves the error code from the HTTP status.
|
||||
*/
|
||||
static fromAxiosError(error: AxiosError<unknown>): ApiError {
|
||||
// Network error (no response received)
|
||||
if (!error.response) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return new ApiError(
|
||||
'Request timed out',
|
||||
ApiErrorCode.TIMEOUT,
|
||||
0,
|
||||
undefined,
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return new ApiError(
|
||||
'Request was cancelled',
|
||||
ApiErrorCode.CANCELLED,
|
||||
0,
|
||||
undefined,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return new ApiError(
|
||||
error.message || 'Network error',
|
||||
ApiErrorCode.NETWORK_ERROR,
|
||||
0,
|
||||
undefined,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Server responded with an error status
|
||||
const response: AxiosResponse = error.response;
|
||||
const status = response.status;
|
||||
const data = response.data;
|
||||
const code = httpStatusToErrorCode(status);
|
||||
|
||||
// Extract message from common server response formats
|
||||
const serverMessage =
|
||||
(data && typeof data === 'object' && 'message' in data)
|
||||
? String((data as Record<string, unknown>).message)
|
||||
: `Request failed with status ${status}`;
|
||||
|
||||
return new ApiError(serverMessage, code, status, data, error);
|
||||
}
|
||||
|
||||
/** Convenience check for authentication failures. */
|
||||
get isUnauthorized(): boolean {
|
||||
return this.code === ApiErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
|
||||
/** Convenience check for permission failures. */
|
||||
get isForbidden(): boolean {
|
||||
return this.code === ApiErrorCode.FORBIDDEN;
|
||||
}
|
||||
|
||||
/** Convenience check for network/connectivity issues. */
|
||||
get isNetworkError(): boolean {
|
||||
return this.code === ApiErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
/** JSON-serializable representation for logging/telemetry. */
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
status: this.status,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Enumeration of well-known API error codes.
|
||||
*
|
||||
* Use these to programmatically handle specific server responses
|
||||
* without relying on magic strings scattered across the codebase.
|
||||
*/
|
||||
export enum ApiErrorCode {
|
||||
// ─── HTTP Standard ────────────────────────────────────────────
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
CONFLICT = 'CONFLICT',
|
||||
UNPROCESSABLE_ENTITY = 'UNPROCESSABLE_ENTITY',
|
||||
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
||||
|
||||
// ─── Client-side ──────────────────────────────────────────────
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
CANCELLED = 'CANCELLED',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps HTTP status codes to ApiErrorCode enum values.
|
||||
*/
|
||||
export function httpStatusToErrorCode(status: number): ApiErrorCode {
|
||||
switch (status) {
|
||||
case 400: return ApiErrorCode.BAD_REQUEST;
|
||||
case 401: return ApiErrorCode.UNAUTHORIZED;
|
||||
case 403: return ApiErrorCode.FORBIDDEN;
|
||||
case 404: return ApiErrorCode.NOT_FOUND;
|
||||
case 409: return ApiErrorCode.CONFLICT;
|
||||
case 422: return ApiErrorCode.UNPROCESSABLE_ENTITY;
|
||||
case 429: return ApiErrorCode.TOO_MANY_REQUESTS;
|
||||
case 500: return ApiErrorCode.INTERNAL_SERVER_ERROR;
|
||||
case 503: return ApiErrorCode.SERVICE_UNAVAILABLE;
|
||||
default: return ApiErrorCode.UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ApiError } from './api-error';
|
||||
export { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { IObservabilityAdapter } from './types';
|
||||
export { noopObservabilityAdapter } from './noop.adapter';
|
||||
export { faroAdapter } from './otel.adapter';
|
||||
export { initTelemetry, getFaro } from './setup';
|
||||
export type { TelemetryConfig } from './setup';
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IObservabilityAdapter } from './types';
|
||||
|
||||
/**
|
||||
* No-Op Observability Adapter.
|
||||
*
|
||||
* Default adapter when no telemetry is configured.
|
||||
* All methods are empty — V8's TurboFan JIT compiler will inline
|
||||
* and dead-code-eliminate these calls during optimization,
|
||||
* resulting in effectively ZERO runtime overhead.
|
||||
*
|
||||
* Used by apps that don't need APM (e.g., `apps/landing`).
|
||||
*/
|
||||
export const noopObservabilityAdapter: IObservabilityAdapter = {
|
||||
onRequestStart() {},
|
||||
onRequestEnd() {},
|
||||
onRequestError() {},
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { trace, SpanStatusCode, type Span } from '@opentelemetry/api';
|
||||
import { LogLevel } from '@grafana/faro-web-sdk';
|
||||
import type { IObservabilityAdapter } from './types';
|
||||
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||
import type { TelemetryContext } from '../http-client/types';
|
||||
import { getFaro } from './setup';
|
||||
|
||||
// ─── Symbol Keys ────────────────────────────────────────────────
|
||||
|
||||
/** Symbol-keyed storage for custom spans on the Axios config. */
|
||||
const CUSTOM_SPAN_KEY = Symbol('__customOtelSpan');
|
||||
|
||||
function attachSpan(config: InternalAxiosRequestConfig, span: Span): void {
|
||||
(config as unknown as Record<symbol, Span>)[CUSTOM_SPAN_KEY] = span;
|
||||
}
|
||||
|
||||
function getSpan(config: unknown): Span | undefined {
|
||||
if (!config) return undefined;
|
||||
return (config as Record<symbol, Span>)?.[CUSTOM_SPAN_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely close a span, guarding against double-close.
|
||||
* After ending, removes the reference from the config to prevent
|
||||
* duplicate Span ID errors on potential Axios retries.
|
||||
*/
|
||||
function safeEndSpan(config: unknown, span: Span): void {
|
||||
span.end();
|
||||
// Detach from config to prevent double-close on retry
|
||||
if (config) {
|
||||
delete (config as Record<symbol, unknown>)[CUSTOM_SPAN_KEY];
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract TelemetryContext from an Axios config. */
|
||||
function getTelemetryContext(config: unknown): TelemetryContext | undefined {
|
||||
if (!config) return undefined;
|
||||
return (config as Record<string, unknown>)?.telemetryContext as TelemetryContext | undefined;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Convert TelemetryContext tags to a string record for Faro context. */
|
||||
function tagsToFaroContext(tags?: Record<string, string | number | boolean>): Record<string, string> {
|
||||
if (!tags) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(tags).map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the standardized base context used by ALL Faro pushLog/pushError calls.
|
||||
* Ensures `module.key`, `module.action`, and tags are always at the top-level
|
||||
* `context` object — making them directly queryable in LogQL (Loki).
|
||||
*/
|
||||
function buildBaseContext(
|
||||
method: string,
|
||||
url: string,
|
||||
moduleKey?: string,
|
||||
action?: string,
|
||||
tags?: Record<string, string | number | boolean>,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
'http.method': method,
|
||||
'http.url': url,
|
||||
...(moduleKey ? { 'module.key': moduleKey } : {}),
|
||||
...(action ? { 'module.action': action } : {}),
|
||||
...tagsToFaroContext(tags),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Adapter ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Production-grade Observability Adapter.
|
||||
*
|
||||
* Strategy: **Opt-In Custom Spans + Faro/Loki Baseline Logging**
|
||||
*
|
||||
* `trace.getActiveSpan()` returns `undefined` inside Axios interceptors
|
||||
* due to browser XHR/Fetch lifecycle race conditions with Faro's
|
||||
* `TracingInstrumentation`. Therefore this adapter does NOT attempt
|
||||
* to enrich auto-instrumented spans.
|
||||
*
|
||||
* Instead it focuses on two responsibilities:
|
||||
*
|
||||
* 1. **Custom Span Mode** (opt-in via `telemetryContext.customSpanName`):
|
||||
* Creates an explicit OTel span, attaches business tags, and ensures
|
||||
* the span is ALWAYS closed — even on abort, timeout, or unexpected
|
||||
* errors — to prevent span leaks.
|
||||
*
|
||||
* 2. **Faro/Loki Baseline** (always):
|
||||
* Pushes rich contextual logs (`pushLog`), errors (`pushError`), and
|
||||
* success events (`pushEvent`) with standardized `baseContext` for
|
||||
* direct LogQL queryability.
|
||||
*
|
||||
* Safety guarantees:
|
||||
* - Spans are always closed via `safeEndSpan()` which detaches the
|
||||
* reference after closing, preventing double-close on Axios retries.
|
||||
* - Adapter errors are caught internally and never swallowed — the
|
||||
* original API error always propagates to the UI.
|
||||
* - `null`/`undefined` config guards prevent crashes on network timeouts
|
||||
* where `error.config` may be undefined.
|
||||
*/
|
||||
export const faroAdapter: IObservabilityAdapter = {
|
||||
onRequestStart(config: InternalAxiosRequestConfig) {
|
||||
const ctx = getTelemetryContext(config);
|
||||
const method = (config.method ?? 'UNKNOWN').toUpperCase();
|
||||
const url = config.url ?? '/';
|
||||
const moduleKey = config.headers?.['ex-module-key'] as string | undefined;
|
||||
const action = config.headers?.['ex-module-action'] as string | undefined;
|
||||
|
||||
// ── 1. Create optional custom span ──────────────────────────
|
||||
if (ctx?.customSpanName) {
|
||||
const tracer = trace.getTracer('@repo/core-api', '1.0.0');
|
||||
const span = tracer.startSpan(ctx.customSpanName, {
|
||||
attributes: {
|
||||
'http.method': method,
|
||||
'http.url': url,
|
||||
...(moduleKey ? { 'custom.module_key': moduleKey } : {}),
|
||||
...(action ? { 'custom.module_action': action } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Attach custom tags with `custom.` prefix
|
||||
if (ctx.tags) {
|
||||
for (const [key, value] of Object.entries(ctx.tags)) {
|
||||
span.setAttribute(`custom.${key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
attachSpan(config, span);
|
||||
}
|
||||
|
||||
// ── 2. Push structured log (Loki) ───────────────────────────
|
||||
const faro = getFaro();
|
||||
if (faro) {
|
||||
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
|
||||
faro.api.pushLog(
|
||||
[`[core-api] ${method} ${url}`],
|
||||
{ level: LogLevel.DEBUG, context: baseContext },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
onRequestEnd(response: AxiosResponse) {
|
||||
const ctx = getTelemetryContext(response.config);
|
||||
const moduleKey = response.config?.headers?.['ex-module-key'] as string | undefined;
|
||||
const action = response.config?.headers?.['ex-module-action'] as string | undefined;
|
||||
|
||||
// ── 1. Close custom span (OK) ───────────────────────────────
|
||||
const customSpan = getSpan(response.config);
|
||||
if (customSpan) {
|
||||
customSpan.setAttribute('http.status_code', response.status);
|
||||
customSpan.setStatus({ code: SpanStatusCode.OK });
|
||||
safeEndSpan(response.config, customSpan);
|
||||
}
|
||||
|
||||
// ── 2. Push success event (Faro) ────────────────────────────
|
||||
if (ctx?.pushEventOnSuccess) {
|
||||
const faro = getFaro();
|
||||
if (faro) {
|
||||
const method = (response.config?.method ?? 'UNKNOWN').toUpperCase();
|
||||
const url = response.config?.url ?? '/';
|
||||
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx.tags);
|
||||
faro.api.pushEvent(ctx.pushEventOnSuccess, {
|
||||
...baseContext,
|
||||
'http.status_code': String(response.status),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onRequestError(error: AxiosError) {
|
||||
const ctx = getTelemetryContext(error.config);
|
||||
const status = error.response?.status ?? 0;
|
||||
const method = (error.config?.method ?? 'UNKNOWN').toUpperCase();
|
||||
const url = error.config?.url ?? '/';
|
||||
const moduleKey = error.config?.headers?.['ex-module-key'] as string | undefined;
|
||||
const action = error.config?.headers?.['ex-module-action'] as string | undefined;
|
||||
|
||||
// Standardized context for ALL Faro calls in this handler
|
||||
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
|
||||
const errorContext: Record<string, string> = {
|
||||
...baseContext,
|
||||
'http.status_code': String(status),
|
||||
'error.message': error.message,
|
||||
};
|
||||
|
||||
// ── 1. Close custom span with error (if present) ────────────
|
||||
const customSpan = getSpan(error.config);
|
||||
if (customSpan) {
|
||||
customSpan.setAttribute('http.status_code', status);
|
||||
customSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
||||
customSpan.recordException(error);
|
||||
safeEndSpan(error.config, customSpan);
|
||||
}
|
||||
|
||||
// ── 2. Push structured error + log (Faro → Loki) ────────────
|
||||
const faro = getFaro();
|
||||
if (faro) {
|
||||
faro.api.pushError(error, {
|
||||
type: 'api_error',
|
||||
context: errorContext,
|
||||
});
|
||||
|
||||
faro.api.pushLog(
|
||||
[`[core-api] ERROR ${method} ${url} → ${status}`],
|
||||
{ level: LogLevel.ERROR, context: errorContext },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Centralized Telemetry Setup — Grafana Faro + OpenTelemetry.
|
||||
*
|
||||
* Provides a plug-and-play `initTelemetry()` function that hides
|
||||
* all Faro/OTel complexity behind a simple config interface.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // apps/web/src/main.tsx (top of file)
|
||||
* import { initTelemetry } from '@repo/core-api/observability/setup';
|
||||
* initTelemetry({
|
||||
* appName: 'web',
|
||||
* appVersion: '1.0.0',
|
||||
* telemetryUrl: 'https://telemetry.eigen.co.id/collect',
|
||||
* environment: 'production',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {
|
||||
getWebInstrumentations,
|
||||
initializeFaro,
|
||||
type Faro,
|
||||
} from '@grafana/faro-react';
|
||||
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
|
||||
|
||||
// ─── Configuration Interface ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Configuration for initializing the full telemetry stack.
|
||||
* Apps pass this once at startup — everything else is automatic.
|
||||
*/
|
||||
export interface TelemetryConfig {
|
||||
/** Application name for Faro + OTel resource attributes. */
|
||||
appName: string;
|
||||
/** Application version (SemVer). */
|
||||
appVersion: string;
|
||||
/** Grafana Faro collector URL (e.g., 'https://telemetry.eigen.co.id/collect'). */
|
||||
telemetryUrl: string;
|
||||
/** Deployment environment ('production', 'staging', 'development'). */
|
||||
environment: string;
|
||||
/**
|
||||
* Optional: Separate OTLP trace endpoint for direct Tempo ingestion.
|
||||
* If not provided, traces are only sent through Faro's built-in exporter.
|
||||
*/
|
||||
otlpTraceUrl?: string;
|
||||
/**
|
||||
* Optional: CORS URL patterns for W3C trace context propagation.
|
||||
* @default [/.* /]
|
||||
*/
|
||||
propagateTraceHeaderCorsUrls?: Array<string | RegExp>;
|
||||
}
|
||||
|
||||
// ─── Session Persistence ────────────────────────────────────────
|
||||
|
||||
const FARO_SESSION_KEY = 'faroSession';
|
||||
|
||||
function getStoredSession(): Record<string, unknown> | null {
|
||||
try {
|
||||
const data = localStorage.getItem(FARO_SESSION_KEY);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Singleton ──────────────────────────────────────────────────
|
||||
|
||||
let faroInstance: Faro | null = null;
|
||||
|
||||
/**
|
||||
* Initializes the Grafana Faro + OpenTelemetry observability stack.
|
||||
*
|
||||
* Call this ONCE at the top of your app's entry point, before any
|
||||
* React code, HTTP requests, or other imports execute.
|
||||
*
|
||||
* `TracingInstrumentation` internally handles:
|
||||
* - WebTracerProvider setup with resource attributes
|
||||
* - FaroMetaAttributesSpanProcessor (session/user enrichment)
|
||||
* - FaroTraceExporter (sends spans to the Faro collector)
|
||||
* - Auto-instrumentation for fetch/XHR
|
||||
* - `faro.api.initOTEL(trace, context)` bridge
|
||||
*
|
||||
* @returns The initialized Faro instance for advanced usage.
|
||||
*/
|
||||
export function initTelemetry(config: TelemetryConfig): Faro {
|
||||
if (faroInstance) return faroInstance;
|
||||
|
||||
const storedSession = getStoredSession();
|
||||
|
||||
// Build optional extra span processors
|
||||
const tracingOptions: Record<string, unknown> = {};
|
||||
|
||||
if (config.otlpTraceUrl) {
|
||||
tracingOptions.spanProcessor = new BatchSpanProcessor(
|
||||
new OTLPTraceExporter({
|
||||
url: config.otlpTraceUrl,
|
||||
headers: {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
faroInstance = initializeFaro({
|
||||
url: config.telemetryUrl,
|
||||
app: {
|
||||
name: config.appName,
|
||||
version: config.appVersion,
|
||||
environment: config.environment,
|
||||
},
|
||||
sessionTracking: {
|
||||
enabled: true,
|
||||
persistent: true,
|
||||
session: storedSession ?? undefined,
|
||||
onSessionChange: (_oldSession, newSession) => {
|
||||
if (newSession) {
|
||||
localStorage.setItem(FARO_SESSION_KEY, JSON.stringify(newSession));
|
||||
}
|
||||
},
|
||||
},
|
||||
instrumentations: [
|
||||
...getWebInstrumentations(),
|
||||
|
||||
new TracingInstrumentation({
|
||||
...tracingOptions,
|
||||
instrumentationOptions: {
|
||||
propagateTraceHeaderCorsUrls:
|
||||
config.propagateTraceHeaderCorsUrls ?? [/.*/],
|
||||
fetchInstrumentationOptions: {
|
||||
applyCustomAttributesOnSpan(span) {
|
||||
span.setAttribute('app.synthetic_request', 'false');
|
||||
},
|
||||
},
|
||||
xhrInstrumentationOptions: {
|
||||
applyCustomAttributesOnSpan(span) {
|
||||
span.setAttribute('app.synthetic_request', 'false');
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return faroInstance;
|
||||
}
|
||||
|
||||
/** Returns the Faro instance (null if not yet initialized). */
|
||||
export function getFaro(): Faro | null {
|
||||
return faroInstance;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||
|
||||
/**
|
||||
* Interface-driven observability contract.
|
||||
*
|
||||
* The HTTP client calls these hooks at request lifecycle points.
|
||||
* Implementations decide whether to trace, log, metric, or do nothing.
|
||||
*
|
||||
* This interface is the ONLY dependency between the HTTP client and
|
||||
* any telemetry library. The core-api package NEVER imports
|
||||
* OpenTelemetry, Datadog, Sentry, or any vendor SDK directly.
|
||||
*/
|
||||
export interface IObservabilityAdapter {
|
||||
/** Called immediately before a request is dispatched. */
|
||||
onRequestStart(config: InternalAxiosRequestConfig): void;
|
||||
|
||||
/** Called when a response is successfully received. */
|
||||
onRequestEnd(response: AxiosResponse): void;
|
||||
|
||||
/** Called when a request fails (network error or error status). */
|
||||
onRequestError(error: AxiosError): void;
|
||||
}
|
||||
Reference in New Issue
Block a user