From 0cebde9bd7337e7cf26b9b6f0dbdf0b5139310ad Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 7 Aug 2026 13:00:41 -0500
Subject: [PATCH 1/4] A more generic back end expand via a new
/v1/id/_:id/expanded/ endpoint
---
controllers/crud.js | 167 ++++++++++++++++-
controllers/gog.js | 69 +------
controllers/utils.js | 64 +++++++
database/__mocks__/index.js | 1 +
db-controller.js | 3 +-
openapi/contracts/core-provider.openapi.yaml | 93 ++++++++++
public/API.html | 186 +++++++++++++++++++
routes/id.js | 9 +
8 files changed, 528 insertions(+), 64 deletions(-)
diff --git a/controllers/crud.js b/controllers/crud.js
index 758a048a..bcadc537 100644
--- a/controllers/crud.js
+++ b/controllers/crud.js
@@ -6,7 +6,7 @@
*/
import { newID, isValidID, db } from '../database/index.js'
import utils from '../utils.js'
-import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID } from './utils.js'
+import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID, findLeafAnnotationsFor } from './utils.js'
/**
* Create a new Linked Open Data object in RERUM v1.
@@ -127,8 +127,171 @@ const id = async function (req, res, next) {
}
}
+/**
+ * The expand job always constrains the Annotations it gathers to the leaf versions, to the
+ * Annotation types, and to the entity in the request URI. A client cannot influence those, so
+ * these keys are dropped from a supplied filter body by exact name or dotted prefix.
+ * The dot matters -- 'targetCollection' is a real property on Gallery of Glosses data and must
+ * still be usable as a filter.
+ */
+const RESERVED_FILTER_KEYS = ["target", "type", "@type", "__rerum.history"]
+
+/**
+ * Identity and system properties an Annotation body must never overwrite. '@id' and '@context'
+ * are read by idNegotiation() and res.location() right after the merge, so clobbering them would
+ * break the response itself. '__proto__' is not data -- assigning it would re-point the response
+ * object's prototype instead of adding a property, and emitting it would hand a prototype
+ * pollution vector to every client that parses the response.
+ */
+const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "@context", "__proto__"])
+
+/**
+ * Reduce a supplied POST body to the literal MongoDB filter keys the expand job will honor.
+ * @param supplied The parsed JSON request body.
+ * @return An object of filter keys, minus the ones this endpoint owns.
+ */
+function sanitizeExpansionFilters(supplied) {
+ const filters = {}
+ for (const [key, value] of Object.entries(supplied)) {
+ if (RESERVED_FILTER_KEYS.some(reserved => key === reserved || key.startsWith(`${reserved}.`))) continue
+ filters[key] = value
+ }
+ return filters
+}
+
+/**
+ * The [key, value] assertions an Annotation makes about the entity it targets.
+ * Only 'body' and 'bodyValue' are read -- an Annotation carrying neither is ignored, and no other
+ * property of the Annotation can leak onto the entity.
+ *
+ * Anticipates the likely Annotation body formats
+ * - bodyValue: 'text' the W3C shorthand, which has no key of its own
+ * - body: {'key': 'value'} a single assertion
+ * - body: {'key': {...}} a single assertion, value kept as-is
+ * - body: {'type':'TextualBody', 'value': 'text', ...} kept whole so 'format' and 'language' survive
+ *
+ * @param anno An Annotation document.
+ * @return An Array of [key, value] pairs to merge onto the entity.
+ */
+function assertionsFrom(anno) {
+ const assertions = []
+ if (typeof anno.bodyValue === "string") assertions.push(["bodyValue", anno.bodyValue])
+ const body = anno.body
+ // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an
+ // external resource with no embedded value to expand with.
+ if (!body || typeof body !== "object" || Array.isArray(body)) return assertions
+ if ((body.type ?? body["@type"]) === "TextualBody") {
+ assertions.push(["bodyValue", body])
+ return assertions
+ }
+ const keys = Object.keys(body)
+ // Any other multi-key body is structural rather than assertional and cannot be attributed to a
+ // single entity property. This is what skips the Choice, Composite, and List multiplicity
+ // constructs, which are all shaped {type, items}.
+ if (keys.length !== 1) return assertions
+ assertions.push([keys[0], body[keys[0]]])
+ return assertions
+}
+
+/**
+ * Merge the assertions of the gathered Annotations onto a copy of the entity, as raw values.
+ * Unlike the Gallery of Glosses expand(), values are not wrapped and not unwrapped -- what the
+ * Annotation says is what the entity gets. When more than one current Annotation asserts the same
+ * key, or the entity already carries it, the values collect into an Array.
+ * @param primitiveEntity The unexpanded entity.
+ * @param annos The Annotations targeting it.
+ * @return A new, expanded entity object.
+ */
+function applyRawExpansion(primitiveEntity, annos) {
+ const expandedEntity = structuredClone(primitiveEntity)
+ // Hold __rerum aside so it can be re-appended after the merged properties. It is the
+ // last property on a stored object and should stay last on an expanded one.
+ const rerumProp = expandedEntity.__rerum
+ delete expandedEntity.__rerum
+ for (const anno of annos) {
+ for (const [key, value] of assertionsFrom(anno)) {
+ if (PROTECTED_EXPANSION_KEYS.has(key)) continue
+ if (!Object.hasOwn(expandedEntity, key)) {
+ expandedEntity[key] = value
+ continue
+ }
+ const existing = Array.isArray(expandedEntity[key]) ? expandedEntity[key] : [expandedEntity[key]]
+ expandedEntity[key] = Array.isArray(value) ? [...existing, ...value] : [...existing, value]
+ }
+ }
+ if (rerumProp !== undefined) expandedEntity.__rerum = rerumProp
+ return expandedEntity
+}
+
+/**
+ * Query the MongoDB for the object with the _id or __rerum.slug provided in the request URL, then
+ * merge in the assertions of all the current Annotations targeting it.
+ *
+ * GET recognizes the '?generator=' and '?creator=' convenience parameters only.
+ * POST reads literal MongoDB filter keys from the JSON body and ignores URL parameters as filters.
+ * Both methods page the Annotation search with the usual '?limit=' and '?skip=' parameters.
+ * */
+const idExpanded = async function (req, res, next) {
+ res.set("Content-Type", "application/json; charset=utf-8")
+ const id = req.params["_id"]
+ const isPost = req.method === "POST"
+ //Paging is transport rather than a filter, so it comes off the URL for both methods.
+ //The default is generous because an expansion wants every Annotation it can get, and an
+ //entity with more than 200 targeting it is not expected.
+ const pagination = getPagination(req.query, 200)
+ let filters = {}
+ if (isPost) {
+ //Express leaves the body undefined when a POST supplies none. That is an unfiltered expand.
+ const supplied = req.body ?? {}
+ if (typeof supplied !== "object" || Array.isArray(supplied)) {
+ const err = {
+ "message": "The /expanded request body must be a JSON object of filter properties.",
+ "status": 400
+ }
+ return next(utils.createExpressError(err))
+ }
+ filters = sanitizeExpansionFilters(supplied)
+ }
+ else {
+ //Repeated query parameters arrive as an Array, which is not a filter value we support.
+ if (typeof req.query.generator === "string" && req.query.generator) filters["__rerum.generatedBy"] = req.query.generator
+ if (typeof req.query.creator === "string" && req.query.creator) filters.creator = req.query.creator
+ }
+ try {
+ const match = await db.findOne({"$or": [{"_id": id}, {"__rerum.slug": id}]})
+ if (!match) {
+ const err = {
+ "message": `No RERUM object with id '${id}'`,
+ "status": 404
+ }
+ return next(utils.createExpressError(err))
+ }
+ res.set(utils.configureWebAnnoHeadersFor(match))
+ //Support built in browser caching. A POST response is not cacheable.
+ if (!isPost) res.set("Cache-Control", "max-age=86400, must-revalidate")
+ // No Last-Modified here, unlike GET /v1/id/:_id. It would compare against the root entity
+ // before the targeting Annotations are merged in.
+ // Include current version for optimistic locking
+ res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "")
+ // Annotations target the stored URI, so this must come off the raw match. idNegotiation()
+ // below rebuilds 'id' from RERUM_ID_PREFIX, which is not necessarily the stored host.
+ const targetId = match["@id"] ?? match.id
+ const annos = targetId ? await findLeafAnnotationsFor(targetId, filters, pagination) : []
+ // Let clients detect a full page. When this equals the limit there may be more to gather,
+ // and the entity in hand is expanded from only part of its Annotations.
+ res.set('Annotations-Merged', String(annos.length))
+ let expanded = applyRawExpansion(match, annos)
+ expanded = idNegotiation(expanded)
+ res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"])
+ res.json(expanded)
+ } catch (error) {
+ return next(utils.createExpressError(error))
+ }
+}
+
export {
create,
query,
- id
+ id,
+ idExpanded
}
diff --git a/controllers/gog.js b/controllers/gog.js
index db69a3e5..bfbe1b7c 100644
--- a/controllers/gog.js
+++ b/controllers/gog.js
@@ -8,7 +8,7 @@
import { newID, isValidID, db } from '../database/index.js'
import utils from '../utils.js'
-import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation } from './utils.js'
+import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor } from './utils.js'
// The Gallery of Glosses agents, by RERUM ObjectId. Prod (store) and dev (devstore) mint different
// agents; only the trailing id is compared, so either host spelling matches.
@@ -336,66 +336,13 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde
// An entity is expandable if it carries a URI under either '@id' or 'id'.
if(!primitiveEntity?.["@id"] && !primitiveEntity?.id) return primitiveEntity
const targetId = primitiveEntity["@id"] ?? primitiveEntity.id ?? "unknown"
- // '$and' is always present so the GENERATOR and CREATOR blocks below can push into it from
- // either branch. 'annoTypeConditions' is always pushed, so it is never the empty Array Mongo rejects.
- let queryObj = {
- "__rerum.history.next": { $exists: true, $size: 0 },
- "$and": []
- }
- let targetPatterns = ["target", "target.@id", "target.id"]
- let targetConditions = []
- let annoTypeConditions = [{"type": "Annotation"}, {"@type":"Annotation"}, {"@type":"oa:Annotation"}]
-
- if (targetId.startsWith("http")) {
- for(const targetKey of targetPatterns){
- targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") })
- targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") })
- }
- queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions})
- }
- else{
- queryObj["$and"].push({"$or": annoTypeConditions})
- queryObj.target = targetId
- }
-
- // Only expand with data from a specific app
- if(GENERATOR) {
- // Need to check http:// and https://
- const generatorConditions = [
- {"__rerum.generatedBy": GENERATOR.replace(/^https?/, "http")},
- {"__rerum.generatedBy": GENERATOR.replace(/^https?/, "https")}
- ]
- if (GENERATOR.startsWith("http")) {
- queryObj["$and"].push({"$or": generatorConditions })
- }
- else{
- // It should be a URI, but this can be a fallback.
- queryObj["__rerum.generatedBy"] = GENERATOR
- }
- }
- // Only expand with data from a specific creator
- if(CREATOR) {
- // Need to check http:// and https://
- const creatorConditions = [
- {"creator": CREATOR.replace(/^https?/, "http")},
- {"creator": CREATOR.replace(/^https?/, "https")}
- ]
- if (CREATOR.startsWith("http")) {
- queryObj["$and"].push({"$or": creatorConditions })
- }
- else{
- // It should be a URI, but this can be a fallback.
- queryObj["creator"] = CREATOR
- }
- }
-
- // Get the Annotations targeting this Entity from the db. Remove _id property.
- // Assuming we do not need paged query here
- let matches = await db.find(queryObj).toArray()
- matches = matches.map(o => {
- delete o._id
- return o
- })
+ // Only expand with data from a specific app and/or a specific creator. The shared helper
+ // applies the leaf, target, and Annotation type constraints and doubles these two URIs
+ // across the http/https spellings.
+ const filters = {}
+ if(GENERATOR) filters["__rerum.generatedBy"] = GENERATOR
+ if(CREATOR) filters.creator = CREATOR
+ const matches = await findLeafAnnotationsFor(targetId, filters)
// Combine the Annotation bodies with the primitive object.
// Mirror DEER's client-side expand() (deer-utils.js buildValueObject)
diff --git a/controllers/utils.js b/controllers/utils.js
index dd455d05..72e2614a 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -109,6 +109,69 @@ const generateSlugId = async function(slug_id="", next){
return slug_return
}
+// RERUM has minted these two under both 'http' and 'https' over the years, so a filter on either
+// must match both spellings. Every other supplied filter key is applied exactly as given.
+const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"])
+
+/**
+ * Find the current (leaf) Annotations targeting an entity, for expansion.
+ *
+ * Anticipates the likely Annotation target formats
+ * - target: 'uri'
+ * - target: {'id':'uri'}
+ * - target: {'@id':'uri'}
+ * and the likely Annotation type formats
+ * - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}
+ *
+ * @param targetId The '@id' or 'id' URI of the entity being expanded.
+ * @param filters Literal MongoDB filter keys to AND into the query. Already sanitized by the
+ * caller -- the leaf, type, and target constraints here cannot be overruled.
+ * @param pagination A {limit, skip} pair from getPagination(). When supplied, the query is sorted
+ * by '_id' first -- Mongo's natural order is not stable across paged calls, so
+ * without a sort a client walking pages could miss or repeat Annotations.
+ * Omit it to fetch every match, which is the long standing expand() behavior.
+ * @return An Array of matching Annotation documents, with '_id' removed.
+ */
+const findLeafAnnotationsFor = async function (targetId, filters = {}, pagination = null) {
+ // '$and' is always present so the filter conditions below can push into it from either branch.
+ // 'annoTypeConditions' is always pushed, so it is never the empty Array Mongo rejects.
+ const queryObj = {
+ "__rerum.history.next": { $exists: true, $size: 0 },
+ "$and": []
+ }
+ const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}]
+ if (targetId.startsWith("http")) {
+ const targetConditions = []
+ for (const targetKey of ["target", "target.@id", "target.id"]) {
+ targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") })
+ targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") })
+ }
+ queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions})
+ }
+ else {
+ queryObj["$and"].push({"$or": annoTypeConditions})
+ queryObj.target = targetId
+ }
+ for (const [key, value] of Object.entries(filters)) {
+ if (URI_DOUBLED_FILTER_KEYS.has(key) && typeof value === "string" && /^https?:\/\//.test(value)) {
+ queryObj["$and"].push({"$or": [
+ { [key]: value.replace(/^https?/, "http") },
+ { [key]: value.replace(/^https?/, "https") }
+ ]})
+ continue
+ }
+ queryObj["$and"].push({ [key]: value })
+ }
+ // Get the Annotations targeting this Entity from the db. Remove _id property.
+ let cursor = db.find(queryObj)
+ if (pagination) cursor = cursor.sort({ "_id": 1 }).limit(pagination.limit).skip(pagination.skip)
+ const matches = await cursor.toArray()
+ return matches.map(o => {
+ delete o._id
+ return o
+ })
+}
+
// Handle index actions
const index = function (req, res, next) {
res.json({
@@ -465,6 +528,7 @@ async function healReleasesTree(releasing) {
export {
_contextid,
idNegotiation,
+ findLeafAnnotationsFor,
getPagination,
generateSlugId,
index,
diff --git a/database/__mocks__/index.js b/database/__mocks__/index.js
index 51ec6a2c..b155179a 100644
--- a/database/__mocks__/index.js
+++ b/database/__mocks__/index.js
@@ -41,6 +41,7 @@ function createMockFunction(implementation = () => undefined) {
function createCursor() {
return {
+ sort: createMockFunction(function () { return this }),
limit: createMockFunction(function () { return this }),
skip: createMockFunction(function () { return this }),
toArray: createMockFunction(() => Promise.resolve([]))
diff --git a/db-controller.js b/db-controller.js
index 7f161667..99f5c163 100644
--- a/db-controller.js
+++ b/db-controller.js
@@ -8,7 +8,7 @@
// Import controller modules
import { index, idNegotiation, generateSlugId, remove } from './controllers/utils.js'
-import { create, query, id } from './controllers/crud.js'
+import { create, query, id, idExpanded } from './controllers/crud.js'
import { searchAsWords, searchAsPhrase } from './controllers/search.js'
import { deleteObj } from './controllers/delete.js'
import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from './controllers/update.js'
@@ -32,6 +32,7 @@ export default {
searchAsWords,
searchAsPhrase,
id,
+ idExpanded,
bulkCreate,
bulkUpdate,
queryHeadRequest,
diff --git a/openapi/contracts/core-provider.openapi.yaml b/openapi/contracts/core-provider.openapi.yaml
index 7174b34a..fa603009 100644
--- a/openapi/contracts/core-provider.openapi.yaml
+++ b/openapi/contracts/core-provider.openapi.yaml
@@ -44,6 +44,99 @@ paths:
description: Object headers
'404':
$ref: '#/components/responses/NotFound'
+ /id/{id}/expanded:
+ get:
+ summary: Read object by id with its current Annotations merged in
+ operationId: getExpandedObjectById
+ parameters:
+ - $ref: '#/components/parameters/ObjectId'
+ - in: query
+ name: generator
+ required: false
+ description: Only expand with Annotations generated by this registered app agent.
+ schema:
+ type: string
+ - in: query
+ name: creator
+ required: false
+ description: Only expand with Annotations attributed to this creator.
+ schema:
+ type: string
+ - in: query
+ name: limit
+ required: false
+ description: Maximum Annotations to gather. Defaults to 200.
+ schema:
+ type: integer
+ - in: query
+ name: skip
+ required: false
+ description: Annotations to skip before gathering. Defaults to 0.
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: Expanded object payload
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenericObject'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ head:
+ summary: Read expanded object headers by id
+ operationId: headExpandedObjectById
+ parameters:
+ - $ref: '#/components/parameters/ObjectId'
+ responses:
+ '200':
+ description: Expanded object headers
+ '404':
+ $ref: '#/components/responses/NotFound'
+ post:
+ summary: Read object by id with its current Annotations merged in, filtered by the request body
+ operationId: postExpandedObjectById
+ description: >-
+ The request body is an object of literal MongoDB filter properties ANDed into the search for
+ Annotations targeting the entity. URL query parameters supply no filters, though 'limit' and
+ 'skip' still page the search. The leaf version, the Annotation type, and the target
+ constraints are applied automatically and cannot be overruled, so 'target', 'type', '@type',
+ and '__rerum.history' keys are ignored.
+ parameters:
+ - $ref: '#/components/parameters/ObjectId'
+ - in: query
+ name: limit
+ required: false
+ description: Maximum Annotations to gather. Defaults to 200.
+ schema:
+ type: integer
+ - in: query
+ name: skip
+ required: false
+ description: Annotations to skip before gathering. Defaults to 0.
+ schema:
+ type: integer
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenericObject'
+ responses:
+ '200':
+ description: Expanded object payload
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenericObject'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '413':
+ $ref: '#/components/responses/PayloadTooLarge'
+ '415':
+ $ref: '#/components/responses/UnsupportedMediaType'
/since/{id}:
get:
summary: Read updates since id
diff --git a/public/API.html b/public/API.html
index 38f2df93..a105fc3c 100644
--- a/public/API.html
+++ b/public/API.html
@@ -48,6 +48,7 @@ API (1.1.0)
GET
@@ -61,6 +62,7 @@ API (1.1.0)
Custom Query
Text Search
Phrase Search
+ Expanded record with filters
HTTP POST Method Override
@@ -149,6 +151,99 @@ Single record by id
This can be used directly in the browser. Try it to see what the response resp looks like.
https://devstore.rerum.io/v1/id/11111
+ Expanded record by id
+
+
+
+ | Pattern |
+ Payload |
+ Response |
+
+
+
+
+ /id/_id/expanded |
+ empty |
+ 200 {JSON} |
+
+
+
+
+ Gather every current Annotation targeting the record and merge what those Annotations assert onto it.
+ This does the entity assembly that client applications otherwise perform with many
+ /query requests.
+
+
+ _id—the id of the record in
+ RERUM.
+ ?generator—optional. Only
+ expand with Annotations generated by this registered app agent. Both the
+ http and
+ https spellings of the URI are matched.
+ ?creator—optional. Only
+ expand with Annotations attributed to this creator.
+ ?limit and
+ ?skip—optional. Page the
+ Annotation search. limit defaults to 200.
+ - Response:
{JSON}—The record
+ with identifier _id, plus the merged
+ properties.
+ - Response header
+
Annotations-Merged—how many
+ Annotations went into this expansion. When it equals your
+ limit there may be more, and the record you
+ received was expanded from only part of its Annotations. Raise the
+ limit or walk pages with
+ skip.
+
+
+ Only the current (leaf) versions of Annotations are gathered. No token is required.
+ The response is a raw assembly—if your application needs the data in a particular shape, format the
+ response for your own internal needs.
+
+ What gets merged
+
+ - Only the Annotation's
body and
+ bodyValue are read. Every other property of
+ the Annotation is ignored, and an Annotation carrying neither is ignored entirely.
+ - A
body with exactly one property contributes
+ that property. Values are passed through exactly as they appear—a body of
+ {"text": {"value": "hello"}} puts
+ {"value": "hello"} on the entity, not
+ "hello".
+ - A
bodyValue string, and a
+ TextualBody body, both land under the property
+ bodyValue. The
+ TextualBody is kept whole so its
+ format and
+ language survive.
+ - When several Annotations assert the same property, or the record already carries it, the values collect
+ into an Array. Array order is not guaranteed.
+ - Identity and system properties are never overwritten:
+
@id,
+ id,
+ _id,
+ __rerum,
+ __deleted, and
+ @context.
+ - Skipped for now, as future work: a
body with
+ more than one property, an Annotation with multiple bodies, the
+ Choice,
+ Composite, and
+ List constructs, and a
+ body that is a URI referencing an external
+ resource.
+
+
+
Javascript Example
+
+ const expanded = await fetch("https://devstore.rerum.io/v1/id/11111/expanded").then(resp => resp.json()).catch(err => {throw err})
+
+
+
+ This can be used directly in the browser. Try it to see what the response resp looks like.
+ https://devstore.rerum.io/v1/id/11111/expanded
+
History tree before this version
@@ -748,6 +843,97 @@ Phrase Search
Results are returned sorted by relevance score in descending order. The __rerum.score property indicates match quality.
+ Expanded record with filters
+
+
+
+ | Pattern |
+ Payload |
+ Response |
+
+
+
+
+ /id/_id/expanded |
+ {JSON} |
+ 200 {JSON} |
+
+
+
+
+ The same expansion as
+ GET /id/_id/expanded,
+ with the search for Annotations narrowed by the request body. Use this when the convenience parameters on
+ the GET are not enough. Unlike the GET, this response is not browser cached.
+
+
+ - Payload:
{JSON}—an object of
+ literal properties combined into the search for Annotations targeting the record. The property names are
+ the real ones stored on an Annotation, so filtering by generating application means supplying
+ __rerum.generatedBy. An empty or absent body
+ expands without any filter.
+ - URL parameters supply no filters. The
+
?generator and
+ ?creator parameters of the GET have no effect
+ here—supply them in the body instead. Paging is not a filter, so
+ ?limit and
+ ?skip still work, and the
+ Annotations-Merged response header is set
+ exactly as it is on the GET.
+ __rerum.generatedBy and
+ creator are matched against both the
+ http and
+ https spellings of the URI you provide. Every
+ other property is applied exactly as supplied.
+ - The
Content-Type header must be
+ application/json. A body that is not a JSON
+ object is a 400.
+
+
+ Three constraints belong to the endpoint and cannot be overruled. Supplying them is not an error—they are
+ ignored, by exact name or as a dotted prefix.
+
+
+
+
+ | Ignored |
+ Always applied instead |
+
+
+
+
+ target,
+ target.@id,
+ target.id |
+ Annotations targeting the record at
+ _id |
+
+
+ type,
+ @type |
+ Annotations only |
+
+
+ __rerum.history.next,
+ __rerum.history.previous,
+ __rerum.history.prime |
+ Current (leaf) versions only |
+
+
+
+
+
Javascript Example
+
+ const expanded = await fetch("https://devstore.rerum.io/v1/id/11111/expanded", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ "__rerum.generatedBy": "https://devstore.rerum.io/v1/id/agent7",
+ "motivation": "describing"
+ })
+ }).then(resp => resp.json()).catch(err => {throw err})
+
+
HTTP POST Method Override
This section is non-normative.
diff --git a/routes/id.js b/routes/id.js
index fdfca44f..3e2069a1 100644
--- a/routes/id.js
+++ b/routes/id.js
@@ -2,6 +2,15 @@ import express from 'express'
const router = express.Router()
//This controller will handle all MongoDB interactions.
import controller from '../db-controller.js'
+import rest from '../rest.js'
+
+router.route('/:_id/expanded')
+ .get(controller.idExpanded)
+ .post(rest.verifyJsonContentType, controller.idExpanded)
+ .all((req, res, next) => {
+ res.statusMessage = 'Improper request method, please use GET or POST.'
+ res.status(405).end()
+ })
router.route('/:_id')
.get(controller.id)
From c060e50abcdf433e8a9d72a3a1d47b6434ad5fb7 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 7 Aug 2026 13:40:52 -0500
Subject: [PATCH 2/4] Catch the W3C SpecificResource form target variants as
well
---
controllers/utils.js | 6 +++++-
public/API.html | 14 +++++++++++---
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/controllers/utils.js b/controllers/utils.js
index 72e2614a..8aeb499a 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -120,6 +120,8 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"])
* - target: 'uri'
* - target: {'id':'uri'}
* - target: {'@id':'uri'}
+ * - target: {'source':'uri', 'type':'SpecificResource'} the W3C SpecificResource
+ * - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source
* and the likely Annotation type formats
* - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}
*
@@ -142,7 +144,9 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio
const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}]
if (targetId.startsWith("http")) {
const targetConditions = []
- for (const targetKey of ["target", "target.@id", "target.id"]) {
+ // 'target.source' is the W3C SpecificResource, which is how an Annotation targets a
+ // fragment or a selected region of a resource rather than the whole of it.
+ for (const targetKey of ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"]) {
targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") })
targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") })
}
diff --git a/public/API.html b/public/API.html
index a105fc3c..30c56868 100644
--- a/public/API.html
+++ b/public/API.html
@@ -225,7 +225,10 @@ Expanded record by id
_id,
__rerum,
__deleted, and
- @context.
+ @context. An assertion naming
+ __proto__ is dropped for the same reason—it
+ is not data, and emitting it would hand a prototype pollution vector to every client that parses the
+ response.
Skipped for now, as future work: a body with
more than one property, an Annotation with multiple bodies, the
Choice,
@@ -904,9 +907,14 @@ Expanded record with filters
target,
target.@id,
- target.id |
+ target.id,
+ target.source,
+ target.source.@id,
+ target.source.id
Annotations targeting the record at
- _id |
+ _id, including those
+ targeting it through a
+ SpecificResource
type,
From 1cb50865b180c21dbf47656bcb783921c897fa6b Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Tue, 11 Aug 2026 10:44:32 -0500
Subject: [PATCH 3/4] changes during review
---
controllers/crud.js | 19 +++++++++++++++++--
controllers/gog.js | 13 ++++++++++---
controllers/utils.js | 24 ++++++++++++++++++++++--
public/API.html | 4 +++-
4 files changed, 52 insertions(+), 8 deletions(-)
diff --git a/controllers/crud.js b/controllers/crud.js
index bcadc537..5c7946f3 100644
--- a/controllers/crud.js
+++ b/controllers/crud.js
@@ -159,6 +159,14 @@ function sanitizeExpansionFilters(supplied) {
return filters
}
+/**
+ * The Annotation body types whose value is kept whole rather than read as a single assertion.
+ * The OA prefixed spelling is honored for the Annotation type in findLeafAnnotationsFor(), so it
+ * is honored here too. Without it an 'oa:TextualBody' falls through to the single key check and
+ * is dropped, since a TextualBody always carries at least a type and a value.
+ */
+const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody"])
+
/**
* The [key, value] assertions an Annotation makes about the entity it targets.
* Only 'body' and 'bodyValue' are read -- an Annotation carrying neither is ignored, and no other
@@ -169,6 +177,7 @@ function sanitizeExpansionFilters(supplied) {
* - body: {'key': 'value'} a single assertion
* - body: {'key': {...}} a single assertion, value kept as-is
* - body: {'type':'TextualBody', 'value': 'text', ...} kept whole so 'format' and 'language' survive
+ * - body: {'@type':'oa:TextualBody', ...} the OA prefixed spelling of the same
*
* @param anno An Annotation document.
* @return An Array of [key, value] pairs to merge onto the entity.
@@ -180,7 +189,7 @@ function assertionsFrom(anno) {
// Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an
// external resource with no embedded value to expand with.
if (!body || typeof body !== "object" || Array.isArray(body)) return assertions
- if ((body.type ?? body["@type"]) === "TextualBody") {
+ if (TEXTUAL_BODY_TYPES.has(body.type ?? body["@type"])) {
assertions.push(["bodyValue", body])
return assertions
}
@@ -280,9 +289,15 @@ const idExpanded = async function (req, res, next) {
// Let clients detect a full page. When this equals the limit there may be more to gather,
// and the entity in hand is expanded from only part of its Annotations.
res.set('Annotations-Merged', String(annos.length))
+ // This deployment's '/expanded' URI, not the entity URI. The entity URI would hand back
+ // the unexpanded record, and it cannot be the base for this one either -- an entity minted
+ // by another RERUM carries that host in its stored '@id', and there is no guarantee the
+ // other host serves '/expanded' at all. RERUM_ID_PREFIX is how idNegotiation() mints ids,
+ // so this stays on the host actually answering the request.
+ const expandedLocation = `${process.env.RERUM_ID_PREFIX}${match._id}/expanded`
let expanded = applyRawExpansion(match, annos)
expanded = idNegotiation(expanded)
- res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"])
+ res.location(expandedLocation)
res.json(expanded)
} catch (error) {
return next(utils.createExpressError(error))
diff --git a/controllers/gog.js b/controllers/gog.js
index bfbe1b7c..e575910d 100644
--- a/controllers/gog.js
+++ b/controllers/gog.js
@@ -8,7 +8,7 @@
import { newID, isValidID, db } from '../database/index.js'
import utils from '../utils.js'
-import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor } from './utils.js'
+import { ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor } from './utils.js'
// The Gallery of Glosses agents, by RERUM ObjectId. Prod (store) and dev (devstore) mint different
// agents; only the trailing id is compared, so either host spelling matches.
@@ -364,7 +364,9 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde
},
evidence: assertion?.evidence ?? anno.evidence ?? ""
}
- if(expandedEntity.hasOwnProperty(key)){
+ // Object.hasOwn() rather than the method on the entity. A merged assertion named
+ // 'hasOwnProperty' would shadow the method and throw a TypeError on the next iteration.
+ if(Object.hasOwn(expandedEntity, key)){
expandedEntity[key] = Array.isArray(expandedEntity[key])
? [...expandedEntity[key], valueObject]
: [expandedEntity[key], valueObject]
@@ -401,6 +403,11 @@ const expandedId = async function (req, res, next) {
})
return next(utils.createExpressError(err))
}
+ // This '/gog/id' URI, not the entity URI. This response is the expanded representation,
+ // and the entity URI would hand back the unexpanded record instead. Built off
+ // RERUM_ID_PREFIX so the origin follows the deployment, and captured before expand() in
+ // case idNegotiation() reaches the match itself and drops '_id'.
+ const expandedLocation = new URL(`/gog/id/${match._id}`, process.env.RERUM_ID_PREFIX).href
// 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")
@@ -409,7 +416,7 @@ const expandedId = async function (req, res, next) {
res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "")
let expanded = await expand(match, generator)
expanded = idNegotiation(expanded)
- res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"])
+ res.location(expandedLocation)
res.json(expanded)
} catch (error) {
return next(utils.createExpressError(error))
diff --git a/controllers/utils.js b/controllers/utils.js
index 8aeb499a..4fbf0f2a 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -113,6 +113,19 @@ const generateSlugId = async function(slug_id="", next){
// must match both spellings. Every other supplied filter key is applied exactly as given.
const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"])
+// The properties an Annotation can carry the URI of its target under.
+const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"]
+
+/**
+ * Escape the RegExp metacharacters in a literal so it can be embedded in a pattern and match only
+ * itself. A RERUM URI has at least the dots of its host to escape.
+ * @param literal A string to be matched literally.
+ * @return The same string, safe to concatenate into a RegExp source.
+ */
+function escapeRegex(literal) {
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
/**
* Find the current (leaf) Annotations targeting an entity, for expansion.
*
@@ -122,6 +135,7 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"])
* - target: {'@id':'uri'}
* - target: {'source':'uri', 'type':'SpecificResource'} the W3C SpecificResource
* - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source
+ * - target: 'uri#xywh=0,0,100,100' a fragment of the resource
* and the likely Annotation type formats
* - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}
*
@@ -144,11 +158,17 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio
const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}]
if (targetId.startsWith("http")) {
const targetConditions = []
+ // Hanging a fragment off the URI is the other W3C way to target part of a resource rather
+ // than the whole of it, and an exact match will not catch one. Anchored at the front so
+ // the pattern can still use an index, and terminated by the '#' so it cannot spill onto a
+ // longer id. One pattern covers both spellings, since only the scheme is left unescaped.
+ const fragmentPattern = new RegExp(`^https?${escapeRegex(targetId.replace(/^https?/, ""))}#`)
// 'target.source' is the W3C SpecificResource, which is how an Annotation targets a
- // fragment or a selected region of a resource rather than the whole of it.
- for (const targetKey of ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"]) {
+ // selected region of a resource rather than the whole of it.
+ for (const targetKey of TARGET_KEYS) {
targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") })
targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") })
+ targetConditions.push({ [targetKey]: fragmentPattern })
}
queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions})
}
diff --git a/public/API.html b/public/API.html
index 30c56868..45838f66 100644
--- a/public/API.html
+++ b/public/API.html
@@ -914,7 +914,9 @@ Expanded record with filters
Annotations targeting the record at
_id, including those
targeting it through a
- SpecificResource |
+ SpecificResource or through a
+ fragment of its URI such as
+ #xywh=0,0,100,100 |
type,
From e39ff2e5d684bb2b7d6957330deb4e38c4c4e3f2 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Tue, 11 Aug 2026 11:21:55 -0500
Subject: [PATCH 4/4] changes during review
---
controllers/gog.js | 7 ++++++-
controllers/utils.js | 21 ++++++++++++++-------
2 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/controllers/gog.js b/controllers/gog.js
index e575910d..166c02f4 100644
--- a/controllers/gog.js
+++ b/controllers/gog.js
@@ -323,6 +323,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) {
*
* Anticipate likely Annotation type formats
* - {"type": "Annotation"}
+* - {"type": "oa:Annotation"}
* - {"@type": "Annotation"}
* - {"@type": "oa:Annotation"}
*
@@ -350,7 +351,11 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde
let expandedEntity = structuredClone(primitiveEntity)
for(const anno of matches){
const body = anno.body
- if(!body || typeof body !== "object") continue
+ // Array.isArray() as well as the typeof check. An Array is a typeof 'object', and
+ // Object.keys() on a one element Array is ["0"] -- a length of 1 that would pass the
+ // single assertion check below and merge the body onto the entity under the key "0".
+ // Annotations carrying multiple bodies are not expanded with.
+ if(!body || typeof body !== "object" || Array.isArray(body)) continue
const keys = Object.keys(body)
if(keys.length !== 1) continue
const key = keys[0]
diff --git a/controllers/utils.js b/controllers/utils.js
index 4fbf0f2a..a4e7a793 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -137,7 +137,8 @@ function escapeRegex(literal) {
* - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source
* - target: 'uri#xywh=0,0,100,100' a fragment of the resource
* and the likely Annotation type formats
- * - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}
+ * - {"type": "Annotation"}, {"type": "oa:Annotation"}
+ * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}
*
* @param targetId The '@id' or 'id' URI of the entity being expanded.
* @param filters Literal MongoDB filter keys to AND into the query. Already sanitized by the
@@ -155,20 +156,26 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio
"__rerum.history.next": { $exists: true, $size: 0 },
"$and": []
}
- const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}]
+ const annoTypeConditions = [
+ {"type": "Annotation"}, {"type": "oa:Annotation"},
+ {"@type": "Annotation"}, {"@type": "oa:Annotation"}
+ ]
if (targetId.startsWith("http")) {
const targetConditions = []
// Hanging a fragment off the URI is the other W3C way to target part of a resource rather
- // than the whole of it, and an exact match will not catch one. Anchored at the front so
- // the pattern can still use an index, and terminated by the '#' so it cannot spill onto a
- // longer id. One pattern covers both spellings, since only the scheme is left unescaped.
- const fragmentPattern = new RegExp(`^https?${escapeRegex(targetId.replace(/^https?/, ""))}#`)
+ // than the whole of it, and an exact match will not catch one. Anchored at the front and
+ // terminated by the '#' so the pattern cannot spill onto a longer id. One pattern per
+ // scheme rather than a single '^https?' -- Mongo bounds an index scan by the pattern's
+ // literal prefix, and '^https?' leaves it only 'http', which is every target URI stored.
+ const fragmentPatterns = ["http", "https"].map(scheme =>
+ new RegExp(`^${escapeRegex(targetId.replace(/^https?/, scheme))}#`)
+ )
// 'target.source' is the W3C SpecificResource, which is how an Annotation targets a
// selected region of a resource rather than the whole of it.
for (const targetKey of TARGET_KEYS) {
targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") })
targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") })
- targetConditions.push({ [targetKey]: fragmentPattern })
+ for (const fragmentPattern of fragmentPatterns) targetConditions.push({ [targetKey]: fragmentPattern })
}
queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions})
}
|