Files
trackgo-fe/packages/core-api/src/data-services/base-remote.data-services.ts
T

361 lines
13 KiB
TypeScript

import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type {
BaseEntity,
ApiURLMap,
RequestMethodMap,
RequestDescriptor,
ExecuteOptions,
DataServicesConfig,
EntityId,
} from './types';
import type { IDataTransformer } from './base-data.transformer';
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)
* @typeParam TDTO - The API DTO shape (defaults to E for backward compatibility)
*
* @example
* ```ts
* // Without transformer (backward compatible)
* class BookingDataServices extends BaseRemoteDataServices<BookingEntity> {}
*
* const services = new BookingDataServices(apiClient, {
* apiUrl: '/bookings',
* moduleKey: 'BOOKING',
* });
*
* // With transformer (DTO ↔ Entity mapping)
* const services = new BookingDataServices(apiClient, {
* apiUrl: '/bookings',
* moduleKey: 'BOOKING',
* transformer: new BookingTransformer(),
* });
*
* const { data, status } = await services.getOne<BookingEntity>('42');
* ```
*/
export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity, TDTO = E> {
/** 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;
/**
* Optional data transformer for DTO ↔ Entity mapping.
*
* When present, CRUD methods automatically transform responses
* and payloads. When absent, data passes through unchanged.
*/
protected readonly transformer: IDataTransformer<E, TDTO> | undefined;
constructor(httpClient: AxiosInstance, config: DataServicesConfig<E, TDTO>) {
this.httpClient = httpClient;
this.moduleKey = config.moduleKey;
this.transformer = config.transformer;
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.
*
* When a transformer is injected, the raw API response is passed
* through `transformGetManyResponse()` before being returned.
*/
async getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const finalConfig = config?.params && this.transformer?.transformPayloadFilter
? { ...config, params: this.transformer.transformPayloadFilter(config.params) }
: config;
const result = await this.execute<T>(DESCRIPTORS.getMany, { config: finalConfig });
if (this.transformer && Array.isArray(result.data)) {
return {
...result,
data: this.transformer.transformGetManyResponse
? this.transformer.transformGetManyResponse(result.data as unknown as TDTO[])
: result.data.map((item: unknown) => this.transformer!.transformToEntity(item as TDTO)),
} as unknown as ApiResponse<T>;
}
return result;
}
/**
* Fetch a single entity by ID.
*
* When a transformer is injected, the raw API response is passed
* through `transformGetOneResponse()` before being returned.
*/
async getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const result = await this.execute<T>(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
if (this.transformer && result.data != null) {
return {
...result,
data: this.transformer.transformGetOneResponse
? this.transformer.transformGetOneResponse(result.data as unknown as TDTO)
: this.transformer.transformToEntity(result.data as unknown as TDTO),
} as unknown as ApiResponse<T>;
}
return result;
}
/**
* Create a new entity.
*
* When a transformer is injected, the entity payload is passed
* through `transformCreatePayload()` before being sent to the API.
*/
create<T = E>(data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const transformedData = this.transformer?.transformCreatePayload
? this.transformer.transformCreatePayload(data)
: this.transformer
? this.transformer.transformToDTO(data as E)
: data;
return this.execute<T>(DESCRIPTORS.create, {
config: { ...config, data: transformedData },
});
}
/**
* Update an existing entity by ID.
*
* When a transformer is injected, the entity payload is passed
* through `transformEditPayload()` before being sent to the API.
*/
edit<T = E>(id: string, data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const transformedData = this.transformer?.transformEditPayload
? this.transformer.transformEditPayload(data)
: this.transformer
? this.transformer.transformToDTO(data as E)
: data;
return this.execute<T>(DESCRIPTORS.edit, {
variableURL: { id },
config: { ...config, data: transformedData },
});
}
/** Delete a single entity by ID. Optionally sends form data as `meta` in the request body. */
delete(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.delete, {
variableURL: { id },
config: { ...config, ...(meta ? { data: { meta } } : {}) },
});
}
/** Delete multiple entities by IDs. */
batchDelete(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids } },
});
}
// ─── Activation Lifecycle ─────────────────────────────────────
/** Activate a single entity. Optionally sends form data as `meta` in the request body. */
activate(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.activate, {
variableURL: { id },
config: { ...config, ...(meta ? { data: { meta } } : {}) },
});
}
/** Activate multiple entities. */
batchActivate(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids } },
});
}
/** Deactivate a single entity. Optionally sends form data as `meta` in the request body. */
deactivate(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.deactivate, {
variableURL: { id },
config: { ...config, ...(meta ? { data: { meta } } : {}) },
});
}
/** Deactivate multiple entities. */
batchDeactivate(ids: EntityId[], 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. Optionally sends form data as `meta` in the request body. */
confirmData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmData, {
variableURL: { id },
config: { ...config, ...(meta ? { data: { meta } } : {}) },
});
}
/** Confirm processing of multiple data records. */
batchConfirmData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmData, {
config: { ...config, data: { ids } },
});
}
/** Cancel processing of a single data record. Optionally sends form data as `meta` in the request body. */
cancelData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelData, {
variableURL: { id },
config: { ...config, ...(meta ? { data: { meta } } : {}) },
});
}
/** Cancel processing of multiple data records. */
batchCancelData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelData, {
config: { ...config, data: { ids } },
});
}
// ─── Transaction Lifecycle ────────────────────────────────────
/** Rollback a transaction. Optionally sends form data as `meta` in the request body. */
rollbackData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.rollbackData, {
variableURL: { id },
config: { ...config, ...(meta ? { data: { meta } } : {}) },
});
}
/** Rollback multiple transactions. */
batchRollbackData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackData, {
config: { ...config, data: { ids } },
});
}
/** Hold a transaction. Optionally sends form data as `meta` in the request body. */
holdData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.holdData, {
variableURL: { id },
config: { ...config, ...(meta ? { data: { meta } } : {}) },
});
}
/** Hold multiple transactions. */
batchHoldData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldData, {
config: { ...config, data: { ids } },
});
}
}