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' }))