/v1/id/_:id/expanded endpoint - #286
Draft
thehabes wants to merge 9 commits into
Draft
Conversation
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #285.
Summary
Adds
GETandPOST /v1/id/:_id/expanded— a generic, unauthenticated, browser-cacheable endpoint that returns a RERUM object with the assertions of every current Annotation targeting it already merged in./gog/id/:_id(#280) proved the approach but is GoG-shaped: it wraps every merged value in DEER's{ value, source, evidence }object. Almost every application building entities in RERUM does this same gather-and-merge on the client, across many/queryrequests, so the generic version emits raw values instead — as close to what the Annotation body actually says as possible. Clients that need a particular shape format the response themselves.The Annotation-gathering logic is now shared by both endpoints, so they cannot drift apart.
Changes
New endpoint
routes/id.js—/:_id/expandedregistered ahead of/:_id.GET,POST(behindrest.verifyJsonContentType), andHEADvia Express's nativeGEThandling. Everything else is a405.controllers/crud.js—idExpanded()plus three helpers:sanitizeExpansionFilters()reduces a POST body to the filter keys this endpoint will honor.assertionsFrom()reads onlybodyandbodyValueoff an Annotation, so no other property of an Annotation can leak onto the entity.applyRawExpansion()merges those assertions onto a clone of the entity and re-appends__rerumlast.db-controller.js— exportsidExpanded.Shared Annotation gathering
controllers/utils.js— newfindLeafAnnotationsFor(targetId, filters, pagination), lifted out ofcontrollers/gog.jsexpand()and widened. It constrains to current (leaf) versions and to Annotations, and it matches these target forms — each under both thehttpandhttpsspelling:and these type spellings, under both
typeand@type:target.source*and the#fragmentform are new — the GoG path previously matched onlytarget,target.@id, andtarget.id, so it was silently missing every Annotation that targets a selected region rather than the whole resource. The fragment patterns are built through anescapeRegex()helper.Indexes already exist for all six target keys, so this stays index-backed.
explain("executionStats")on a real entity:nReturned 24, totalKeysExamined 30, totalDocsExamined 25.GET— the convenience formImagined as the plain
GETwhere nothing else is supplied. Recognizes?generator=and?creator=only, both matched across thehttp/httpsspellings of the URI.?limit=(default 200) and?skip=page the Annotation search.POST— the filtered formThe JSON body is an object of literal MongoDB filter keys,
ANDed into the Annotation search — so filtering by generating app means supplying the real property name,__rerum.generatedBy. URL parameters supply no filters here, though?limit=and?skip=still page. Not browser-cached.Three constraints belong to the endpoint and cannot be overruled. Supplying them is not an error, they are dropped by exact name or dotted prefix:
target,target.@id,target.id,target.source,target.source.@id,target.source.id:_idtype,@type__rerum.history.*Because every supplied filter is
ANDed in, a filter can only narrow the result set — there is no way to widen it past the entity in the request URI.What gets merged
bodywith exactly one property contributes that property, value passed through exactly as it appears. A body of{"text": {"value": "hello"}}puts{"value": "hello"}on the entity, not"hello".bodyValuestring and aTextualBodybody both land underbodyValue; theTextualBodyis kept whole so itsformatandlanguagesurvive.@id,id,_id,__rerum,__deleted,@context. An assertion naming__proto__is dropped — it is not data, and emitting it would hand a prototype pollution vector to every client that parses the response.bodywith more than one property, an Annotation with multiple bodies (Expansion drops Annotations that carry multiple bodies #288), theChoice/Composite/Listconstructs, and abodythat is a bare URI referencing an external resource.Response headers
Cache-Control: max-age=86400, must-revalidateon theGET, so browsers can cache for 24h. Annotations on an entity are volatile during initial creation and stable afterward, so this trades a hard reload in the rare stale case for not re-running the expand on every visit. Not set on thePOST.No
Last-Modified— it derives from the root entity's__rerumand would not move when a targeting Annotation changed, which is the wrong freshness signal for a composed body. Same reasoning as #283. Express'sETagstill gives conditional revalidation.Two new headers let a client reason about coverage:
Annotations-Gathered— the size of the page you received. When it equals yourlimitthere may be more, and you were expanded from only part of the record's Annotations. This is the one to page off.Annotations-Merged— how many of those could contribute. Annotations with multiple bodies are not counted. A counted Annotation may still assert nothing mergeable, so this is not a count of properties received either.Locationis canonicalized to the_idform, so a slug request advertises the stable URL.controllers/gog.jsRefactored onto the shared helper, which is where the bulk of the deletions come from. Three behavior changes ride along, all on
/gog/id/:_id:hasOwnProperty→Object.hasOwn. An Annotation body assertinghasOwnPropertywould otherwise shadow the method and break the merge. This one is load-bearing, not cosmetic.Annotations carrying multiple bodies are now skipped explicitly. Previously a one-element Array body merged onto the entity under the key
"0". See Expansion drops Annotations that carry multiple bodies #288./gog/id/:_idis now restricted to GoG-generated data (403otherwise), and expands only with Annotations from the entity's own generator. This is the follow-through on the open note left in /gog/id/:_id expand endpoint for GoG Data #280 — "this still doesn't stop anyone from using/gog/id/:_idto expand their own data — restricting it to GoG data would need an additional guard (e.g. gating on__rerum.generatedBy/ GENERATOR)" — and it lines up with Filtering by generator for gog data #284.Measured against production before merging: across 3,000 sampled leaf entities generated by a GoG agent, all 24,259 leaf Annotations targeting them are from the GoG production agent, so the generator filter drops nothing today. The prod/dev agent ids moved into named constants at the top of the file.
Documentation
public/API.htmlgets both forms underGETandPOST, including the full merge rules and the ignored-filters table.openapi/contracts/core-provider.openapi.yamlgets/id/{id}/expandedwithget,head, andpost.Testing
npm test— 188 pass, 0 fail.Manually exercised against production data: control entity
614362c3e74876243131a4e0(a GoG Gloss) and a purpose-built variant entity6a760abbeaaeae59c6c5e3ba(a IIIF Manifest annotated to hit the edge cases —bodyValue,oa:TextualBody, fragment target,target.id,target.@id, Array-valued assertions, multi-key bodies, and hostile probes asserting__proto__,constructor,toJSON,hasOwnProperty,@id,_id,__rerum,@context).Annotation detection was also replayed against MongoDB over synthetic documents, covering 19 valid W3C target/type forms — all matched, including array-valued
type, array targets, andSpecificResourcewith an embeddedsource.Notes and follow-ups
controllers/crud.jsare pure and the db mock already exists, soroutes/__tests__/idExpanded.test.jsis cheap to add. Worth doing before this sees real client traffic./v1/id/:_id/expandedEndpoint #285 raised this as an open question for DEER'scitationSource. The raw form deliberately does not carry it — a client that needs provenance still has to query. Worth revisiting if it turns out to be needed.limitof 200 is comfortable today — the most-annotated single target in production carries 26 leaf Annotations.Split out of review so they do not block this PR: #287 (
Allowheader on 405s), #288 (Annotations with multiple bodies), #289 (hardening filter endpoints against MongoDB operator keys).