From 1c2090f4fb6a5e5a9e576e3d36c58aeec32f2072 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:13:09 +0700 Subject: [PATCH] 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` 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. --- .../src/packages/core-api/transformers.md | 323 ++++++++++++++ .../data/advanced-booking.data-services.ts | 118 ++++++ .../data/advanced-booking.transformer.ts | 159 +++++++ .../booking/data/booking.data-services.ts | 22 +- .../booking/data/booking.transformer.ts | 123 ++++++ .../data-services/base-data.transformer.ts | 221 ++++++++++ .../base-remote.data-services.test.ts | 397 ++++++++++++++++++ .../base-remote.data-services.ts | 102 ++++- .../common-remote.data-services.ts | 22 +- packages/core-api/src/data-services/index.ts | 3 + packages/core-api/src/data-services/types.ts | 23 +- 11 files changed, 1494 insertions(+), 19 deletions(-) create mode 100644 apps/docs-dev/src/packages/core-api/transformers.md create mode 100644 apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts create mode 100644 apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts create mode 100644 apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts create mode 100644 packages/core-api/src/data-services/base-data.transformer.ts diff --git a/apps/docs-dev/src/packages/core-api/transformers.md b/apps/docs-dev/src/packages/core-api/transformers.md new file mode 100644 index 0000000..614eb39 --- /dev/null +++ b/apps/docs-dev/src/packages/core-api/transformers.md @@ -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
(snake_case DTOs)"]:::api + SVC["BaseRemoteDataServices
(execute, getOne, getMany, ...)"]:::service + TFM["Data Transformer
(transformToEntity / transformToDTO)"]:::transformer + ENT["Domain Entity
(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 { + 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( + 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` + +The minimal contract for bidirectional data transformation. + +```typescript +interface IDataTransformer { + transformToEntity(dto: TDTO): TEntity; + transformToDTO(entity: TEntity): TDTO; + + // Optional operation-specific hooks + transformGetOneResponse?(dto: TDTO): TEntity; + transformGetManyResponse?(dtos: TDTO[]): TEntity[]; + transformCreatePayload?(entity: Partial): Partial; + transformEditPayload?(entity: Partial): Partial; +} +``` + +### `BaseDataTransformer` + +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 { + 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> { + const response = await this.customRequest({ + 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 { + transformToEntity(dto: MyDTO): MyEntity { /* ... */ } + transformToDTO(entity: MyEntity): MyDTO { /* ... */ } +} +``` + +### Step 2: Add a Second Generic Parameter + +```diff +- const services = new CommonRemoteDataServices(apiClient, { ++ const services = new CommonRemoteDataServices(apiClient, { + apiUrl: '/my-endpoint', ++ transformer: new MyTransformer(), + }); +``` + +### Step 3: (Optional) Override Operation-Specific Hooks + +```typescript +class MyTransformer extends BaseDataTransformer { + 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()` | diff --git a/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts new file mode 100644 index 0000000..929c940 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts @@ -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 { + /** + * 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> { + const response = await this.customRequest({ + 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(); diff --git a/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts new file mode 100644 index 0000000..657d803 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts @@ -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, + })); + } +} diff --git a/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts b/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts index 2c2d8a0..b65ce28 100644 --- a/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts +++ b/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts @@ -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(apiClient, { +export const bookingServices = new CommonRemoteDataServices(apiClient, { apiUrl: '/bookings', moduleKey: 'BOOKING', + transformer: new BookingTransformer(), }); + diff --git a/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts new file mode 100644 index 0000000..b37bc63 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts @@ -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 { + /** + * 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): Partial { + const dto = this.transformToDTO(entity as BookingEntity); + const { id: _, ...rest } = dto; + return rest; + } +} diff --git a/packages/core-api/src/data-services/base-data.transformer.ts b/packages/core-api/src/data-services/base-data.transformer.ts new file mode 100644 index 0000000..58dfc53 --- /dev/null +++ b/packages/core-api/src/data-services/base-data.transformer.ts @@ -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 { + * 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): Partial; + + /** + * Transform the payload before an `edit()` call. + * If not provided, falls back to `transformToDTO`. + */ + transformEditPayload?(entity: Partial): Partial; +} + +// ─── 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 { + * 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 +{ + /** + * 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): Partial { + return this.transformToDTO(entity as TEntity) as Partial; + } + + /** + * 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): Partial { + return this.transformToDTO(entity as TEntity) as Partial; + } +} diff --git a/packages/core-api/src/data-services/base-remote.data-services.test.ts b/packages/core-api/src/data-services/base-remote.data-services.test.ts index b19104b..abed529 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.test.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.test.ts @@ -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 { + 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): Partial { + const dto = super.transformCreatePayload(entity); + return { ...dto, id: undefined }; + } + + override transformEditPayload(entity: Partial): Partial { + 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).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).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).mock.calls[0][0]; + expect(requestArg.data).toEqual(entityData); + }); + }); + + // ── With Base Transformer (core methods) ────────────────────── + + describe('with transformer (core transformToEntity / transformToDTO)', () => { + let services: CommonRemoteDataServices; + const transformer = new TestTransformer(); + + beforeEach(() => { + services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer, + }); + }); + + it('getOne() transforms API DTO to domain entity', async () => { + (mockClient.request as ReturnType).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).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).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).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).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).mock.calls[0][0]; + expect(requestArg.data).toEqual({ items: [] }); + }); + }); + + // ── With Custom Hook Transformer ────────────────────────────── + + describe('with custom operation-specific hooks', () => { + let services: CommonRemoteDataServices; + const transformer = new CustomHookTransformer(); + + beforeEach(() => { + services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer, + }); + }); + + it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => { + (mockClient.request as ReturnType).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).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).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).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).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).mockResolvedValueOnce({ + data: rawDTO, + status: 200, + }); + + const mockTransformer: IDataTransformer = { + 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(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 = { + bookingCode: 'BK001', + customerName: 'Alice', + }; + + const mockTransformer: IDataTransformer = { + 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(mockClient, { + apiUrl: '/bookings', + transformer: mockTransformer, + }); + + await services.create(entityData); + + expect(mockTransformer.transformCreatePayload).toHaveBeenCalledWith(entityData); + expect(mockTransformer.transformToDTO).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core-api/src/data-services/base-remote.data-services.ts b/packages/core-api/src/data-services/base-remote.data-services.ts index 51e875f..1efac40 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.ts @@ -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()` 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 {} * * 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('42'); * ``` */ -export abstract class BaseRemoteDataServices { +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 /** 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 | undefined; + + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { this.httpClient = httpClient; this.moduleKey = config.moduleKey; + this.transformer = config.transformer; this.urls = { ...makeDefaultURLs(config.apiUrl ?? ''), @@ -143,31 +165,85 @@ export abstract class BaseRemoteDataServices // ─── CRUD Operations ─────────────────────────────────────────── - /** Fetch a paginated list of entities. */ - getMany(config?: AxiosRequestConfig): Promise> { - return this.execute(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(config?: AxiosRequestConfig): Promise> { + const result = await this.execute(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; + } + + return result; } - /** Fetch a single entity by ID. */ - getOne(id: string, config?: AxiosRequestConfig): Promise> { - return this.execute(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(id: string, config?: AxiosRequestConfig): Promise> { + const result = await this.execute(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; + } + + 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(data: Partial, config?: AxiosRequestConfig): Promise> { + const transformedData = this.transformer?.transformCreatePayload + ? this.transformer.transformCreatePayload(data) + : this.transformer + ? this.transformer.transformToDTO(data as E) + : data; + return this.execute(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(id: string, data: Partial, config?: AxiosRequestConfig): Promise> { + const transformedData = this.transformer?.transformEditPayload + ? this.transformer.transformEditPayload(data) + : this.transformer + ? this.transformer.transformToDTO(data as E) + : data; + return this.execute(DESCRIPTORS.edit, { variableURL: { id }, - config: { ...config, data }, + config: { ...config, data: transformedData }, }); } diff --git a/packages/core-api/src/data-services/common-remote.data-services.ts b/packages/core-api/src/data-services/common-remote.data-services.ts index bdcae39..a9d6d43 100644 --- a/packages/core-api/src/data-services/common-remote.data-services.ts +++ b/packages/core-api/src/data-services/common-remote.data-services.ts @@ -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( * 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( + * apiClient, + * { + * apiUrl: '/bookings', + * moduleKey: 'BOOKING', + * transformer: new BookingTransformer(), + * }, + * ); + * ``` + * + * @example + * ```ts * // For modules needing custom operations, extend the base: * class InvoiceDataServices extends BaseRemoteDataServices { * async calculateTax(invoiceId: string) { @@ -40,4 +54,6 @@ import { BaseRemoteDataServices } from './base-remote.data-services'; */ export class CommonRemoteDataServices< E extends BaseEntity = BaseEntity, -> extends BaseRemoteDataServices {} + TDTO = E, +> extends BaseRemoteDataServices {} + diff --git a/packages/core-api/src/data-services/index.ts b/packages/core-api/src/data-services/index.ts index edaf7ef..ff7ca20 100644 --- a/packages/core-api/src/data-services/index.ts +++ b/packages/core-api/src/data-services/index.ts @@ -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'; diff --git a/packages/core-api/src/data-services/types.ts b/packages/core-api/src/data-services/types.ts index 76ec63c..5c45e82 100644 --- a/packages/core-api/src/data-services/types.ts +++ b/packages/core-api/src/data-services/types.ts @@ -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; /** Override specific HTTP methods. */ methods?: Partial; + + /** + * 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; }