Skip to content

/v1/id/_:id/expanded endpoint - #286

Open
thehabes wants to merge 49 commits into
mainfrom
285-id-expanded
Open

/v1/id/_:id/expanded endpoint#286
thehabes wants to merge 49 commits into
mainfrom
285-id-expanded

Conversation

@thehabes

@thehabes thehabes commented Aug 7, 2026

Copy link
Copy Markdown
Member

Closes #285.

Summary

Adds a GET and POST /v1/id/:_id/expanded endpoint — a generic, unauthenticated, browser-cacheable endpoint that returns a RERUM object with the assertions of every current Annotation targeting it already merged in.

Changes

routes/id.js/:_id/expanded registered ahead of /:_id. GET, POST (behind rest.verifyJsonContentType), and HEAD via Express's native GET handling. Every other method is a 405. OPTIONS is answered 204 by the CORS layer, which sits ahead of the route.

controllers/crud.jsidExpanded() plus three helpers:

  • sanitizeExpansionFilters() reduces a POST body to the filter keys this endpoint will honor.
  • assertionsFrom() reads only body and bodyValue off an Annotation, so no other property of an Annotation can leak onto the entity.
  • applyExpansionAnnotations() merges those assertions onto a clone of the entity and re-appends __rerum last.

db-controller.js — exports idExpanded.

controllers/utils.jsfindLeafAnnotationsFor() builds the Annotation search and reads the whole result set from a single cursor, which the driver pages server-side in strides of EXPANSION_BATCH_SIZE (200). The query plan promises no order, so the result is sorted by _id before _id is dropped. That is roughly Annotation creation order, and it makes the assembled entity byte-stable, which is what lets the weak ETag answer a revalidation with 304.

Two smaller fixes in the same file, both needed by this endpoint:

  • _contextid() now skips non-string members of an @context Array. An inline term definition object names no context, and the old code called .includes() on it — _contextid(["@context" array starting with an object]) threw a TypeError. Legal JSON-LD, reachable through any /v1/id/ read.
  • idNegotiation() restructured to return early instead of cloning up front and conditionally rebuilding. This prevents needless extra cloning between the routes and this helper function.

utils.js

Two changes here, neither specific to /expanded but both on paths it reads through:

  • configureRerumOptions() only inherits received.__rerum when update is true. This fixes a bug where client supplied values went into the __rerum property. Affects POST /v1/api/create and POST /v1/api/bulkCreate.
  • isContainerType() accepts an Array @type/type and ignores non-string members. JSON-LD serializes a single type as a one-element Array, and with the old code a numeric type threw a TypeError out of configureWebAnnoHeadersFor().

Neither form pages

Both methods gather every Annotation matching their filters before the entity is assembled. A client asks once and receives the whole thing. There is no ?limit= or ?skip= on this endpoint — the 200 in findLeafAnnotationsFor() is an internal cursor stride, not a page size.

GET — the convenience form

Imagined as the plain GET where nothing else is supplied. Recognizes ?generator= and ?creator= only. A value shaped like a URI is matched across both the http and https spellings; any other value is matched exactly. A parameter supplied more than once arrives as an Array and is dropped rather than applied, as is a bracketed form like ?generator[k]=v; the other parameter still applies if it was supplied correctly.

POST — the filtered form

The 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. Not browser-cached.

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.

The http/https doubling applies to __rerum.generatedBy and creator in this form too, since it lives in the shared helper. Every other key is applied exactly as given.

Response headers

  • Cache-Control: max-age=86400, must-revalidate — only on the GET, 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.
  • Annotations-Gathered — how many current Annotations were gathered for this expansion. This is a complete count, not the size of a page.
  • 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.

Deleted records

A deleted record is returned exactly as GET /v1/id/:_id returns it, with no expansion attempted and both count headers reporting 0.

controllers/gog.js

Refactored onto the shared helper, which is where the bulk of the deletions come from. Three behavior changes ride along, all on /gog/id/:_id:

  • hasOwnPropertyObject.hasOwn. An Annotation body asserting hasOwnProperty would otherwise shadow the method and break the merge. This one is load-bearing, not cosmetic.
  • Annotations carrying multiple bodies are now skipped explicitly.
  • /gog/id/:_id is now restricted to data generated by a Gallery of Glosses agent and expands only with Annotations from the entity's own generator. A deleted record short-circuits ahead of that gate and returns its tombstone, since a tombstone has no generator to match on.

Measured against production before merging, over the full population rather than a sample:

  • 80,154 leaf entities are generated by the GoG production agent.
  • 78,022 leaf Annotations target a rerum.io/v1/id/ URI under any of the six target keys.
  • 70,299 of those are generated by the GoG production agent. The other 7,723 point at 2,614 distinct RERUM entities, and none of those 2,614 is GoG-generated.

So the generator filter drops nothing today.

Documentation

public/API.html gets both forms under GET and POST, plus an Entity Expansion section carrying the merge rules, the reserved filter keys, the protected properties, and the deleted-record case. It also picks up unrelated markup repairs in passing — unclosed </span>s, a nested <code>, and two malformed <code __rerum.generatedBy> tags.

openapi/contracts/core-provider.openapi.yaml gets /id/{id}/expanded with get, head, and post, and documents the response headers for /id/{id} as well. The reserved filter keys the POST ignores are documented in public/API.html but not yet in the contract.

Testing

No automated tests have been written for the new endpoint yet, this is TODO.

…cation header and/or @id should be the /id/_id/expanded URI instead.  However, downstream logic and behaviors expect the @id to come back for object validation.  It is better to keep those aligned for now
…s cannot be expected to paginate simple GET requests. The response is now fully assembled.
…but only has one {} in it, as we can treat that the same way as a body that is just {} for expanding purposes.
…_contextid guard related to latent bug. Documentation cleanup.
…nored

/expanded skips the Annotation gather and the merge for a deleted record and
hands the tombstone back exactly as GET /v1/id/:_id does, with both count
headers at 0.  A production record with 8 leaf Annotations targeting it was
being re-dressed with the descriptive data of the record it used to be.

configureRerumOptions only receives a trustworthy __rerum on the update path,
where the caller hands it a record from the database, so a supplied __rerum is
read there alone.  history and releases are built locally instead of being
rebound to the received objects, so caller properties under them no longer
reach storage and an affected record is cleaned on its next version.
@thehabes
thehabes marked this pull request as ready for review August 20, 2026 19:36
@thehabes
thehabes requested a review from cubap as a code owner August 20, 2026 19:36
@thehabes
thehabes deployed to development August 20, 2026 22:41 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/v1/id/:_id/expanded Endpoint

1 participant