diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js index 9cea017..c73d130 100644 --- a/__tests__/core_provider_contract.test.js +++ b/__tests__/core_provider_contract.test.js @@ -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}`) } diff --git a/controllers/gog.js b/controllers/gog.js index e1d6078..ed5d7e3 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -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). diff --git a/controllers/history.js b/controllers/history.js index eb29970..b7b55a5 100644 --- a/controllers/history.js +++ b/controllers/history.js @@ -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. @@ -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 } diff --git a/db-controller.js b/db-controller.js index 44a5f33..7f16166 100644 --- a/db-controller.js +++ b/db-controller.js @@ -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' @@ -34,12 +34,9 @@ export default { id, bulkCreate, bulkUpdate, - idHeadRequest, queryHeadRequest, since, history, - sinceHeadRequest, - historyHeadRequest, remove, _gog_glosses_from_manuscript, _gog_fragments_from_manuscript, diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index 8e5850c..b2ac718 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -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" @@ -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}`) + } + }) }) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index cb469cb..e6701fc 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -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" @@ -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', () => { diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index 28e02e5..af11dd9 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -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" @@ -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}`) + } + }) }) diff --git a/routes/history.js b/routes/history.js index 9abd6dd..44943e9 100644 --- a/routes/history.js +++ b/routes/history.js @@ -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() diff --git a/routes/id.js b/routes/id.js index 0e3d8d7..fdfca44 100644 --- a/routes/id.js +++ b/routes/id.js @@ -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() diff --git a/routes/since.js b/routes/since.js index c328fd5..4c545df 100644 --- a/routes/since.js +++ b/routes/since.js @@ -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()