Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ describe('GetAllCFDocuments', () => {
const result = await getAllCFDocuments.execute({ tenantId, caseVersion })

expect(result).toEqual({
CFDocuments: []
CFDocuments: [],
total: 0
})
})

Expand Down Expand Up @@ -79,7 +80,8 @@ describe('GetAllCFDocuments', () => {
identifier: 'doc-2'
})
})
])
]),
total: 2
})
})

Expand Down Expand Up @@ -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)
})

Expand Down
23 changes: 19 additions & 4 deletions apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -48,18 +48,21 @@ 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)
responseBody = {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -53,18 +53,21 @@ 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)
responseBody = {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Loading
Loading