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:
@@ -0,0 +1,221 @@
|
||||
import type { BaseEntity } from './types';
|
||||
|
||||
// ─── Core Transformer Interface ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal contract for bidirectional data transformation
|
||||
* between API Data Transfer Objects (DTOs) and domain entities.
|
||||
*
|
||||
* Implement this interface when you only need the two core
|
||||
* mapping methods without operation-specific hooks.
|
||||
*
|
||||
* @typeParam TEntity - The frontend domain entity type
|
||||
* @typeParam TDTO - The API response/request DTO shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class UserTransformer implements IDataTransformer<UserEntity, UserDTO> {
|
||||
* transformToEntity(dto: UserDTO): UserEntity {
|
||||
* return { id: dto.id, fullName: `${dto.first_name} ${dto.last_name}` };
|
||||
* }
|
||||
* transformToDTO(entity: UserEntity): UserDTO {
|
||||
* const [first, ...rest] = entity.fullName.split(' ');
|
||||
* return { id: entity.id, first_name: first, last_name: rest.join(' ') };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface IDataTransformer<
|
||||
TEntity extends BaseEntity = BaseEntity,
|
||||
TDTO = TEntity,
|
||||
> {
|
||||
/**
|
||||
* Transform an API DTO into a domain entity.
|
||||
*
|
||||
* Called after receiving data from the API. Use this to map
|
||||
* snake_case fields to camelCase, flatten nested structures,
|
||||
* compute derived fields, or apply any normalization.
|
||||
*
|
||||
* @param dto - Raw data from the API response
|
||||
* @returns The mapped domain entity
|
||||
*/
|
||||
transformToEntity(dto: TDTO): TEntity;
|
||||
|
||||
/**
|
||||
* Transform a domain entity into an API DTO.
|
||||
*
|
||||
* Called before sending data to the API. Use this to map
|
||||
* camelCase fields to snake_case, restructure nested objects,
|
||||
* or strip frontend-only computed fields.
|
||||
*
|
||||
* @param entity - Domain entity from the frontend
|
||||
* @returns The mapped DTO for the API request
|
||||
*/
|
||||
transformToDTO(entity: TEntity): TDTO;
|
||||
|
||||
// ─── Optional Operation-Specific Hooks ──────────────────────
|
||||
|
||||
/**
|
||||
* Transform the response of a `getOne()` call.
|
||||
* If not provided, falls back to `transformToEntity`.
|
||||
*/
|
||||
transformGetOneResponse?(dto: TDTO): TEntity;
|
||||
|
||||
/**
|
||||
* Transform the response of a `getMany()` call.
|
||||
* If not provided, falls back to mapping each item via `transformToEntity`.
|
||||
*/
|
||||
transformGetManyResponse?(dtos: TDTO[]): TEntity[];
|
||||
|
||||
/**
|
||||
* Transform the payload before a `create()` call.
|
||||
* If not provided, falls back to `transformToDTO`.
|
||||
*/
|
||||
transformCreatePayload?(entity: Partial<TEntity>): Partial<TDTO>;
|
||||
|
||||
/**
|
||||
* Transform the payload before an `edit()` call.
|
||||
* If not provided, falls back to `transformToDTO`.
|
||||
*/
|
||||
transformEditPayload?(entity: Partial<TEntity>): Partial<TDTO>;
|
||||
}
|
||||
|
||||
// ─── Abstract Base Transformer ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Abstract base class providing operation-specific transformation
|
||||
* hooks with sensible passthrough defaults.
|
||||
*
|
||||
* Extends the core `IDataTransformer` contract with granular hooks
|
||||
* for each CRUD operation. Override only the hooks you need —
|
||||
* unoverridden hooks delegate to the core `transformToEntity` /
|
||||
* `transformToDTO` methods.
|
||||
*
|
||||
* **Design rationale (from legacy analysis):**
|
||||
* The legacy `BaseTransformer` used operation-specific methods
|
||||
* (`transformerGetOne`, `transformerCreate`, etc.) because different
|
||||
* operations often need different transformations. For example,
|
||||
* `getOne` might need to compute derived fields, while `create`
|
||||
* might need to strip IDs. This pattern is preserved here with
|
||||
* proper typing.
|
||||
*
|
||||
* @typeParam TEntity - The frontend domain entity type
|
||||
* @typeParam TDTO - The API response/request DTO shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
|
||||
* transformToEntity(dto: BookingDTO): BookingEntity {
|
||||
* return {
|
||||
* id: dto.id,
|
||||
* bookingCode: dto.booking_code,
|
||||
* customerName: dto.customer_name,
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* transformToDTO(entity: BookingEntity): BookingDTO {
|
||||
* return {
|
||||
* id: entity.id,
|
||||
* booking_code: entity.bookingCode,
|
||||
* customer_name: entity.customerName,
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* // Override only when getOne needs extra computed fields
|
||||
* transformGetOneResponse(dto: BookingDTO): BookingEntity {
|
||||
* const entity = this.transformToEntity(dto);
|
||||
* return { ...entity, durationNights: computeNights(dto) };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class BaseDataTransformer<
|
||||
TEntity extends BaseEntity = BaseEntity,
|
||||
TDTO = TEntity,
|
||||
> implements IDataTransformer<TEntity, TDTO>
|
||||
{
|
||||
/**
|
||||
* Core DTO → Entity transformation.
|
||||
*
|
||||
* Default implementation performs an identity cast.
|
||||
* Override this in concrete transformers to provide
|
||||
* the actual mapping logic.
|
||||
*
|
||||
* @param dto - Raw data from the API response
|
||||
* @returns The mapped domain entity
|
||||
*/
|
||||
transformToEntity(dto: TDTO): TEntity {
|
||||
return dto as unknown as TEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Entity → DTO transformation.
|
||||
*
|
||||
* Default implementation performs an identity cast.
|
||||
* Override this in concrete transformers to provide
|
||||
* the actual mapping logic.
|
||||
*
|
||||
* @param entity - Domain entity from the frontend
|
||||
* @returns The mapped DTO for the API request
|
||||
*/
|
||||
transformToDTO(entity: TEntity): TDTO {
|
||||
return entity as unknown as TDTO;
|
||||
}
|
||||
|
||||
// ─── Operation-Specific Hooks ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Transform the response of a `getOne()` call.
|
||||
*
|
||||
* Override this when `getOne` needs additional computed fields
|
||||
* or different mapping than the default `transformToEntity`.
|
||||
*
|
||||
* @param dto - Single DTO from the API response
|
||||
* @returns The mapped domain entity
|
||||
*/
|
||||
transformGetOneResponse(dto: TDTO): TEntity {
|
||||
return this.transformToEntity(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the response of a `getMany()` call.
|
||||
*
|
||||
* Override this when list responses need bulk transformations
|
||||
* (e.g., sorting, filtering, or status normalization) beyond
|
||||
* per-item mapping.
|
||||
*
|
||||
* @param dtos - Array of DTOs from the API response
|
||||
* @returns Array of mapped domain entities
|
||||
*/
|
||||
transformGetManyResponse(dtos: TDTO[]): TEntity[] {
|
||||
return dtos.map((dto) => this.transformToEntity(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the payload before a `create()` call.
|
||||
*
|
||||
* Override this when create payloads need special handling
|
||||
* (e.g., stripping IDs, formatting dates, converting nested
|
||||
* structures).
|
||||
*
|
||||
* @param entity - Partial entity data from the frontend
|
||||
* @returns The mapped partial DTO for the API request
|
||||
*/
|
||||
transformCreatePayload(entity: Partial<TEntity>): Partial<TDTO> {
|
||||
return this.transformToDTO(entity as TEntity) as Partial<TDTO>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the payload before an `edit()` call.
|
||||
*
|
||||
* Override this when edit payloads need special handling
|
||||
* that differs from create (e.g., preserving certain
|
||||
* read-only fields, handling delta updates).
|
||||
*
|
||||
* @param entity - Partial entity data from the frontend
|
||||
* @returns The mapped partial DTO for the API request
|
||||
*/
|
||||
transformEditPayload(entity: Partial<TEntity>): Partial<TDTO> {
|
||||
return this.transformToDTO(entity as TEntity) as Partial<TDTO>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user