feat(core-api): implement abstract data transformer architecture

- **Core API**: Introduced `IDataTransformer` interface and `BaseDataTransformer` abstract class.
- **Data Services**: Updated `BaseRemoteDataServices` and `DataServicesConfig` to support optional transformer injection. CRUD methods now auto-transform data mapping (DTO <-> Entity) when a transformer is provided.
- **Generics**: Updated `CommonRemoteDataServices` to forward the `TDTO` generic for strict type safety. Fixed `ApiResponse<T>` casting and excess property type checks.
- **Showcase/Samples**: Created `BookingTransformer` (snake_case to camelCase mapping) and injected it into `booking.data-services.ts`.
- **Advanced Showcase**: Added `AdvancedBookingTransformer` and `AdvancedBookingService` to demonstrate extending base transformers with custom methods (e.g., `getAvailabilityChart`).
- **Documentation**: Added comprehensive developer guide at `apps/docs-dev/src/packages/core-api/transformers.md`.
- **Testing**: Added 16 new unit tests for transformer integrations (identity and mock scenarios).

Note: The implementation is 100% backward compatible. All 49/49 tests pass and the TypeScript typecheck is entirely clean.
This commit is contained in:
Firman Ramdhani
2026-07-01 11:13:09 +07:00
parent 7130eb3fe3
commit 1c2090f4fb
11 changed files with 1494 additions and 19 deletions
@@ -7,6 +7,7 @@ import type {
ExecuteOptions,
DataServicesConfig,
} 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';
@@ -25,10 +26,12 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
* - 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 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, {
@@ -36,10 +39,20 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
* 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> {
export abstract class BaseRemoteDataServices<
E extends BaseEntity = BaseEntity,
TDTO = E,
> {
/** The injected, isolated HTTP client instance. */
protected readonly httpClient: AxiosInstance;
@@ -52,9 +65,18 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity>
/** Module key for the 'ex-module-key' audit header. */
protected readonly moduleKey: string | undefined;
constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
/**
* 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 ?? ''),
@@ -143,31 +165,85 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity>
// ─── 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 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 result = await this.execute<T>(DESCRIPTORS.getMany, { config });
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. */
getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getOne, {
/**
* 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. */
/**
* 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 },
config: { ...config, data: transformedData },
});
}
/** Update an existing entity by ID. */
/**
* 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 },
config: { ...config, data: transformedData },
});
}