feat: standardize pagination and response structure for API data services

This commit is contained in:
Firman Ramdhani
2026-07-20 14:46:27 +07:00
parent efa1c086a0
commit 84ea3c5b3e
3 changed files with 152 additions and 44 deletions
@@ -1,5 +1,42 @@
import type { BaseEntity } from './types'; import type { BaseEntity } from './types';
export interface NestJSPaginationMeta {
currentPage: number;
itemsPerPage: number;
totalItems: number;
totalPages: number;
itemCount?: number;
[key: string]: any;
}
export interface StandardPaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: StandardPaginationMeta;
}
export function defaultTransformPaginationMeta(meta: any): StandardPaginationMeta {
if (!meta) {
return { page: 1, limit: 10, total: 0, totalPages: 0 };
}
return {
page: meta.currentPage ?? meta.page ?? 1,
limit: meta.itemsPerPage ?? meta.limit ?? 10,
total: meta.totalItems ?? meta.total ?? 0,
totalPages: meta.totalPages ?? 0,
};
}
export interface SingleResponse<T> {
data: T;
}
// ─── Core Transformer Interface ───────────────────────────────── // ─── Core Transformer Interface ─────────────────────────────────
/** /**
@@ -81,6 +118,12 @@ export interface IDataTransformer<TEntity extends BaseEntity = BaseEntity, TDTO
* If not provided, falls back to identity. * If not provided, falls back to identity.
*/ */
transformPayloadFilter?(filter: Record<string, any>): Record<string, any>; transformPayloadFilter?(filter: Record<string, any>): Record<string, any>;
/**
* Transform the pagination meta object from a `getMany()` call.
* If not provided, falls back to standardizing NestJS meta.
*/
transformPaginationMeta?(meta: any): StandardPaginationMeta;
} }
// ─── Abstract Base Transformer ────────────────────────────────── // ─── Abstract Base Transformer ──────────────────────────────────
@@ -232,4 +275,17 @@ export abstract class BaseDataTransformer<TEntity extends BaseEntity = BaseEntit
transformPayloadFilter(filter: Record<string, any>): Record<string, any> { transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
return filter; return filter;
} }
/**
* Transform the pagination meta object from a `getMany()` call.
*
* Override this if the API returns a completely different meta
* structure that the default NestJS parser cannot handle.
*
* @param meta - The raw meta object from the API response
* @returns The standardized pagination meta
*/
transformPaginationMeta(meta: any): StandardPaginationMeta {
return defaultTransformPaginationMeta(meta);
}
} }
@@ -10,7 +10,10 @@ import { BaseDataTransformer } from './base-data.transformer';
function createMockHttpClient(): AxiosInstance { function createMockHttpClient(): AxiosInstance {
return { return {
request: vi.fn().mockResolvedValue({ request: vi.fn().mockResolvedValue({
data: [{ id: '1', name: 'Test' }], data: {
data: [{ id: '1', name: 'Test' }],
meta: { currentPage: 1, itemsPerPage: 10, totalItems: 1, totalPages: 1 },
},
status: 200, status: 200,
}), }),
// Satisfy the AxiosInstance shape (unused properties) // Satisfy the AxiosInstance shape (unused properties)
@@ -224,14 +227,14 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
describe('response shape', () => { describe('response shape', () => {
it('returns { data, status } from the Axios response', async () => { it('returns { data, status } from the Axios response', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' }, data: { data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' } },
status: 200, status: 200,
}); });
const result = await services.getOne<TestEntity>('42'); const result = await services.getOne('42');
expect(result).toEqual({ expect(result).toEqual({
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' }, data: { data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' } },
status: 200, status: 200,
}); });
}); });
@@ -341,7 +344,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('getOne() returns raw API response unchanged', async () => { it('getOne() returns raw API response unchanged', async () => {
const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' }; const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTO, data: { data: rawDTO },
status: 200, status: 200,
}); });
@@ -350,7 +353,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
}); });
const result = await services.getOne('42'); const result = await services.getOne('42');
expect(result.data).toEqual(rawDTO); expect(result.data).toEqual({ data: rawDTO });
}); });
it('getMany() returns raw API response unchanged', async () => { it('getMany() returns raw API response unchanged', async () => {
@@ -359,7 +362,10 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' }, { id: '2', booking_code: 'BK002', customer_name: 'Bob' },
]; ];
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTOs, data: {
data: rawDTOs,
meta: { currentPage: 2, itemsPerPage: 10, totalItems: 2, totalPages: 1 },
},
status: 200, status: 200,
}); });
@@ -368,7 +374,10 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
}); });
const result = await services.getMany(); const result = await services.getMany();
expect(result.data).toEqual(rawDTOs); expect(result.data).toEqual({
data: rawDTOs,
meta: { page: 2, limit: 10, total: 2, totalPages: 1 },
});
}); });
it('create() sends entity data as-is without transformation', async () => { it('create() sends entity data as-is without transformation', async () => {
@@ -400,46 +409,57 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('getOne() transforms API DTO to domain entity', async () => { it('getOne() transforms API DTO to domain entity', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' }, data: { data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' } },
status: 200, status: 200,
}); });
const result = await services.getOne('42'); const result = await services.getOne('42');
expect(result.data).toEqual({ expect(result.data).toEqual({
id: '42', data: {
bookingCode: 'BK042', id: '42',
customerName: 'Alice', bookingCode: 'BK042',
customerName: 'Alice',
}
}); });
expect(result.status).toBe(200); expect(result.status).toBe(200);
}); });
it('getMany() transforms each DTO in the array to entities', async () => { it('getMany() transforms each DTO in the array to entities', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [ data: {
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }, data: [
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' }, { id: '1', booking_code: 'BK001', customer_name: 'Alice' },
], { id: '2', booking_code: 'BK002', customer_name: 'Bob' },
],
meta: { currentPage: 1, itemsPerPage: 15, totalItems: 2, totalPages: 1 }
},
status: 200, status: 200,
}); });
const result = await services.getMany(); const result = await services.getMany();
expect(result.data).toEqual([ expect(result.data).toEqual({
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' }, data: [
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' }, { id: '1', bookingCode: 'BK001', customerName: 'Alice' },
]); { id: '2', bookingCode: 'BK002', customerName: 'Bob' },
],
meta: { page: 1, limit: 15, total: 2, totalPages: 1 }
});
}); });
it('getMany() handles empty array response', async () => { it('getMany() handles empty array response', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [], data: { data: [], meta: {} },
status: 200, status: 200,
}); });
const result = await services.getMany(); const result = await services.getMany();
expect(result.data).toEqual([]); expect(result.data).toEqual({
data: [],
meta: { page: 1, limit: 10, total: 0, totalPages: 0 }
});
}); });
it('create() transforms entity payload to DTO before sending', async () => { it('create() transforms entity payload to DTO before sending', async () => {
@@ -510,28 +530,36 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => { it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', booking_code: 'BK042', customer_name: 'alice' }, data: { data: { id: '42', booking_code: 'BK042', customer_name: 'alice' } },
status: 200, status: 200,
}); });
const result = await services.getOne('42'); const result = await services.getOne('42');
expect(result.data).toEqual({ expect(result.data).toEqual({
id: '42', data: {
bookingCode: 'BK042', id: '42',
customerName: 'ALICE', // uppercased by custom hook bookingCode: 'BK042',
customerName: 'ALICE', // uppercased by custom hook
}
}); });
}); });
it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => { it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }], data: {
data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }],
meta: { currentPage: 1, itemsPerPage: 10, totalItems: 1, totalPages: 1 },
},
status: 200, status: 200,
}); });
const result = await services.getMany(); const result = await services.getMany();
expect(result.data).toEqual([{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }]); expect(result.data).toEqual({
data: [{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }],
meta: { page: 1, limit: 10, total: 1, totalPages: 1 }
});
}); });
it('create() uses transformCreatePayload hook (strips id)', async () => { it('create() uses transformCreatePayload hook (strips id)', async () => {
@@ -561,7 +589,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
describe('with identity transformer (default passthrough)', () => { describe('with identity transformer (default passthrough)', () => {
it('produces same results as no transformer', async () => { it('produces same results as no transformer', async () => {
const rawData = { id: '1', name: 'Test' }; const rawData = { data: { id: '1', name: 'Test' } };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValue({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValue({
data: rawData, data: rawData,
status: 200, status: 200,
@@ -592,7 +620,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('transformGetOneResponse receives the raw DTO from API', async () => { it('transformGetOneResponse receives the raw DTO from API', async () => {
const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' }; const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTO, data: { data: rawDTO },
status: 200, status: 200,
}); });
@@ -8,7 +8,12 @@ import type {
DataServicesConfig, DataServicesConfig,
EntityId, EntityId,
} from './types'; } from './types';
import type { IDataTransformer } from './base-data.transformer'; import {
type IDataTransformer,
type PaginatedResponse,
type SingleResponse,
defaultTransformPaginationMeta,
} from './base-data.transformer';
import type { ApiResponse } from '../http-client/types'; import type { ApiResponse } from '../http-client/types';
import { interpolateUrl } from './url-builder'; import { interpolateUrl } from './url-builder';
import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants'; import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
@@ -167,19 +172,34 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
* When a transformer is injected, the raw API response is passed * When a transformer is injected, the raw API response is passed
* through `transformGetManyResponse()` before being returned. * through `transformGetManyResponse()` before being returned.
*/ */
async getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> { async getMany<T = PaginatedResponse<E>>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const finalConfig = config?.params && this.transformer?.transformPayloadFilter const finalConfig =
? { ...config, params: this.transformer.transformPayloadFilter(config.params) } config?.params && this.transformer?.transformPayloadFilter
: config; ? { ...config, params: this.transformer.transformPayloadFilter(config.params) }
: config;
const result = await this.execute<T>(DESCRIPTORS.getMany, { config: finalConfig }); const result = await this.execute<T>(DESCRIPTORS.getMany, { config: finalConfig });
const responseData = result.data as unknown as PaginatedResponse<TDTO> & { meta: any };
if (responseData?.data && Array.isArray(responseData.data)) {
if (this.transformer) {
return {
...result,
data: {
...responseData,
data: this.transformer.transformGetManyResponse
? this.transformer.transformGetManyResponse(responseData.data)
: responseData.data.map((item: TDTO) => this.transformer!.transformToEntity(item)),
meta: this.transformer?.transformPaginationMeta
? this.transformer.transformPaginationMeta(responseData.meta)
: defaultTransformPaginationMeta(responseData.meta),
},
} as unknown as ApiResponse<T>;
}
if (this.transformer && Array.isArray(result.data)) {
return { return {
...result, ...result,
data: this.transformer.transformGetManyResponse data: responseData,
? 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>; } as unknown as ApiResponse<T>;
} }
@@ -192,18 +212,22 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
* When a transformer is injected, the raw API response is passed * When a transformer is injected, the raw API response is passed
* through `transformGetOneResponse()` before being returned. * through `transformGetOneResponse()` before being returned.
*/ */
async getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { async getOne<T = SingleResponse<E>>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const result = await this.execute<T>(DESCRIPTORS.getOne, { const result = await this.execute<T>(DESCRIPTORS.getOne, {
variableURL: { id }, variableURL: { id },
config, config,
}); });
const responseData = result.data as unknown as SingleResponse<TDTO>;
if (this.transformer && result.data != null) { if (this.transformer && responseData?.data != null) {
return { return {
...result, ...result,
data: this.transformer.transformGetOneResponse data: {
? this.transformer.transformGetOneResponse(result.data as unknown as TDTO) ...responseData,
: this.transformer.transformToEntity(result.data as unknown as TDTO), data: this.transformer.transformGetOneResponse
? this.transformer.transformGetOneResponse(responseData.data)
: this.transformer.transformToEntity(responseData.data),
},
} as unknown as ApiResponse<T>; } as unknown as ApiResponse<T>;
} }