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:
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user