feat(core-api): implement abstract data transformer architecture

- **Core API**: Introduced `IDataTransformer` interface and `BaseDataTransformer` abstract class.
- **Data Services**: Updated `BaseRemoteDataServices` and `DataServicesConfig` to support optional transformer injection. CRUD methods now auto-transform data mapping (DTO <-> Entity) when a transformer is provided.
- **Generics**: Updated `CommonRemoteDataServices` to forward the `TDTO` generic for strict type safety. Fixed `ApiResponse<T>` casting and excess property type checks.
- **Showcase/Samples**: Created `BookingTransformer` (snake_case to camelCase mapping) and injected it into `booking.data-services.ts`.
- **Advanced Showcase**: Added `AdvancedBookingTransformer` and `AdvancedBookingService` to demonstrate extending base transformers with custom methods (e.g., `getAvailabilityChart`).
- **Documentation**: Added comprehensive developer guide at `apps/docs-dev/src/packages/core-api/transformers.md`.
- **Testing**: Added 16 new unit tests for transformer integrations (identity and mock scenarios).

Note: The implementation is 100% backward compatible. All 49/49 tests pass and the TypeScript typecheck is entirely clean.
This commit is contained in:
Firman Ramdhani
2026-07-01 11:13:09 +07:00
parent 7130eb3fe3
commit 1c2090f4fb
11 changed files with 1494 additions and 19 deletions
@@ -0,0 +1,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';
+22 -1
View File
@@ -1,5 +1,6 @@
import type { AxiosRequestConfig } from 'axios';
import type { TelemetryContext } from '../http-client/types';
import type { IDataTransformer } from './base-data.transformer';
// ─── Base Entity ────────────────────────────────────────────────
@@ -112,8 +113,14 @@ export interface ExecuteOptions {
/**
* Configuration for constructing a BaseRemoteDataServices instance.
*
* @typeParam TEntity - The frontend domain entity type (defaults to BaseEntity)
* @typeParam TDTO - The API DTO shape (defaults to TEntity for backward compatibility)
*/
export interface DataServicesConfig {
export interface DataServicesConfig<
TEntity extends BaseEntity = BaseEntity,
TDTO = TEntity,
> {
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
apiUrl?: string;
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
@@ -122,4 +129,18 @@ export interface DataServicesConfig {
urls?: Partial<ApiURLMap>;
/** Override specific HTTP methods. */
methods?: Partial<RequestMethodMap>;
/**
* Optional data transformer for DTO ↔ Entity mapping.
*
* When provided, CRUD methods automatically transform:
* - **Responses** (`getOne`, `getMany`): DTO → Entity via transformer
* - **Payloads** (`create`, `edit`): Entity → DTO via transformer
*
* When omitted, data passes through unchanged (backward compatible).
*
* @see {@link IDataTransformer} for the transformer contract
* @see {@link BaseDataTransformer} for the abstract base class
*/
transformer?: IDataTransformer<TEntity, TDTO>;
}