feat: standardize pagination and response structure for API data services
This commit is contained in:
@@ -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: {
|
||||||
data: [{ id: '1', name: 'Test' }],
|
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: {
|
||||||
data: rawDTOs,
|
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({
|
||||||
|
data: {
|
||||||
id: '42',
|
id: '42',
|
||||||
bookingCode: 'BK042',
|
bookingCode: 'BK042',
|
||||||
customerName: 'Alice',
|
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: [
|
data: [
|
||||||
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
|
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
|
||||||
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
|
{ 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({
|
||||||
|
data: [
|
||||||
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' },
|
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' },
|
||||||
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' },
|
{ 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({
|
||||||
|
data: {
|
||||||
id: '42',
|
id: '42',
|
||||||
bookingCode: 'BK042',
|
bookingCode: 'BK042',
|
||||||
customerName: 'ALICE', // uppercased by custom hook
|
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: {
|
||||||
data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }],
|
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: this.transformer.transformPayloadFilter(config.params) }
|
? { ...config, params: this.transformer.transformPayloadFilter(config.params) }
|
||||||
: config;
|
: 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 (this.transformer && Array.isArray(result.data)) {
|
if (responseData?.data && Array.isArray(responseData.data)) {
|
||||||
|
if (this.transformer) {
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
data: {
|
||||||
|
...responseData,
|
||||||
data: this.transformer.transformGetManyResponse
|
data: this.transformer.transformGetManyResponse
|
||||||
? this.transformer.transformGetManyResponse(result.data as unknown as TDTO[])
|
? this.transformer.transformGetManyResponse(responseData.data)
|
||||||
: result.data.map((item: unknown) => this.transformer!.transformToEntity(item as TDTO)),
|
: 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: responseData,
|
||||||
} 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: {
|
||||||
|
...responseData,
|
||||||
data: this.transformer.transformGetOneResponse
|
data: this.transformer.transformGetOneResponse
|
||||||
? this.transformer.transformGetOneResponse(result.data as unknown as TDTO)
|
? this.transformer.transformGetOneResponse(responseData.data)
|
||||||
: this.transformer.transformToEntity(result.data as unknown as TDTO),
|
: this.transformer.transformToEntity(responseData.data),
|
||||||
|
},
|
||||||
} as unknown as ApiResponse<T>;
|
} as unknown as ApiResponse<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user