From 84ea3c5b3ed9f6ad5fcfda2db355c0312a3187be Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:46:27 +0700 Subject: [PATCH] feat: standardize pagination and response structure for API data services --- .../data-services/base-data.transformer.ts | 56 ++++++++++++ .../base-remote.data-services.test.ts | 88 ++++++++++++------- .../base-remote.data-services.ts | 52 ++++++++--- 3 files changed, 152 insertions(+), 44 deletions(-) diff --git a/packages/core-api/src/data-services/base-data.transformer.ts b/packages/core-api/src/data-services/base-data.transformer.ts index 3b923a8..cf2b869 100644 --- a/packages/core-api/src/data-services/base-data.transformer.ts +++ b/packages/core-api/src/data-services/base-data.transformer.ts @@ -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 { + 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 { + data: T; +} + // ─── Core Transformer Interface ───────────────────────────────── /** @@ -81,6 +118,12 @@ export interface IDataTransformer): Record; + + /** + * 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): Record { 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); + } } diff --git a/packages/core-api/src/data-services/base-remote.data-services.test.ts b/packages/core-api/src/data-services/base-remote.data-services.test.ts index 0d694d2..2ea963c 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.test.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.test.ts @@ -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).mockResolvedValueOnce({ - data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' }, + data: { data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' } }, status: 200, }); - const result = await services.getOne('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).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).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).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).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).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).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).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).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).mockResolvedValueOnce({ - data: rawDTO, + data: { data: rawDTO }, status: 200, }); diff --git a/packages/core-api/src/data-services/base-remote.data-services.ts b/packages/core-api/src/data-services/base-remote.data-services.ts index e0cc24f..5eb61aa 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.ts @@ -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(config?: AxiosRequestConfig): Promise> { - const finalConfig = config?.params && this.transformer?.transformPayloadFilter - ? { ...config, params: this.transformer.transformPayloadFilter(config.params) } - : config; + async getMany>(config?: AxiosRequestConfig): Promise> { + const finalConfig = + config?.params && this.transformer?.transformPayloadFilter + ? { ...config, params: this.transformer.transformPayloadFilter(config.params) } + : config; const result = await this.execute(DESCRIPTORS.getMany, { config: finalConfig }); + const responseData = result.data as unknown as PaginatedResponse & { 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; + } - 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; } @@ -192,18 +212,22 @@ export abstract class BaseRemoteDataServices(id: string, config?: AxiosRequestConfig): Promise> { + async getOne>(id: string, config?: AxiosRequestConfig): Promise> { const result = await this.execute(DESCRIPTORS.getOne, { variableURL: { id }, config, }); + const responseData = result.data as unknown as SingleResponse; - 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; }