From 52747212921d3c3a1bf98bd2badc8e57fe7596e5 Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Thu, 29 Oct 2026 10:14:01 -0400 Subject: [PATCH] Move CFDocuments pagination metadata to response headers (CGE-19, CGE-176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paginated getAllCFDocuments responses no longer include total, limit or offset in the JSON body. CFDocumentSet doesn't allow extra properties, so these fields broke schema validation. List endpoints now send the total count in an X-Total-Count header, as the CASE REST binding (§3.1) requires. When the request includes limit or offset, getAllCFDocuments and getCFItemAssociations also send first/prev/next/last links in a Link header. Both headers are exposed through CORS and documented in the OpenAPI spec. --- .../case/endpoints/GetAllCFDocuments.ts | 10 ++- .../__tests__/GetAllCFDocuments.test.ts | 12 ++-- .../http/OpenAPISpecGenerator.ts | 23 +++++-- .../v1p0/controllers/CFConceptsController.ts | 1 + .../CFItemAssociationsController.ts | 5 +- .../v1p0/controllers/CFItemTypesController.ts | 1 + .../v1p0/controllers/CFSubjectsController.ts | 1 + .../GetAllCFDocumentsController.ts | 7 ++- .../v1p1/controllers/CFConceptsController.ts | 1 + .../CFItemAssociationsController.ts | 5 +- .../v1p1/controllers/CFItemTypesController.ts | 1 + .../v1p1/controllers/CFSubjectsController.ts | 1 + .../GetAllCFDocumentsController.ts | 7 ++- .../CFItemAssociationsController.test.ts | 19 +++++- .../GetAllCFDocumentsController.test.ts | 48 +++++++++----- .../v1p1/utils/__tests__/httpUtils.test.ts | 63 ++++++++++++++++++- .../http/http-public/v1p1/utils/httpUtils.ts | 46 +++++++++++++- apps/opencase/src/interfaces/http/server.ts | 4 +- 18 files changed, 213 insertions(+), 42 deletions(-) diff --git a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts index 939ac11..fe034ed 100644 --- a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts +++ b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts @@ -147,14 +147,12 @@ export class GetAllCFDocuments { return doc }) + // `total` is the collection size before pagination. It is not part of the CFDocumentSet + // body (the CASE schema forbids additional properties) — controllers send it in the + // X-Total-Count header. return { CFDocuments: cfDocuments, - // Pagination metadata (if pagination is used) - ...(limit !== undefined && { - total, - limit, - offset - }) + total } } } diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts index c8a5d24..b8d3027 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts @@ -23,7 +23,8 @@ describe('GetAllCFDocuments', () => { const result = await getAllCFDocuments.execute({ tenantId, caseVersion }) expect(result).toEqual({ - CFDocuments: [] + CFDocuments: [], + total: 0 }) }) @@ -79,7 +80,8 @@ describe('GetAllCFDocuments', () => { identifier: 'doc-2' }) }) - ]) + ]), + total: 2 }) }) @@ -108,10 +110,10 @@ describe('GetAllCFDocuments', () => { expect.objectContaining({ identifier: 'doc-5' }), expect.objectContaining({ identifier: 'doc-6' }) ]), - total: 10, - limit: 5, - offset: 2 + total: 10 }) + expect(result).not.toHaveProperty('limit') + expect(result).not.toHaveProperty('offset') expect(result.CFDocuments).toHaveLength(5) }) diff --git a/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts b/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts index 63f535a..86b55c4 100644 --- a/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts +++ b/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts @@ -205,6 +205,10 @@ export class OpenAPISpecGenerator { responses: { 200: { description: 'This is the response when the request has been completed successfully.', + headers: { + 'X-Total-Count': { $ref: '#/components/headers/X-Total-Count' }, + Link: { $ref: '#/components/headers/Link' } + }, content: { 'application/json': { schema: { $ref: '#/components/schemas/CFDocumentSetDType' } @@ -310,6 +314,10 @@ export class OpenAPISpecGenerator { responses: { 200: { description: 'This is the response when the request has been completed successfully.', + headers: { + 'X-Total-Count': { $ref: '#/components/headers/X-Total-Count' }, + Link: { $ref: '#/components/headers/Link' } + }, content: { 'application/json': { schema: { $ref: '#/components/schemas/CFAssociationSetDType' } @@ -1620,6 +1628,16 @@ export class OpenAPISpecGenerator { // Note: legacy /oauth/* endpoints were removed in favor of external OIDC (Keycloak). }, components: { + headers: { + 'X-Total-Count': { + description: 'The total number of records in the collection (CASE REST binding §3.1).', + schema: { type: 'integer', minimum: 0 } + }, + Link: { + description: 'Pagination links (first, prev, next, last) per RFC 8288. Only supplied when the request includes limit or offset.', + schema: { type: 'string' } + } + }, schemas: { CFPackageDType: { type: 'object', @@ -1895,10 +1913,7 @@ export class OpenAPISpecGenerator { CFDocuments: { type: 'array', items: { $ref: '#/components/schemas/CFDocumentDType' } - }, - total: { type: 'integer' }, - limit: { type: 'integer' }, - offset: { type: 'integer' } + } } }, CFAssociationSetDType: { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFConceptsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFConceptsController.ts index 0fdc1eb..9590957 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFConceptsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFConceptsController.ts @@ -40,6 +40,7 @@ export class CFConceptsControllerV1p0 { } let body = absolutizeCaseUris(entity, baseUrl, '1.0') if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + res.setHeader('X-Total-Count', String(result.CFConcepts.length)) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemAssociationsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemAssociationsController.ts index 90ff3dc..61cf9ed 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemAssociationsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemAssociationsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFItemAssociations } from '../../../../../application/case/endpoints/GetCFItemAssociations' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyQueryToArray, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyQueryToArray, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, setPaginationHeaders, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -48,6 +48,7 @@ export class CFItemAssociationsControllerV1p0 { } let responseBody: any = { ...result } + let total = 0 if (result?.CFAssociations) { const applied = applyQueryToArray(result.CFAssociations, parsed.value) if (!applied.ok) return res.status(applied.status).json(applied.body) @@ -55,11 +56,13 @@ export class CFItemAssociationsControllerV1p0 { CFItem: result.CFItem, CFAssociations: applied.items } + total = applied.total } const baseUrl = getBaseUrl(req) let body = absolutizeCaseUris(responseBody, baseUrl, '1.0') if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + setPaginationHeaders(req, res, total, parsed.value) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemTypesController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemTypesController.ts index 97c4c73..a04e688 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemTypesController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFItemTypesController.ts @@ -40,6 +40,7 @@ export class CFItemTypesControllerV1p0 { } let body = absolutizeCaseUris(entity, baseUrl, '1.0') if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + res.setHeader('X-Total-Count', String(result.CFItemTypes.length)) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFSubjectsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFSubjectsController.ts index e777f34..f04c4e9 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFSubjectsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFSubjectsController.ts @@ -40,6 +40,7 @@ export class CFSubjectsControllerV1p0 { } let body = absolutizeCaseUris(entity, baseUrl, '1.0') if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + res.setHeader('X-Total-Count', String(result.CFSubjects.length)) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts index 9af5248..882f08c 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetAllCFDocuments } from '../../../../../application/case/endpoints/GetAllCFDocuments' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, setPaginationHeaders, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' export class GetAllCFDocumentsControllerV1p0 { constructor (private readonly getAllCFDocuments: GetAllCFDocuments) {} @@ -22,7 +22,7 @@ export class GetAllCFDocumentsControllerV1p0 { // by default. Callers can opt out with includeArchived=false. const includeArchived = req.query.includeArchived !== 'false' - const result = await this.getAllCFDocuments.execute({ + const { total, ...documentSet } = await this.getAllCFDocuments.execute({ tenantId, caseVersion: '1.0', limit, @@ -35,8 +35,9 @@ export class GetAllCFDocumentsControllerV1p0 { }) const baseUrl = getBaseUrl(req) - let body = absolutizeCaseUris(result, baseUrl, '1.0') + let body = absolutizeCaseUris(documentSet, baseUrl, '1.0') if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + setPaginationHeaders(req, res, total, parsed.value) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts index 4a1c0c1..e1214fd 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts @@ -45,6 +45,7 @@ export class CFConceptsControllerV1p1 { } let body = absolutizeCaseUris(entity, baseUrl) if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } + res.setHeader('X-Total-Count', String(result.CFConcepts.length)) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts index 9bded75..68d2524 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFItemAssociations } from '../../../../../application/case/endpoints/GetCFItemAssociations' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyQueryToArray, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyQueryToArray, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, setPaginationHeaders, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -53,6 +53,7 @@ export class CFItemAssociationsControllerV1p1 { // Apply query params to the associations array (filter/sort/paging/fields) let responseBody: any = { ...result } + let total = 0 if (result?.CFAssociations) { const applied = applyQueryToArray(result.CFAssociations, parsed.value) if (!applied.ok) return res.status(applied.status).json(applied.body) @@ -60,11 +61,13 @@ export class CFItemAssociationsControllerV1p1 { CFItem: result.CFItem, CFAssociations: applied.items } + total = applied.total } const baseUrl = getBaseUrl(req) let body = absolutizeCaseUris(responseBody, baseUrl) if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } + setPaginationHeaders(req, res, total, parsed.value) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts index 0fa090c..4c3300d 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts @@ -45,6 +45,7 @@ export class CFItemTypesControllerV1p1 { } let body = absolutizeCaseUris(entity, baseUrl) if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } + res.setHeader('X-Total-Count', String(result.CFItemTypes.length)) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts index adde937..60be5e2 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts @@ -45,6 +45,7 @@ export class CFSubjectsControllerV1p1 { } let body = absolutizeCaseUris(entity, baseUrl) if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } + res.setHeader('X-Total-Count', String(result.CFSubjects.length)) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts index b0cb481..57b8c4a 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetAllCFDocuments } from '../../../../../application/case/endpoints/GetAllCFDocuments' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, setPaginationHeaders, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' export class GetAllCFDocumentsControllerV1p1 { constructor (private readonly getAllCFDocuments: GetAllCFDocuments) {} @@ -22,7 +22,7 @@ export class GetAllCFDocumentsControllerV1p1 { // by default. Callers can opt out with includeArchived=false. const includeArchived = req.query.includeArchived !== 'false' - const result = await this.getAllCFDocuments.execute({ + const { total, ...documentSet } = await this.getAllCFDocuments.execute({ tenantId, caseVersion: '1.1', limit, @@ -35,8 +35,9 @@ export class GetAllCFDocumentsControllerV1p1 { }) const baseUrl = getBaseUrl(req) - let body = absolutizeCaseUris(result, baseUrl) + let body = absolutizeCaseUris(documentSet, baseUrl) if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } + setPaginationHeaders(req, res, total, parsed.value) if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFItemAssociationsController.test.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFItemAssociationsController.test.ts index da20e9a..d86e8a0 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFItemAssociationsController.test.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFItemAssociationsController.test.ts @@ -83,7 +83,24 @@ describe('CFItemAssociationsControllerV1p1', () => { expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost')) }) - it('should return 404 when item is not found', async () => { + it('should send X-Total-Count and Link headers for a paginated request', async () => { + const assoc = (i: number) => ({ identifier: `assoc-${i}`, uri: `/ims/case/v1p1/CFAssociations/assoc-${i}`, associationType: 'isChildOf' }) + mockGetCFItemAssociations.execute.mockResolvedValue({ + CFItem: { identifier: '550e8400-e29b-41d4-a716-446655440000' }, + CFAssociations: [assoc(0), assoc(1), assoc(2)] + } as any) + ;(mockRequest as any).originalUrl = '/ims/case/v1p1/CFItemAssociations/550e8400-e29b-41d4-a716-446655440000?limit=2' + mockRequest.query = { limit: '2' } + + await controller.getById(mockRequest as Request, mockResponse as Response) + + expect(responseStatus).toHaveBeenCalledWith(200) + expect(responseJson.mock.calls[0][0].CFAssociations).toHaveLength(2) + expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Total-Count', '3') + expect(mockResponse.setHeader).toHaveBeenCalledWith('Link', expect.stringContaining('offset=2>; rel="next"')) + }) + + it('should return 404 when item is not found', async () => { mockStore.resolveItemGlobal.mockReturnValue(null) ;(mockRequest as any).tenantId = 'test-tenant' diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts index d173b00..721c849 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts @@ -46,7 +46,8 @@ describe('GetAllCFDocumentsControllerV1p1', () => { title: 'Test Document', lastChangeDateTime: '2024-01-01T00:00:00.000Z' } - ] + ], + total: 1 } mockGetAllCFDocuments.execute.mockResolvedValue(result) @@ -67,15 +68,15 @@ describe('GetAllCFDocumentsControllerV1p1', () => { includeArchived: true }) expect(responseStatus).toHaveBeenCalledWith(200) - expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost')) + expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris({ CFDocuments: result.CFDocuments } as any, 'http://localhost')) + expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Total-Count', '1') + expect(mockResponse.setHeader).not.toHaveBeenCalledWith('Link', expect.anything()) }) it('should handle query parameters', async () => { const result = { CFDocuments: [], - total: 10, - limit: 5, - offset: 0 + total: 10 } mockGetAllCFDocuments.execute.mockResolvedValue(result) @@ -106,8 +107,27 @@ describe('GetAllCFDocumentsControllerV1p1', () => { expect(responseStatus).toHaveBeenCalledWith(200) }) + it('should keep pagination metadata out of the body and send it as headers (CGE-19)', async () => { + mockGetAllCFDocuments.execute.mockResolvedValue({ CFDocuments: [], total: 25 }) + ;(mockRequest as any).isAuthenticated = false + ;(mockRequest as any).originalUrl = '/ims/case/v1p1/CFDocuments?limit=4&offset=1' + mockRequest.query = { limit: '4', offset: '1' } + + await controller.getAll(mockRequest as Request, mockResponse as Response) + + expect(responseStatus).toHaveBeenCalledWith(200) + expect(Object.keys(responseJson.mock.calls[0][0])).toEqual(['CFDocuments']) + expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Total-Count', '25') + expect(mockResponse.setHeader).toHaveBeenCalledWith('Link', [ + '; rel="first"', + '; rel="prev"', + '; rel="next"', + '; rel="last"' + ].join(', ')) + }) + it('should exclude archived documents when includeArchived=false', async () => { - mockGetAllCFDocuments.execute.mockResolvedValue({ CFDocuments: [] }) + mockGetAllCFDocuments.execute.mockResolvedValue({ CFDocuments: [], total: 0 }) ;(mockRequest as any).tenantId = 'test-tenant' ;(mockRequest as any).isAuthenticated = true mockRequest.query = { includeArchived: 'false' } @@ -119,15 +139,13 @@ describe('GetAllCFDocumentsControllerV1p1', () => { ) }) - it('should return every document, total and all, to unauthenticated requests', async () => { + it('should return every document to unauthenticated requests', async () => { const result = { CFDocuments: [ { identifier: 'doc-public', uri: '/ims/case/v1p1/CFDocuments/doc-public', title: 'Public', lastChangeDateTime: '2024-01-01T00:00:00.000Z' }, { identifier: 'doc-private', uri: '/ims/case/v1p1/CFDocuments/doc-private', title: 'Private', lastChangeDateTime: '2024-01-01T00:00:00.000Z' } ], - total: 2, - limit: 10, - offset: 0 + total: 2 } mockGetAllCFDocuments.execute.mockResolvedValue(result) ;(mockRequest as any).isAuthenticated = false @@ -141,7 +159,6 @@ describe('GetAllCFDocumentsControllerV1p1', () => { expect(responseStatus).toHaveBeenCalledWith(200) const body = responseJson.mock.calls[0][0] expect(body.CFDocuments.map((d: any) => d.identifier)).toEqual(['doc-public', 'doc-private']) - expect(body.total).toBe(2) }) it('should strip extensions from the response when X-CASE-EDITOR header is absent', async () => { @@ -154,7 +171,8 @@ describe('GetAllCFDocumentsControllerV1p1', () => { lastChangeDateTime: '2024-01-01T00:00:00.000Z', extensions: { 'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' } } } - ] + ], + total: 1 } mockGetAllCFDocuments.execute.mockResolvedValue(result) @@ -180,7 +198,8 @@ describe('GetAllCFDocumentsControllerV1p1', () => { smartschoolId: 'ss-42' } } - ] + ], + total: 1 } mockGetAllCFDocuments.execute.mockResolvedValue(result) @@ -203,7 +222,8 @@ describe('GetAllCFDocumentsControllerV1p1', () => { lastChangeDateTime: '2024-01-01T00:00:00.000Z', extensions: { 'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' } } } - ] + ], + total: 1 } mockGetAllCFDocuments.execute.mockResolvedValue(result) diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/utils/__tests__/httpUtils.test.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/__tests__/httpUtils.test.ts index 98f34a0..e112788 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/utils/__tests__/httpUtils.test.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/__tests__/httpUtils.test.ts @@ -1,4 +1,5 @@ -import { stripExtensions, stripOpenCaseExtensions } from '../httpUtils' +import type { Request, Response } from 'express' +import { applyQueryToArray, setPaginationHeaders, stripExtensions, stripOpenCaseExtensions } from '../httpUtils' describe('stripOpenCaseExtensions', () => { it('removes only ext:opencase and keeps other extensions on every entity in a package', () => { @@ -63,3 +64,63 @@ describe('stripExtensions', () => { }) }) }) + +describe('setPaginationHeaders', () => { + const makeReq = (originalUrl: string) => ({ + originalUrl, + protocol: 'https', + get: () => 'case.example.org', + header: () => undefined + }) as unknown as Request + + const run = (originalUrl: string, total: number, q: { limit?: number, offset?: number }) => { + const headers: Record = {} + const res = { setHeader: (k: string, v: string) => { headers[k] = v } } as unknown as Response + setPaginationHeaders(makeReq(originalUrl), res, total, q) + return headers + } + + const links = (header: string) => Object.fromEntries( + header.split(', ').map(l => { + const [, url, rel] = /^<([^>]+)>; rel="(\w+)"$/.exec(l)! + return [rel, new URL(url).searchParams.get('offset')] + }) + ) + + it('sets only X-Total-Count when the request is not paginated', () => { + expect(run('/ims/case/v1p1/CFDocuments', 7, {})).toEqual({ 'X-Total-Count': '7' }) + }) + + it('returns first/prev/next/last links that preserve the other query parameters', () => { + const headers = run('/ims/case/v1p1/CFDocuments?limit=10&offset=20&sort=title', 53, { limit: 10, offset: 20 }) + expect(headers['X-Total-Count']).toBe('53') + expect(headers.Link.split(', ')[0]).toBe('; rel="first"') + expect(links(headers.Link)).toEqual({ first: '0', prev: '10', next: '30', last: '50' }) + }) + + it('omits prev on the first page and next on the last page', () => { + expect(links(run('/x?limit=10', 25, { limit: 10 }).Link)).toEqual({ first: '0', next: '10', last: '20' }) + expect(links(run('/x?limit=10&offset=20', 25, { limit: 10, offset: 20 }).Link)).toEqual({ first: '0', prev: '10', last: '20' }) + }) + + it('keeps last on the same page grid as next when paging from a non-aligned offset', () => { + expect(links(run('/x?limit=4&offset=1', 25, { limit: 4, offset: 1 }).Link)).toEqual({ first: '0', prev: '0', next: '5', last: '21' }) + }) + + it('clamps prev to zero and handles an empty collection', () => { + expect(links(run('/x?limit=10&offset=3', 0, { limit: 10, offset: 3 }).Link)).toEqual({ first: '0', prev: '0', last: '0' }) + }) + + it('returns only a first link when offset is given without a limit', () => { + expect(links(run('/x?offset=5', 9, { offset: 5 }).Link)).toEqual({ first: '0' }) + }) +}) + +describe('applyQueryToArray', () => { + it('reports the filtered total before the page is cut', () => { + const items = Array.from({ length: 8 }, (_, i) => ({ identifier: `id-${i}`, tag: i % 2 === 0 ? 'even' : 'odd' })) + const applied = applyQueryToArray(items, { filter: 'even', limit: 2, offset: 1 }) + expect(applied.ok && applied.total).toBe(4) + expect(applied.ok && applied.items.map(i => i.identifier)).toEqual(['id-2', 'id-4']) + }) +}) diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts index 6c47c30..3f274ec 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts @@ -175,10 +175,50 @@ export function applyFieldSelectionToEntity (entity: Record, fields return filtered } +/** + * Set the CASE pagination headers on a list response. Per the CASE REST binding (§3.1), + * the total collection size goes in X-Total-Count — never in the JSON body, whose schemas + * forbid additional properties. When the request was paginated (limit/offset supplied), + * first/prev/next/last links are also returned in the Link header (RFC 8288). + */ +export function setPaginationHeaders (req: Request, res: Response, total: number, q: CaseQueryParams): void { + res.setHeader('X-Total-Count', String(total)) + if (q.limit === undefined && q.offset === undefined) return + + const url = new URL(req.originalUrl ?? req.url ?? '/', getBaseUrl(req)) + const link = (offset: number, rel: string): string => { + url.searchParams.set('offset', String(offset)) + return `<${url.toString()}>; rel="${rel}"` + } + + const offset = q.offset ?? 0 + const limit = q.limit + // Without a limit the response already runs to the end of the collection, + // so the only other meaningful page is the start of it. + if (limit === undefined) { + res.setHeader('Link', link(0, 'first')) + return + } + + // "last" is the final page reachable by following "next" from this offset, so + // the links stay consistent when a caller pages from a non-aligned offset. + const last = total === 0 + ? 0 + : offset < total + ? offset + Math.floor((total - 1 - offset) / limit) * limit + : Math.floor((total - 1) / limit) * limit + + const links = [link(0, 'first')] + if (offset > 0) links.push(link(Math.max(0, offset - limit), 'prev')) + if (offset + limit < total) links.push(link(offset + limit, 'next')) + links.push(link(last, 'last')) + res.setHeader('Link', links.join(', ')) +} + export function applyQueryToArray> ( items: T[], q: CaseQueryParams -): { ok: true, items: Array> } | { ok: false, status: number, body: any } { +): { ok: true, items: Array>, total: number } | { ok: false, status: number, body: any } { let out: Array> = items if (q.filter) { @@ -210,6 +250,8 @@ export function applyQueryToArray> ( }) } + // Total is the filtered collection size, before the page is cut from it + const total = out.length const offset = q.offset ?? 0 const limit = q.limit out = limit !== undefined ? out.slice(offset, offset + limit) : out.slice(offset) @@ -218,7 +260,7 @@ export function applyQueryToArray> ( out = out.map(it => applyFieldSelectionToEntity(it, q.fields) as T) } - return { ok: true, items: out } + return { ok: true, items: out, total } } export function setEtagAndHandleNotModified (req: Request, res: Response, body: unknown): boolean { diff --git a/apps/opencase/src/interfaces/http/server.ts b/apps/opencase/src/interfaces/http/server.ts index 314c376..d0048e6 100644 --- a/apps/opencase/src/interfaces/http/server.ts +++ b/apps/opencase/src/interfaces/http/server.ts @@ -15,7 +15,9 @@ export function createServer (container: Container): express.Express { origin: true, // Allow all origins (for development) - restrict in production credentials: true, // Allow credentials (cookies, authorization headers) methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + // CASE pagination headers (REST binding §3.1) must be exposed for browser clients to read them + exposedHeaders: ['X-Total-Count', 'Link'] })) app.use(express.json({ limit: '50mb' }))