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
2 changes: 2 additions & 0 deletions __tests__/core_provider_contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ function parseRouteOperations(filePath, prefix) {
for (const methodMatch of match[2].matchAll(/\.(get|post|put|patch|delete|head)\(/g)) {
methods.add(methodMatch[1].toUpperCase())
}
// Express serves HEAD from the GET handler natively.
if (methods.has('GET')) methods.add('HEAD')
for (const method of methods) {
operations.add(`${method} ${routePath}`)
}
Expand Down
4 changes: 2 additions & 2 deletions controllers/gog.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,8 @@ const expandedId = async function (req, res, next) {
// Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h).
res.set(utils.configureWebAnnoHeadersFor(match))
res.set("Cache-Control", "max-age=86400, must-revalidate")
res.set(utils.configureLastModifiedHeader(match))
// Include current version for optimistic locking (parity with GET /v1/id/:_id)
// No Last-Modified here, unlike GET /v1/id/:_id. It would compare against the root entity
// before expand() merges the targeting Annotations.
res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "")
// No GENERATOR/CREATOR filter: merge every current targeting Annotation, matching the
// client's historical expand() behavior (its checkMatch is short-circuited to true).
Expand Down
108 changes: 1 addition & 107 deletions controllers/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,36 +79,6 @@ const history = async function (req, res, next) {
res.json(ancestors)
}

/**
* Allow for HEAD requests by @id via the RERUM getByID pattern /v1/id/
* No object is returned, but the Content-Length header is set.
* Note /v1/id/{blank} does not route here. It routes to the generic 404
* */
const idHeadRequest = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let id = req.params["_id"]
try {
let match = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]})
if (match) {
// Use res.end() instead of res.sendStatus(200) — sendStatus writes "OK" as the body
// and overwrites Content-Type and Content-Length. HEAD must preserve our manual headers.
// Mirror the GET pipeline (idNegotiation) so Content-Length matches the GET payload.
const negotiated = idNegotiation(match)
const size = Buffer.byteLength(JSON.stringify(negotiated))
res.set("Content-Length", size)
res.status(200).end()
return
}
let err = {
"message": `No RERUM object with id '${id}'`,
"status": 404
}
return next(utils.createExpressError(err))
} catch (error) {
return next(utils.createExpressError(error))
}
}

/**
* Allow for HEAD requests via the RERUM getByProperties pattern /v1/api/query
* No objects are returned, but the Content-Length header is set.
Expand Down Expand Up @@ -136,80 +106,4 @@ const queryHeadRequest = async function (req, res, next) {
}
}

/**
* Allow for HEAD requests via the RERUM since pattern /v1/since/:_id
* No objects are returned, but the Content-Length header is set.
* */
const sinceHeadRequest = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let id = req.params["_id"]
let obj
try {
obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]})
} catch (error) {
return next(utils.createExpressError(error))
}
if (null === obj) {
let err = {
message: `Cannot produce a history. There is no object in the database with id '${id}'. Check the URL.`,
status: 404
}
return next(utils.createExpressError(err))
}
let all = await getAllVersions(obj)
.catch(error => {
console.error(error)
return []
})
let descendants = getAllDescendants(all, obj, [])
if (descendants.length) {
const negotiated = descendants.map(o => idNegotiation(o))
const size = Buffer.byteLength(JSON.stringify(negotiated))
res.set("Content-Length", size)
res.status(200).end()
return
}
// GET returns "[]" for the empty case — match its byte length.
res.set("Content-Length", Buffer.byteLength("[]"))
res.status(200).end()
}

/**
* Allow for HEAD requests via the RERUM since pattern /v1/history/:_id
* No objects are returned, but the Content-Length header is set.
* */
const historyHeadRequest = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let id = req.params["_id"]
let obj
try {
obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]})
} catch (error) {
return next(utils.createExpressError(error))
}
if (null === obj) {
let err = {
message: "Cannot produce a history. There is no object in the database with this id. Check the URL.",
status: 404
}
return next(utils.createExpressError(err))
}
let all = await getAllVersions(obj)
.catch(error => {
console.error(error)
return []
})
let ancestors = getAllAncestors(all, obj, [])
if (ancestors.length) {
const negotiated = ancestors.map(o => idNegotiation(o))
const size = Buffer.byteLength(JSON.stringify(negotiated))
res.set("Content-Length", size)
res.status(200).end()
return
}
// GET returns "[]" for the empty case — match its byte length.
res.set("Content-Length", Buffer.byteLength("[]"))
res.status(200).end()
}

export { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest }
export { since, history, queryHeadRequest }
5 changes: 1 addition & 4 deletions db-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { searchAsWords, searchAsPhrase } from './controllers/search.js'
import { deleteObj } from './controllers/delete.js'
import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from './controllers/update.js'
import { bulkCreate, bulkUpdate } from './controllers/bulk.js'
import { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } from './controllers/history.js'
import { since, history, queryHeadRequest } from './controllers/history.js'
import { release } from './controllers/release.js'
import { _gog_fragments_from_manuscript, _gog_glosses_from_manuscript, expand, expandedId } from './controllers/gog.js'

Expand All @@ -34,12 +34,9 @@ export default {
id,
bulkCreate,
bulkUpdate,
idHeadRequest,
queryHeadRequest,
since,
history,
sinceHeadRequest,
historyHeadRequest,
remove,
_gog_glosses_from_manuscript,
_gog_fragments_from_manuscript,
Expand Down
24 changes: 20 additions & 4 deletions routes/__tests__/history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ import controller from '../../db-controller.js'

const routeTester = new express()
routeTester.use(express.json({ type: ["application/json", "application/ld+json"] }))

// Mount /history for both GET (controller.history) and HEAD (controller.historyHeadRequest).
// `.head()` must be registered before `.use()` to win over the method-agnostic mount.
routeTester.head("/history/:_id", controller.historyHeadRequest)
routeTester.use("/history/:_id", controller.history)

const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007"
Expand Down Expand Up @@ -65,4 +61,24 @@ describe('HEAD /history/:id', () => {
const response = await request(routeTester).head(`/history/${MOCK_ID}`)
assert.strictEqual(response.statusCode, 404)
})

// RFC 9110 s9.3.2: HEAD sends the same headers a GET would.
it("sends the same headers as the GET, including the validators", async () => {
db.findOne.mockResolvedValueOnce(structuredClone(mockDoc))
const getResp = await request(routeTester).get(`/history/${MOCK_ID}`)

db.findOne.mockResolvedValueOnce(structuredClone(mockDoc))
const headResp = await request(routeTester).head(`/history/${MOCK_ID}`)

// Anchor to the success path. A 404 pair also agrees on every header below, so without
// these the comparison would pass while proving nothing.
assert.strictEqual(getResp.statusCode, 200)
assert.strictEqual(headResp.statusCode, 200)
assert.ok(headResp.headers['link'], 'HEAD must carry the JSON-LD context Link header')
assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against')
for (const header of ['etag', 'content-type', 'link', 'allow']) {
assert.strictEqual(headResp.headers[header], getResp.headers[header],
`HEAD and GET must agree on ${header}`)
}
})
})
35 changes: 31 additions & 4 deletions routes/__tests__/id.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ import controller from '../../db-controller.js'
const routeTester = new express()
routeTester.use(express.json({ type: ["application/json", "application/ld+json"] }))

// Mount our own /id route without auth that will use controller.id (GET) and
// controller.idHeadRequest (HEAD). `.use()` is method-agnostic, so the explicit
// `.head()` entry must come first to intercept HEAD before the catch-all GET handler.
routeTester.head("/id/:_id", controller.idHeadRequest)
// Mount our own /id route without auth, matching routes/id.js: GET only, no HEAD handler.
routeTester.use("/id/:_id", controller.id)

const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007"
Expand Down Expand Up @@ -71,6 +68,36 @@ describe('HEAD /id/:id', () => {
const response = await request(routeTester).head(`/id/${MOCK_ID}`)
assert.strictEqual(response.statusCode, 404)
})

