feat(core-api): implement abstract data transformer architecture
- **Core API**: Introduced `IDataTransformer` interface and `BaseDataTransformer` abstract class. - **Data Services**: Updated `BaseRemoteDataServices` and `DataServicesConfig` to support optional transformer injection. CRUD methods now auto-transform data mapping (DTO <-> Entity) when a transformer is provided. - **Generics**: Updated `CommonRemoteDataServices` to forward the `TDTO` generic for strict type safety. Fixed `ApiResponse<T>` casting and excess property type checks. - **Showcase/Samples**: Created `BookingTransformer` (snake_case to camelCase mapping) and injected it into `booking.data-services.ts`. - **Advanced Showcase**: Added `AdvancedBookingTransformer` and `AdvancedBookingService` to demonstrate extending base transformers with custom methods (e.g., `getAvailabilityChart`). - **Documentation**: Added comprehensive developer guide at `apps/docs-dev/src/packages/core-api/transformers.md`. - **Testing**: Added 16 new unit tests for transformer integrations (identity and mock scenarios). Note: The implementation is 100% backward compatible. All 49/49 tests pass and the TypeScript typecheck is entirely clean.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import type { BaseEntity } from './types';
|
||||
|
||||
// ─── Core Transformer Interface ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal contract for bidirectional data transformation
|
||||
* between API Data Transfer Objects (DTOs) and domain entities.
|
||||
*
|
||||
* Implement this interface when you only need the two core
|
||||
* mapping methods without operation-specific hooks.
|
||||
*
|
||||
* @typeParam TEntity - The frontend domain entity type
|
||||
* @typeParam TDTO - The API response/request DTO shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class UserTransformer implements IDataTransformer<UserEntity, UserDTO> {
|
||||
* transformToEntity(dto: UserDTO): UserEntity {
|
||||
* return { id: dto.id, fullName: `${dto.first_name} ${dto.last_name}` };
|
||||
* }
|
||||
* transformToDTO(entity: UserEntity): UserDTO {
|
||||
* const [first, ...rest] = entity.fullName.split(' ');
|
||||
* return { id: entity.id, first_name: first, last_name: rest.join(' ') };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface IDataTransformer<
|
||||
TEntity extends BaseEntity = BaseEntity,
|
||||
TDTO = TEntity,
|
||||
> {
|
||||
/**
|
||||
* Transform an API DTO into a domain entity.
|
||||
*
|
||||
* Called after receiving data from the API. Use this to map
|
||||
* snake_case fields to camelCase, flatten nested structures,
|
||||
* compute derived fields, or apply any normalization.
|
||||
*
|
||||
* @param dto - Raw data from the API response
|
||||
* @returns The mapped domain entity
|
||||
*/
|
||||
transformToEntity(dto: TDTO): TEntity;
|
||||
|
||||
/**
|
||||
* Transform a domain entity into an API DTO.
|
||||
*
|
||||
* Called before sending data to the API. Use this to map
|
||||
* camelCase fields to snake_case, restructure nested objects,
|
||||
* or strip frontend-only computed fields.
|
||||
*
|
||||
* @param entity - Domain entity from the frontend
|
||||
* @returns The mapped DTO for the API request
|
||||
*/
|
||||
transformToDTO(entity: TEntity): TDTO;
|
||||
|
||||
// ─── Optional Operation-Specific Hooks ──────────────────────
|
||||
|
||||
/**
|
||||
* Transform the response of a `getOne()` call.
|
||||
* If not provided, falls back to `transformToEntity`.
|
||||
*/
|
||||
transformGetOneResponse?(dto: TDTO): TEntity;
|
||||
|
||||
/**
|
||||
* Transform the response of a `getMany()` call.
|
||||
* If not provided, falls back to mapping each item via `transformToEntity`.
|
||||
*/
|
||||
transformGetManyResponse?(dtos: TDTO[]): TEntity[];
|
||||
|
||||
/**
|
||||
* Transform the payload before a `create()` call.
|
||||
* If not provided, falls back to `transformToDTO`.
|
||||
*/
|
||||
transformCreatePayload?(entity: Partial<TEntity>): Partial<TDTO>;
|
||||
|
||||
/**
|
||||
* Transform the payload before an `edit()` call.
|
||||
* If not provided, falls back to `transformToDTO`.
|
||||
*/
|
||||
transformEditPayload?(entity: Partial<TEntity>): Partial<TDTO>;
|
||||
}
|
||||
|
||||
// ─── Abstract Base Transformer ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Abstract base class providing operation-specific transformation
|
||||
* hooks with sensible passthrough defaults.
|
||||
*
|
||||
* Extends the core `IDataTransformer` contract with granular hooks
|
||||
* for each CRUD operation. Override only the hooks you need —
|
||||
* unoverridden hooks delegate to the core `transformToEntity` /
|
||||
* `transformToDTO` methods.
|
||||
*
|
||||
* **Design rationale (from legacy analysis):**
|
||||
* The legacy `BaseTransformer` used operation-specific methods
|
||||
* (`transformerGetOne`, `transformerCreate`, etc.) because different
|
||||
* operations often need different transformations. For example,
|
||||
* `getOne` might need to compute derived fields, while `create`
|
||||
* might need to strip IDs. This pattern is preserved here with
|
||||
* proper typing.
|
||||
*
|
||||
* @typeParam TEntity - The frontend domain entity type
|
||||
* @typeParam TDTO - The API response/request DTO shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
|
||||
* transformToEntity(dto: BookingDTO): BookingEntity {
|
||||
* return {
|
||||
* id: dto.id,
|
||||
* bookingCode: dto.booking_code,
|
||||
* customerName: dto.customer_name,
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* transformToDTO(entity: BookingEntity): BookingDTO {
|
||||
* return {
|
||||
* id: entity.id,
|
||||
* booking_code: entity.bookingCode,
|
||||
* customer_name: entity.customerName,
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* // Override only when getOne needs extra computed fields
|
||||
* transformGetOneResponse(dto: BookingDTO): BookingEntity {
|
||||
* const entity = this.transformToEntity(dto);
|
||||
* return { ...entity, durationNights: computeNights(dto) };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class BaseDataTransformer<
|
||||
TEntity extends BaseEntity = BaseEntity,
|
||||
TDTO = TEntity,
|
||||
> implements IDataTransformer<TEntity, TDTO>
|
||||
{
|
||||
/**
|
||||
* Core DTO → Entity transformation.
|
||||
*
|
||||
* Default implementation performs an identity cast.
|
||||
* Override this in concrete transformers to provide
|
||||
* the actual mapping logic.
|
||||
*
|
||||
* @param dto - Raw data from the API response
|
||||
* @returns The mapped domain entity
|
||||
*/
|
||||
transformToEntity(dto: TDTO): TEntity {
|
||||
return dto as unknown as TEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Entity → DTO transformation.
|
||||
*
|
||||
* Default implementation performs an identity cast.
|
||||
* Override this in concrete transformers to provide
|
||||
* the actual mapping logic.
|
||||
*
|
||||
* @param entity - Domain entity from the frontend
|
||||
* @returns The mapped DTO for the API request
|
||||
*/
|
||||
transformToDTO(entity: TEntity): TDTO {
|
||||
return entity as unknown as TDTO;
|
||||
}
|
||||
|
||||
// ─── Operation-Specific Hooks ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Transform the response of a `getOne()` call.
|
||||
*
|
||||
* Override this when `getOne` needs additional computed fields
|
||||
* or different mapping than the default `transformToEntity`.
|
||||
*
|
||||
* @param dto - Single DTO from the API response
|
||||
* @returns The mapped domain entity
|
||||
*/
|
||||
transformGetOneResponse(dto: TDTO): TEntity {
|
||||
return this.transformToEntity(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the response of a `getMany()` call.
|
||||
*
|
||||
* Override this when list responses need bulk transformations
|
||||
* (e.g., sorting, filtering, or status normalization) beyond
|
||||
* per-item mapping.
|
||||
*
|
||||
* @param dtos - Array of DTOs from the API response
|
||||
* @returns Array of mapped domain entities
|
||||
*/
|
||||
transformGetManyResponse(dtos: TDTO[]): TEntity[] {
|
||||
return dtos.map((dto) => this.transformToEntity(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the payload before a `create()` call.
|
||||
*
|
||||
* Override this when create payloads need special handling
|
||||
* (e.g., stripping IDs, formatting dates, converting nested
|
||||
* structures).
|
||||
*
|
||||
* @param entity - Partial entity data from the frontend
|
||||
* @returns The mapped partial DTO for the API request
|
||||
*/
|
||||
transformCreatePayload(entity: Partial<TEntity>): Partial<TDTO> {
|
||||
return this.transformToDTO(entity as TEntity) as Partial<TDTO>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the payload before an `edit()` call.
|
||||
*
|
||||
* Override this when edit payloads need special handling
|
||||
* that differs from create (e.g., preserving certain
|
||||
* read-only fields, handling delta updates).
|
||||
*
|
||||
* @param entity - Partial entity data from the frontend
|
||||
* @returns The mapped partial DTO for the API request
|
||||
*/
|
||||
transformEditPayload(entity: Partial<TEntity>): Partial<TDTO> {
|
||||
return this.transformToDTO(entity as TEntity) as Partial<TDTO>;
|
||||
}
|
||||
}
|
||||
@@ -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