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
@@ -0,0 +1,323 @@
# Data Transformers
> **Purpose:** Separate data transformation logic from API call logic, enabling clean DTO ↔ Entity mapping with type safety.
>
> **Location:** `packages/core-api/src/data-services/base-data.transformer.ts`
---
## Why Data Transformers?
In enterprise applications, the shape of data returned by the API (DTOs) often differs from the shape used in the frontend (Domain Entities). Common differences include:
| API (DTO) | Frontend (Entity) |
| ------------------------------ | ---------------------------- |
| `snake_case` field names | `camelCase` field names |
| Deeply nested structures | Flattened/normalized shapes |
| Raw ISO date strings | Parsed `Date` objects |
| No computed fields | Derived/computed properties |
| Backend-specific enums | Frontend-friendly enums |
Without transformers, this mapping logic leaks into components, hooks, and services — violating the **Single Responsibility Principle** and making the codebase harder to test and maintain.
### Benefits
- **Separation of Concerns** — Transformation logic lives in one place, not scattered across components
- **Testability** — Transformers are pure functions, trivially unit-testable
- **Reusability** — Same transformer can be used across multiple services or contexts
- **Type Safety** — Two generic parameters (`TEntity`, `TDTO`) enforce correct mapping at compile time
- **Backward Compatible** — Transformers are optional; existing services work unchanged
---
## Architecture
```mermaid
graph LR
classDef api fill:#f59e0b,stroke:#b45309,color:#fff
classDef transformer fill:#6366f1,stroke:#4338ca,color:#fff
classDef entity fill:#10b981,stroke:#047857,color:#fff
classDef service fill:#3b82f6,stroke:#2563eb,color:#fff
API["🌐 REST API<br/>(snake_case DTOs)"]:::api
SVC["BaseRemoteDataServices<br/>(execute, getOne, getMany, ...)"]:::service
TFM["Data Transformer<br/>(transformToEntity / transformToDTO)"]:::transformer
ENT["Domain Entity<br/>(camelCase, computed fields)"]:::entity
API -->|"Response (DTO)"| SVC
SVC -->|"dto"| TFM
TFM -->|"entity"| ENT
ENT -->|"entity"| TFM
TFM -->|"dto"| SVC
SVC -->|"Request (DTO)"| API
```
**Data flows:**
- **API → Frontend:** Response DTO → `transformToEntity()` → Domain Entity
- **Frontend → API:** Domain Entity → `transformToDTO()` → Request DTO
---
## Quick Start
### 1. Define Your Types
```typescript
// Domain Entity (what your UI uses)
interface BookingEntity extends BaseEntity {
bookingCode: string;
customerName: string;
checkInDate: string;
}
// API DTO (what the backend returns)
interface BookingDTO {
id?: string;
booking_code: string;
customer_name: string;
check_in_date: string;
}
```
### 2. Create a Transformer
```typescript
import { BaseDataTransformer } from '@repo/core-api/data-services';
class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
transformToEntity(dto: BookingDTO): BookingEntity {
return {
id: dto.id,
bookingCode: dto.booking_code,
customerName: dto.customer_name,
checkInDate: dto.check_in_date,
};
}
transformToDTO(entity: BookingEntity): BookingDTO {
return {
id: entity.id,
booking_code: entity.bookingCode,
customer_name: entity.customerName,
check_in_date: entity.checkInDate,
};
}
}
```
### 3. Inject into Data Services
```typescript
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(
apiClient,
{
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer: new BookingTransformer(),
},
);
// Now all CRUD methods automatically transform:
const { data } = await bookingServices.getOne('42');
// data is BookingEntity (camelCase) ✓
await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
// Payload is sent as { booking_code: 'BK001', customer_name: 'Alice', ... } ✓
```
---
## Interface Reference
### `IDataTransformer<TEntity, TDTO>`
The minimal contract for bidirectional data transformation.
```typescript
interface IDataTransformer<TEntity, TDTO> {
transformToEntity(dto: TDTO): TEntity;
transformToDTO(entity: TEntity): TDTO;
// Optional operation-specific hooks
transformGetOneResponse?(dto: TDTO): TEntity;
transformGetManyResponse?(dtos: TDTO[]): TEntity[];
transformCreatePayload?(entity: Partial<TEntity>): Partial<TDTO>;
transformEditPayload?(entity: Partial<TEntity>): Partial<TDTO>;
}
```
### `BaseDataTransformer<TEntity, TDTO>`
Abstract class implementing `IDataTransformer` with sensible defaults.
| Method | Default Behavior | Override When |
| ------------------------- | ---------------------------------------- | ------------------------------------------ |
| `transformToEntity` | Identity cast (passthrough) | Always — this is the core mapping |
| `transformToDTO` | Identity cast (passthrough) | Always — this is the core mapping |
| `transformGetOneResponse` | Delegates to `transformToEntity` | `getOne` needs computed/derived fields |
| `transformGetManyResponse`| Maps each item via `transformToEntity` | List responses need bulk transformations |
| `transformCreatePayload` | Delegates to `transformToDTO` | Create payloads need special handling (e.g., strip IDs) |
| `transformEditPayload` | Delegates to `transformToDTO` | Edit payloads differ from create |
---
## Integration with `BaseRemoteDataServices`
When a transformer is injected via `DataServicesConfig.transformer`, the base service methods automatically apply transformations:
| Service Method | Transformer Hook Used | Direction |
| -------------- | -------------------------------- | --------------- |
| `getOne()` | `transformGetOneResponse()` | Response → Entity |
| `getMany()` | `transformGetManyResponse()` | Response → Entity |
| `create()` | `transformCreatePayload()` | Entity → DTO |
| `edit()` | `transformEditPayload()` | Entity → DTO |
| `delete()` | None (no data transformation) | — |
| `customRequest()` | None (manual transformation) | — |
> **Important:** If no transformer is injected, all methods behave exactly as before — data passes through unchanged. This ensures 100% backward compatibility.
---
## Advanced: Extending Transformers
For domain-specific features that go beyond standard CRUD, you can extend both the transformer and the data service.
### Extended Transformer
```typescript
// advanced-booking.transformer.ts
import { BookingTransformer } from './booking.transformer';
interface AvailabilityChartRawData {
dates: Array<{
date_iso: string;
available_rooms: number;
occupancy_rate: number;
}>;
}
interface AvailabilityChartData {
dataPoints: Array<{
label: string;
availableRooms: number;
isHighDemand: boolean;
}>;
}
class AdvancedBookingTransformer extends BookingTransformer {
// All standard CRUD mappings are inherited ✓
// Add custom transformation for non-CRUD data
transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
return {
dataPoints: rawData.dates.map((item) => ({
label: new Date(item.date_iso).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
availableRooms: item.available_rooms,
isHighDemand: item.occupancy_rate > 80,
})),
};
}
}
```
### Extended Data Service
```typescript
// advanced-booking.data-services.ts
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
private readonly advancedTransformer: AdvancedBookingTransformer;
constructor() {
const transformer = new AdvancedBookingTransformer();
super(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer,
});
this.advancedTransformer = transformer;
}
// Custom method using the extended transformer
async getAvailabilityChart(params: {
startDate: string;
endDate: string;
}): Promise<ApiResponse<AvailabilityChartData>> {
const response = await this.customRequest<AvailabilityChartRawData>({
url: '/bookings/availability-chart',
method: 'GET',
params: { start_date: params.startDate, end_date: params.endDate },
});
return {
data: this.advancedTransformer.transformAvailabilityChart(response.data),
status: response.status,
};
}
}
export const advancedBookingServices = new AdvancedBookingDataServices();
```
---
## Migration Guide
Adding transformers to existing services requires **zero breaking changes**:
### Step 1: Create a Transformer
```typescript
class MyTransformer extends BaseDataTransformer<MyEntity, MyDTO> {
transformToEntity(dto: MyDTO): MyEntity { /* ... */ }
transformToDTO(entity: MyEntity): MyDTO { /* ... */ }
}
```
### Step 2: Add a Second Generic Parameter
```diff
- const services = new CommonRemoteDataServices<MyEntity>(apiClient, {
+ const services = new CommonRemoteDataServices<MyEntity, MyDTO>(apiClient, {
apiUrl: '/my-endpoint',
+ transformer: new MyTransformer(),
});
```
### Step 3: (Optional) Override Operation-Specific Hooks
```typescript
class MyTransformer extends BaseDataTransformer<MyEntity, MyDTO> {
transformToEntity(dto: MyDTO): MyEntity { /* ... */ }
transformToDTO(entity: MyEntity): MyDTO { /* ... */ }
// Only override if getOne needs special handling
override transformGetOneResponse(dto: MyDTO): MyEntity {
const entity = this.transformToEntity(dto);
return { ...entity, computedField: derive(dto) };
}
}
```
> **Existing services without transformers are completely unaffected.** The `TDTO` generic defaults to `TEntity`, and the `transformer` config property defaults to `undefined`.
---
## Sample Implementation
A full working example is available in the showcase booking feature:
| File | Description |
| ---- | ----------- |
| `apps/web/.../booking/data/booking.transformer.ts` | Basic transformer with snake_case ↔ camelCase mapping |
| `apps/web/.../booking/data/booking.data-services.ts` | Data service with injected transformer |
| `apps/web/.../booking/data/advanced-booking.transformer.ts` | Extended transformer with custom chart method |
| `apps/web/.../booking/data/advanced-booking.data-services.ts` | Extended service with custom `getAvailabilityChart()` |
@@ -0,0 +1,118 @@
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { apiClient } from '../../../../../../core/lib/api-client';
import type { BookingEntity } from './booking.data-services';
import type { BookingDTO } from './booking.transformer';
import {
AdvancedBookingTransformer,
type AvailabilityChartRawData,
type AvailabilityChartData,
} from './advanced-booking.transformer';
// ─── Advanced Booking Data Services ─────────────────────────────
/**
* Extended booking data services with custom methods for
* advanced booking features beyond standard CRUD.
*
* Extends {@link BaseRemoteDataServices} directly (instead of using
* `CommonRemoteDataServices`) to add domain-specific methods like
* `getAvailabilityChart()`.
*
* Uses {@link AdvancedBookingTransformer} which provides:
* - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
* - Custom `transformAvailabilityChart()` for chart data
* - Enhanced `transformGetManyResponse()` with status normalization
*
* @example
* ```ts
* // Standard CRUD (inherited, with transformer)
* const { data: bookings } = await advancedBookingServices.getMany();
* const { data: booking } = await advancedBookingServices.getOne('42');
*
* // Custom method for chart data
* const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
* startDate: '2026-07-01',
* endDate: '2026-07-31',
* });
* ```
*/
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
/**
* The concrete advanced transformer instance.
*
* Stored separately from the base `transformer` property
* to access custom methods (like `transformAvailabilityChart`)
* that aren't part of the `IDataTransformer` interface.
*/
private readonly advancedTransformer: AdvancedBookingTransformer;
constructor() {
const advancedTransformer = new AdvancedBookingTransformer();
super(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer: advancedTransformer,
});
this.advancedTransformer = advancedTransformer;
}
// ─── Custom Methods ─────────────────────────────────────────
/**
* Fetch the availability chart data for a given date range.
*
* Calls the `/bookings/availability-chart` endpoint and transforms
* the raw API response into a UI-friendly chart format using
* {@link AdvancedBookingTransformer.transformAvailabilityChart}.
*
* @param params - Date range parameters for the chart query
* @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
* @param params.endDate - End date (ISO format, e.g., '2026-07-31')
* @returns Transformed chart data ready for UI rendering
*
* @example
* ```ts
* const { data } = await advancedBookingServices.getAvailabilityChart({
* startDate: '2026-07-01',
* endDate: '2026-07-31',
* });
*
* // data.dataPoints → Array of chart-ready data points
* // data.summary → Aggregated metrics for the period
* ```
*/
async getAvailabilityChart(params: {
startDate: string;
endDate: string;
}): Promise<ApiResponse<AvailabilityChartData>> {
const response = await this.customRequest<AvailabilityChartRawData>({
url: '/bookings/availability-chart',
method: 'GET',
params: {
start_date: params.startDate,
end_date: params.endDate,
},
});
return {
data: this.advancedTransformer.transformAvailabilityChart(response.data),
status: response.status,
};
}
}
// ─── Singleton Export ────────────────────────────────────────────
/**
* Pre-configured advanced booking data services instance.
*
* Use this when you need both standard CRUD operations and
* custom methods like `getAvailabilityChart()`.
*
* For standard CRUD-only usage, prefer `bookingServices` from
* `booking.data-services.ts` instead.
*/
export const advancedBookingServices = new AdvancedBookingDataServices();
@@ -0,0 +1,159 @@
import { BookingTransformer } from './booking.transformer';
import type { BookingDTO } from './booking.transformer';
import type { BookingEntity } from './booking.data-services';
// ─── Advanced Types ─────────────────────────────────────────────
/**
* Raw availability chart data as returned by the API.
*
* The backend returns a flat structure with snake_case keys
* and ISO date strings. This needs to be transformed into
* a more UI-friendly shape for chart rendering.
*/
export interface AvailabilityChartRawData {
dates: Array<{
date_iso: string;
available_rooms: number;
total_rooms: number;
occupancy_rate: number;
revenue_per_room: number;
}>;
summary: {
avg_occupancy_rate: number;
total_revenue: number;
period_start: string;
period_end: string;
};
}
/**
* UI-friendly availability chart data.
*
* Pre-computed for direct rendering in chart components
* with camelCase fields, formatted labels, and derived metrics.
*/
export interface AvailabilityChartData {
/** Data points ready for chart rendering. */
dataPoints: Array<{
/** Formatted date label (e.g., 'Mon, Jul 1'). */
label: string;
/** ISO date string for programmatic use. */
dateISO: string;
/** Number of rooms available. */
availableRooms: number;
/** Total room capacity. */
totalRooms: number;
/** Occupancy rate as a percentage (0-100). */
occupancyRate: number;
/** Revenue per available room. */
revenuePerRoom: number;
/** Whether the day is a high-demand day (>80% occupancy). */
isHighDemand: boolean;
}>;
/** Aggregated summary metrics for the period. */
summary: {
averageOccupancy: number;
totalRevenue: number;
periodStart: string;
periodEnd: string;
/** Number of high-demand days in the period. */
highDemandDays: number;
};
}
// ─── Advanced Booking Transformer ───────────────────────────────
/**
* Extended booking transformer with additional custom methods
* for non-CRUD data transformations.
*
* Inherits all standard DTO ↔ Entity mapping from
* {@link BookingTransformer} and adds domain-specific
* transformations for advanced features like availability charts.
*
* **When to extend vs. create new:**
* - Extend when the new transformer shares the same entity/DTO pair
* and you need additional transformation methods
* - Create a new transformer when the entity/DTO types are different
*
* @example
* ```ts
* const transformer = new AdvancedBookingTransformer();
*
* // Standard CRUD mapping (inherited)
* const entity = transformer.transformToEntity(bookingDTO);
*
* // Custom chart transformation (new)
* const chartData = transformer.transformAvailabilityChart(rawChartData);
* ```
*/
export class AdvancedBookingTransformer extends BookingTransformer {
/**
* Transform raw availability chart data from the API into a
* UI-friendly format for chart rendering.
*
* Performs the following transformations:
* 1. Maps snake_case fields to camelCase
* 2. Formats date strings into human-readable labels
* 3. Computes derived `isHighDemand` flag (>80% occupancy)
* 4. Aggregates `highDemandDays` count in the summary
*
* @param rawData - Raw chart data from the `/bookings/availability-chart` endpoint
* @returns Transformed chart data ready for UI rendering
*/
transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
const HIGH_DEMAND_THRESHOLD = 80;
const dataPoints = rawData.dates.map((item) => {
const date = new Date(item.date_iso);
const isHighDemand = item.occupancy_rate > HIGH_DEMAND_THRESHOLD;
return {
label: date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
dateISO: item.date_iso,
availableRooms: item.available_rooms,
totalRooms: item.total_rooms,
occupancyRate: item.occupancy_rate,
revenuePerRoom: item.revenue_per_room,
isHighDemand,
};
});
const highDemandDays = dataPoints.filter((dp) => dp.isHighDemand).length;
return {
dataPoints,
summary: {
averageOccupancy: rawData.summary.avg_occupancy_rate,
totalRevenue: rawData.summary.total_revenue,
periodStart: rawData.summary.period_start,
periodEnd: rawData.summary.period_end,
highDemandDays,
},
};
}
/**
* Enhanced getMany response that also normalizes status values.
*
* Demonstrates overriding an inherited hook to add
* additional processing on top of the base transformation.
*
* @param dtos - Array of booking DTOs from the API
* @returns Transformed entities with normalized status
*/
override transformGetManyResponse(dtos: BookingDTO[]): BookingEntity[] {
return super.transformGetManyResponse(dtos).map((entity) => ({
...entity,
// Normalize 'cancelled' vs 'canceled' from different API versions
status: entity.status === ('canceled' as BookingEntity['status'])
? 'cancelled'
: entity.status,
}));
}
}
@@ -1,6 +1,8 @@
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../core/lib/api-client';
import { BookingTransformer } from './booking.transformer';
import type { BookingDTO } from './booking.transformer';
// ─── Domain Entity ──────────────────────────────────────────────
@@ -22,19 +24,35 @@ export interface BookingEntity extends BaseEntity {
// ─── Data Services Instance ─────────────────────────────────────
/**
* Booking data services — wired to the enterprise `apiClient`.
* Booking data services — wired to the enterprise `apiClient`
* with automatic DTO ↔ Entity transformation.
*
* All requests flow through the full interceptor chain:
* Faro tracing → Bearer token injection → ApiError normalization.
*
* The injected {@link BookingTransformer} automatically:
* - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
* - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
* - Strips `id` from create payloads
* - Computes `durationNights` on `getOne` responses
*
* @example
* ```ts
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
* // data is BookingEntity[] with camelCase fields
*
* const { data: booking } = await bookingServices.getOne('42');
* // booking is BookingEntity with computed durationNights
*
* await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
* // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
*
* await bookingServices.confirmProcessTransaction('42');
* ```
*/
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(apiClient, {
export const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer: new BookingTransformer(),
});
@@ -0,0 +1,123 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import type { BookingEntity } from './booking.data-services';
// ─── Booking DTO (API Response Shape) ───────────────────────────
/**
* Raw booking data as returned by the API.
*
* Uses snake_case field names matching the backend's JSON serialization.
* This DTO is never used directly in UI components — it is transformed
* into a {@link BookingEntity} by the {@link BookingTransformer}.
*/
export interface BookingDTO {
id?: string;
booking_code: string;
customer_name: string;
check_in_date: string;
check_out_date: string;
status: 'pending' | 'confirmed' | 'cancelled';
total_amount: number;
}
// ─── Booking Transformer ────────────────────────────────────────
/**
* Transforms between the API's `BookingDTO` (snake_case) and
* the frontend's `BookingEntity` (camelCase).
*
* Handles:
* - Field name mapping (snake_case ↔ camelCase)
* - Computed field derivation (e.g., `durationNights` on `getOne`)
* - Payload sanitization (e.g., stripping `id` on create)
*
* @example
* ```ts
* const transformer = new BookingTransformer();
*
* // API response → Domain entity
* const entity = transformer.transformToEntity({
* id: '42',
* booking_code: 'BK042',
* customer_name: 'Alice',
* check_in_date: '2026-07-01',
* check_out_date: '2026-07-03',
* status: 'confirmed',
* total_amount: 500000,
* });
* // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
* ```
*/
export class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
/**
* Map an API booking DTO to a frontend booking entity.
*
* @param dto - Raw booking data from the API
* @returns Mapped booking entity with camelCase fields
*/
override transformToEntity(dto: BookingDTO): BookingEntity {
return {
id: dto.id,
bookingCode: dto.booking_code,
customerName: dto.customer_name,
checkInDate: dto.check_in_date,
checkOutDate: dto.check_out_date,
status: dto.status,
totalAmount: dto.total_amount,
};
}
/**
* Map a frontend booking entity to an API booking DTO.
*
* @param entity - Booking entity from the frontend
* @returns Mapped booking DTO with snake_case fields
*/
override transformToDTO(entity: BookingEntity): BookingDTO {
return {
id: entity.id,
booking_code: entity.bookingCode,
customer_name: entity.customerName,
check_in_date: entity.checkInDate,
check_out_date: entity.checkOutDate,
status: entity.status,
total_amount: entity.totalAmount,
};
}
/**
* Transform a single booking response with computed fields.
*
* Adds `durationNights` as a derived convenience field
* that is only relevant when viewing a single booking detail.
*
* @param dto - Raw booking DTO from the API
* @returns Booking entity with computed fields
*/
override transformGetOneResponse(dto: BookingDTO): BookingEntity {
const entity = this.transformToEntity(dto);
const checkIn = new Date(dto.check_in_date);
const checkOut = new Date(dto.check_out_date);
const durationMs = checkOut.getTime() - checkIn.getTime();
const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
return {
...entity,
// Attach computed field via type assertion since
// durationNights is a view-layer convenience
...(durationNights > 0 ? { durationNights } : {}),
};
}
/**
* Strip `id` from create payloads since the backend generates IDs.
*
* @param entity - Partial booking entity from the create form
* @returns Sanitized DTO payload without `id`
*/
override transformCreatePayload(entity: Partial<BookingEntity>): Partial<BookingDTO> {
const dto = this.transformToDTO(entity as BookingEntity);
const { id: _, ...rest } = dto;
return rest;
}
}
@@ -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>;
}
}
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type { BaseEntity } from './types';
import type { IDataTransformer } from './base-data.transformer';
import { CommonRemoteDataServices } from './common-remote.data-services';
import { BaseDataTransformer } from './base-data.transformer';
// ─── Mock AxiosInstance ─────────────────────────────────────────
@@ -261,3 +263,398 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
});
});
});
// ═══════════════════════════════════════════════════════════════════
// Data Transformer Integration Tests
// ═══════════════════════════════════════════════════════════════════
// ─── Test DTO (snake_case API shape) ────────────────────────────
interface TestDTO {
id?: string;
booking_code: string;
customer_name: string;
}
interface TestEntity2 extends BaseEntity {
bookingCode: string;
customerName: string;
}
// ─── Concrete Transformer for Testing ───────────────────────────
class TestTransformer extends BaseDataTransformer<TestEntity2, TestDTO> {
transformToEntity(dto: TestDTO): TestEntity2 {
return {
id: dto.id,
bookingCode: dto.booking_code,
customerName: dto.customer_name,
};
}
transformToDTO(entity: TestEntity2): TestDTO {
return {
id: entity.id,
booking_code: entity.bookingCode,
customer_name: entity.customerName,
};
}
}
// ─── Transformer with Custom Hooks ──────────────────────────────
class CustomHookTransformer extends TestTransformer {
override transformGetOneResponse(dto: TestDTO): TestEntity2 {
const entity = this.transformToEntity(dto);
return { ...entity, customerName: entity.customerName.toUpperCase() };
}
override transformGetManyResponse(dtos: TestDTO[]): TestEntity2[] {
return dtos
.map((dto) => this.transformToEntity(dto))
.map((entity) => ({ ...entity, bookingCode: `LIST-${entity.bookingCode}` }));
}
override transformCreatePayload(entity: Partial<TestEntity2>): Partial<TestDTO> {
const dto = super.transformCreatePayload(entity);
return { ...dto, id: undefined };
}
override transformEditPayload(entity: Partial<TestEntity2>): Partial<TestDTO> {
const dto = super.transformEditPayload(entity);
return { ...dto, booking_code: `EDIT-${dto.booking_code}` };
}
}
// ─── Transformer Integration Tests ──────────────────────────────
describe('BaseRemoteDataServices — Data Transformer Integration', () => {
let mockClient: AxiosInstance;
beforeEach(() => {
mockClient = createMockHttpClient();
});
// ── Without Transformer (backward compatibility) ──────────────
describe('without transformer (backward compatibility)', () => {
it('getOne() returns raw API response unchanged', async () => {
const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTO,
status: 200,
});
const services = new CommonRemoteDataServices(mockClient, {
apiUrl: '/bookings',
});
const result = await services.getOne('42');
expect(result.data).toEqual(rawDTO);
});
it('getMany() returns raw API response unchanged', async () => {
const rawDTOs = [
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
];
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTOs,
status: 200,
});
const services = new CommonRemoteDataServices(mockClient, {
apiUrl: '/bookings',
});
const result = await services.getMany();
expect(result.data).toEqual(rawDTOs);
});
it('create() sends entity data as-is without transformation', async () => {
const services = new CommonRemoteDataServices(mockClient, {
apiUrl: '/bookings',
});
const entityData = { id: 'temp-1' };
await services.create(entityData);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.data).toEqual(entityData);
});
});
// ── With Base Transformer (core methods) ──────────────────────
describe('with transformer (core transformToEntity / transformToDTO)', () => {
let services: CommonRemoteDataServices<TestEntity2, TestDTO>;
const transformer = new TestTransformer();
beforeEach(() => {
services = new CommonRemoteDataServices<TestEntity2, TestDTO>(mockClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer,
});
});
it('getOne() transforms API DTO to domain entity', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' },
status: 200,
});
const result = await services.getOne('42');
expect(result.data).toEqual({
id: '42',
bookingCode: 'BK042',
customerName: 'Alice',
});
expect(result.status).toBe(200);
});
it('getMany() transforms each DTO in the array to entities', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
],
status: 200,
});
const result = await services.getMany();
expect(result.data).toEqual([
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' },
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' },
]);
});
it('getMany() handles empty array response', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [],
status: 200,
});
const result = await services.getMany();
expect(result.data).toEqual([]);
});
it('create() transforms entity payload to DTO before sending', async () => {
await services.create({
bookingCode: 'BK001',
customerName: 'Alice',
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.data).toEqual({
id: undefined,
booking_code: 'BK001',
customer_name: 'Alice',
});
});
it('edit() transforms entity payload to DTO before sending', async () => {
await services.edit('42', {
bookingCode: 'BK042-UPDATED',
customerName: 'Bob',
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.data).toEqual({
id: undefined,
booking_code: 'BK042-UPDATED',
customer_name: 'Bob',
});
expect(requestArg.url).toBe('/bookings/42');
});
it('delete() is unaffected by transformer (no data transformation needed)', async () => {
await services.delete('42');
expect(mockClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/bookings/42',
method: 'DELETE',
}),
);
});
it('customRequest() is unaffected by transformer', async () => {
await services.customRequest({
url: '/bookings/42/calculate-tax',
method: 'POST',
data: { items: [] },
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.data).toEqual({ items: [] });
});
});
// ── With Custom Hook Transformer ──────────────────────────────
describe('with custom operation-specific hooks', () => {
let services: CommonRemoteDataServices<TestEntity2, TestDTO>;
const transformer = new CustomHookTransformer();
beforeEach(() => {
services = new CommonRemoteDataServices<TestEntity2, TestDTO>(mockClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer,
});
});
it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', booking_code: 'BK042', customer_name: 'alice' },
status: 200,
});
const result = await services.getOne('42');
expect(result.data).toEqual({
id: '42',
bookingCode: 'BK042',
customerName: 'ALICE', // uppercased by custom hook
});
});
it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
],
status: 200,
});
const result = await services.getMany();
expect(result.data).toEqual([
{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' },
]);
});
it('create() uses transformCreatePayload hook (strips id)', async () => {
await services.create({
id: 'should-be-removed',
bookingCode: 'BK001',
customerName: 'Alice',
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.data.id).toBeUndefined();
expect(requestArg.data.booking_code).toBe('BK001');
});
it('edit() uses transformEditPayload hook (prefixes booking code)', async () => {
await services.edit('42', {
bookingCode: 'BK042',
customerName: 'Alice',
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.data.booking_code).toBe('EDIT-BK042');
});
});
// ── Identity Transformer ──────────────────────────────────────
describe('with identity transformer (default passthrough)', () => {
it('produces same results as no transformer', async () => {
const rawData = { id: '1', name: 'Test' };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValue({
data: rawData,
status: 200,
});
// Service without transformer
const servicesNoTransformer = new CommonRemoteDataServices(mockClient, {
apiUrl: '/items',
});
// Service with identity transformer (no method overrides)
class IdentityTransformer extends BaseDataTransformer {}
const servicesWithIdentity = new CommonRemoteDataServices(mockClient, {
apiUrl: '/items',
transformer: new IdentityTransformer(),
});
const resultWithout = await servicesNoTransformer.getOne('1');
const resultWith = await servicesWithIdentity.getOne('1');
expect(resultWithout.data).toEqual(resultWith.data);
});
});
// ── Transformer receives correct arguments ────────────────────
describe('transformer method invocation', () => {
it('transformGetOneResponse receives the raw DTO from API', async () => {
const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTO,
status: 200,
});
const mockTransformer: IDataTransformer<TestEntity2, TestDTO> = {
transformToEntity: vi.fn((dto: TestDTO) => ({
id: dto.id,
bookingCode: dto.booking_code,
customerName: dto.customer_name,
})),
transformToDTO: vi.fn(),
transformGetOneResponse: vi.fn((dto: TestDTO) => ({
id: dto.id,
bookingCode: dto.booking_code,
customerName: dto.customer_name,
})),
transformGetManyResponse: vi.fn(),
transformCreatePayload: vi.fn(),
transformEditPayload: vi.fn(),
};
const services = new CommonRemoteDataServices<TestEntity2, TestDTO>(mockClient, {
apiUrl: '/bookings',
transformer: mockTransformer,
});
await services.getOne('42');
expect(mockTransformer.transformGetOneResponse).toHaveBeenCalledWith(rawDTO);
expect(mockTransformer.transformToEntity).not.toHaveBeenCalled();
});
it('transformCreatePayload receives the entity data from caller', async () => {
const entityData: Partial<TestEntity2> = {
bookingCode: 'BK001',
customerName: 'Alice',
};
const mockTransformer: IDataTransformer<TestEntity2, TestDTO> = {
transformToEntity: vi.fn(),
transformToDTO: vi.fn(),
transformGetOneResponse: vi.fn(),
transformGetManyResponse: vi.fn(),
transformCreatePayload: vi.fn((entity) => ({
booking_code: entity.bookingCode!,
customer_name: entity.customerName!,
})),
transformEditPayload: vi.fn(),
};
const services = new CommonRemoteDataServices<TestEntity2, TestDTO>(mockClient, {
apiUrl: '/bookings',
transformer: mockTransformer,
});
await services.create(entityData);
expect(mockTransformer.transformCreatePayload).toHaveBeenCalledWith(entityData);
expect(mockTransformer.transformToDTO).not.toHaveBeenCalled();
});
});
});
@@ -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';
@@ -26,9 +27,11 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
* - 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, {
@@ -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>;
}
/** Fetch a single entity by ID. */
getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getOne, {
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>;
}
/** Create a new entity. */
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 },
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 },
});
}
@@ -13,10 +13,11 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
* custom methods using `this.execute()` or `this.customRequest()`.
*
* @typeParam E - The domain entity type
* @typeParam TDTO - The API DTO shape (defaults to E for backward compatibility)
*
* @example
* ```ts
* // Direct instantiation for standard modules
* // Direct instantiation for standard modules (no transformer)
* const bookingServices = new CommonRemoteDataServices<BookingEntity>(
* apiClient,
* { apiUrl: '/bookings', moduleKey: 'BOOKING' },
@@ -27,6 +28,19 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
*
* @example
* ```ts
* // With transformer for DTO ↔ Entity mapping
* const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(
* apiClient,
* {
* apiUrl: '/bookings',
* moduleKey: 'BOOKING',
* transformer: new BookingTransformer(),
* },
* );
* ```
*
* @example
* ```ts
* // For modules needing custom operations, extend the base:
* class InvoiceDataServices extends BaseRemoteDataServices<InvoiceEntity> {
* async calculateTax(invoiceId: string) {
@@ -40,4 +54,6 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
*/
export class CommonRemoteDataServices<
E extends BaseEntity = BaseEntity,
> extends BaseRemoteDataServices<E> {}
TDTO = E,
> extends BaseRemoteDataServices<E, TDTO> {}
@@ -1,6 +1,7 @@
// ─── Classes ────────────────────────────────────────────────────
export { BaseRemoteDataServices } from './base-remote.data-services';
export { CommonRemoteDataServices } from './common-remote.data-services';
export { BaseDataTransformer } from './base-data.transformer';
// ─── Utilities ──────────────────────────────────────────────────
export { interpolateUrl } from './url-builder';
@@ -17,3 +18,5 @@ export type {
ExecuteOptions,
DataServicesConfig,
} from './types';
export type { IDataTransformer } from './base-data.transformer';
+22 -1
View File
@@ -1,5 +1,6 @@
import type { AxiosRequestConfig } from 'axios';
import type { TelemetryContext } from '../http-client/types';
import type { IDataTransformer } from './base-data.transformer';
// ─── Base Entity ────────────────────────────────────────────────
@@ -112,8 +113,14 @@ export interface ExecuteOptions {
/**
* Configuration for constructing a BaseRemoteDataServices instance.
*
* @typeParam TEntity - The frontend domain entity type (defaults to BaseEntity)
* @typeParam TDTO - The API DTO shape (defaults to TEntity for backward compatibility)
*/
export interface DataServicesConfig {
export interface DataServicesConfig<
TEntity extends BaseEntity = BaseEntity,
TDTO = TEntity,
> {
/** 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'). */
@@ -122,4 +129,18 @@ export interface DataServicesConfig {
urls?: Partial<ApiURLMap>;
/** Override specific HTTP methods. */
methods?: Partial<RequestMethodMap>;
/**
* Optional data transformer for DTO ↔ Entity mapping.
*
* When provided, CRUD methods automatically transform:
* - **Responses** (`getOne`, `getMany`): DTO → Entity via transformer
* - **Payloads** (`create`, `edit`): Entity → DTO via transformer
*
* When omitted, data passes through unchanged (backward compatible).
*
* @see {@link IDataTransformer} for the transformer contract
* @see {@link BaseDataTransformer} for the abstract base class
*/
transformer?: IDataTransformer<TEntity, TDTO>;
}