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,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()` |
|
||||
+118
@@ -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();
|
||||
+159
@@ -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';
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
|
||||
* CRUD + lifecycle, extend BaseRemoteDataServices instead and add
|
||||
* custom methods using `this.execute()` or `this.customRequest()`.
|
||||
*
|
||||
* @typeParam E - The domain entity type
|
||||
* @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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user