feat: standardize pagination and response structure for API data services
This commit is contained in:
@@ -1,5 +1,42 @@
|
||||
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 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -81,6 +118,12 @@ export interface IDataTransformer<TEntity extends BaseEntity = BaseEntity, TDTO
|
||||
* If not provided, falls back to identity.
|
||||
*/
|
||||
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 ──────────────────────────────────
|
||||
@@ -232,4 +275,17 @@ export abstract class BaseDataTransformer<TEntity extends BaseEntity = BaseEntit
|
||||
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
||||
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 {
|
||||
return {
|
||||
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,
|
||||
}),
|
||||
// Satisfy the AxiosInstance shape (unused properties)
|
||||
@@ -224,14 +227,14 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
|
||||
describe('response shape', () => {
|
||||
it('returns { data, status } from the Axios response', async () => {
|
||||
(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,
|
||||
});
|
||||
|
||||
const result = await services.getOne<TestEntity>('42');
|
||||
const result = await services.getOne('42');
|
||||
|
||||
expect(result).toEqual({
|
||||
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' },
|
||||
data: { data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' } },
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
@@ -341,7 +344,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
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,
|
||||
data: { data: rawDTO },
|
||||
status: 200,
|
||||
});
|
||||
|
||||
@@ -350,7 +353,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -359,7 +362,10 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
|
||||
];
|
||||
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: rawDTOs,
|
||||
data: {
|
||||
data: rawDTOs,
|
||||
meta: { currentPage: 2, itemsPerPage: 10, totalItems: 2, totalPages: 1 },
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
|
||||
@@ -368,7 +374,10 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -400,46 +409,57 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
|
||||
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' },
|
||||
data: { 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',
|
||||
data: {
|
||||
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' },
|
||||
],
|
||||
data: {
|
||||
data: [
|
||||
{ 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,
|
||||
});
|
||||
|
||||
const result = await services.getMany();
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' },
|
||||
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' },
|
||||
]);
|
||||
expect(result.data).toEqual({
|
||||
data: [
|
||||
{ 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 () => {
|
||||
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: [],
|
||||
data: { data: [], meta: {} },
|
||||
status: 200,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -510,28 +530,36 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
|
||||
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' },
|
||||
data: { 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
|
||||
data: {
|
||||
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' }],
|
||||
data: {
|
||||
data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }],
|
||||
meta: { currentPage: 1, itemsPerPage: 10, totalItems: 1, totalPages: 1 },
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -561,7 +589,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
|
||||
describe('with identity transformer (default passthrough)', () => {
|
||||
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({
|
||||
data: rawData,
|
||||
status: 200,
|
||||
@@ -592,7 +620,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
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,
|
||||
data: { data: rawDTO },
|
||||
status: 200,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ import type {
|
||||
DataServicesConfig,
|
||||
EntityId,
|
||||
} 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 { interpolateUrl } from './url-builder';
|
||||
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
|
||||
* through `transformGetManyResponse()` before being returned.
|
||||
*/
|
||||
async getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const finalConfig = config?.params && this.transformer?.transformPayloadFilter
|
||||
? { ...config, params: this.transformer.transformPayloadFilter(config.params) }
|
||||
: config;
|
||||
async getMany<T = PaginatedResponse<E>>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const finalConfig =
|
||||
config?.params && this.transformer?.transformPayloadFilter
|
||||
? { ...config, params: this.transformer.transformPayloadFilter(config.params) }
|
||||
: config;
|
||||
|
||||
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 {
|
||||
...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)),
|
||||
data: responseData,
|
||||
} 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
|
||||
* 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, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
const responseData = result.data as unknown as SingleResponse<TDTO>;
|
||||
|
||||
if (this.transformer && result.data != null) {
|
||||
if (this.transformer && responseData?.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),
|
||||
data: {
|
||||
...responseData,
|
||||
data: this.transformer.transformGetOneResponse
|
||||
? this.transformer.transformGetOneResponse(responseData.data)
|
||||
: this.transformer.transformToEntity(responseData.data),
|
||||
},
|
||||
} as unknown as ApiResponse<T>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user