// RFC 9110 s9.3.2: HEAD sends the same headers a GET would.
it("sends the same headers as the GET, including the validators", async () => {
db.findOne.mockResolvedValueOnce(structuredClone(mockDoc))
const getResp = await request(routeTester).get(`/id/${MOCK_ID}`)

db.findOne.mockResolvedValueOnce(structuredClone(mockDoc))
const headResp = await request(routeTester).head(`/id/${MOCK_ID}`)

assert.strictEqual(headResp.headers['cache-control'], 'max-age=86400, must-revalidate')
assert.ok(headResp.headers['last-modified'], 'HEAD must report Last-Modified')
assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against')
for (const header of ['cache-control', 'last-modified', 'etag', 'content-type',
'link', 'allow', 'current-overwritten-version', 'location']) {
assert.strictEqual(headResp.headers[header], getResp.headers[header],
`HEAD and GET must agree on ${header}`)
}
})

it("reports Last-Modified from the overwrite time once an object has been overwritten", async () => {
const overwritten = structuredClone(mockDoc)
overwritten.__rerum.isOverwritten = '2025-06-24T10:00:00'
db.findOne.mockResolvedValueOnce(overwritten)

const response = await request(routeTester).head(`/id/${MOCK_ID}`)

assert.strictEqual(response.statusCode, 200)
assert.strictEqual(response.headers['last-modified'], new Date('2025-06-24T10:00:00').toUTCString())
assert.strictEqual(response.headers['current-overwritten-version'], '2025-06-24T10:00:00')
})
})

describe('id route overwrite headers', () => {
Expand Down
24 changes: 21 additions & 3 deletions routes/__tests__/since.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import controller from '../../db-controller.js'
const routeTester = new express()
routeTester.use(express.json({ type: ["application/json", "application/ld+json"] }))

// Mount /since for both GET (controller.since) and HEAD (controller.sinceHeadRequest).
// `.head()` must be registered before `.use()` to win over the method-agnostic mount.
routeTester.head("/since/:_id", controller.sinceHeadRequest)
// Mount /since matching routes/since.js: GET only, no HEAD handler.
routeTester.use("/since/:_id", controller.since)

const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007"
Expand Down Expand Up @@ -65,4 +63,24 @@ describe('HEAD /since/:id', () => {
const response = await request(routeTester).head(`/since/${MOCK_ID}`)
assert.strictEqual(response.statusCode, 404)
})

// RFC 9110 s9.3.2: HEAD sends the same headers a GET would.
it("sends the same headers as the GET, including the validators", async () => {
db.findOne.mockResolvedValueOnce(structuredClone(mockDoc))
const getResp = await request(routeTester).get(`/since/${MOCK_ID}`)

db.findOne.mockResolvedValueOnce(structuredClone(mockDoc))
const headResp = await request(routeTester).head(`/since/${MOCK_ID}`)

// Anchor to the success path. A 404 pair also agrees on every header below, so without
// these the comparison would pass while proving nothing.
assert.strictEqual(getResp.statusCode, 200)
assert.strictEqual(headResp.statusCode, 200)
assert.ok(headResp.headers['link'], 'HEAD must carry the JSON-LD context Link header')
assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against')
for (const header of ['etag', 'content-type', 'link', 'allow']) {
assert.strictEqual(headResp.headers[header], getResp.headers[header],
`HEAD and GET must agree on ${header}`)
}
})
})
1 change: 0 additions & 1 deletion routes/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import controller from '../db-controller.js'

router.route('/:_id')
.get(controller.history)
.head(controller.historyHeadRequest)
.all((req, res, next) => {
res.statusMessage = 'Improper request method, please use GET.'
res.status(405).end()
Expand Down
1 change: 0 additions & 1 deletion routes/id.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import controller from '../db-controller.js'

router.route('/:_id')
.get(controller.id)
.head(controller.idHeadRequest)
.all((req, res, next) => {
res.statusMessage = 'Improper request method, please use GET.'
res.status(405).end()
Expand Down
1 change: 0 additions & 1 deletion routes/since.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import controller from '../db-controller.js'

router.route('/:_id')
.get(controller.since)
.head(controller.sinceHeadRequest)
.all((req, res, next) => {
res.statusMessage = 'Improper request method, please use GET.'
res.status(405).end()
Expand Down
Loading