diff --git a/controllers/crud.js b/controllers/crud.js index 758a048a..5c7946f3 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,186 @@ 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 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 + * 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 + * - 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. + */ +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 (TEXTUAL_BODY_TYPES.has(body.type ?? body["@type"])) { + 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)) + // 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(expandedLocation) + 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..166c02f4 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 { 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. @@ -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"} * @@ -336,66 +337,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) @@ -403,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] @@ -417,7 +369,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] @@ -454,6 +408,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") @@ -462,7 +421,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 dd455d05..a4e7a793 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -109,6 +109,100 @@ 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"]) + +// 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. + * + * Anticipates the likely Annotation target formats + * - 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 + * - target: 'uri#xywh=0,0,100,100' a fragment of the resource + * and the likely Annotation type formats + * - {"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 + * 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": "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 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") }) + for (const fragmentPattern of fragmentPatterns) targetConditions.push({ [targetKey]: fragmentPattern }) + } + 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 +559,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..45838f66 100644 --- a/public/API.html +++ b/public/API.html @@ -48,6 +48,7 @@
resp looks like.
https://devstore.rerum.io/v1/id/11111
+ | 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.{JSON}—The record
+ with identifier _id, plus the merged
+ properties.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. +
+body and
+ bodyValue are read. Every other property of
+ the Annotation is ignored, and an Annotation carrying neither is ignored entirely.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".bodyValue string, and a
+ TextualBody body, both land under the property
+ bodyValue. The
+ TextualBody is kept whole so its
+ format and
+ language survive.@id,
+ id,
+ _id,
+ __rerum,
+ __deleted, and
+ @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.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.+
+ 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
+
| 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.
+
{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.?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.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,
+ target.source,
+ target.source.@id,
+ target.source.id |
+ Annotations targeting the record at
+ _id, including those
+ targeting it through a
+ SpecificResource or through a
+ fragment of its URI such as
+ #xywh=0,0,100,100 |
+
type,
+ @type |
+ Annotations only | +
__rerum.history.next,
+ __rerum.history.previous,
+ __rerum.history.prime |
+ Current (leaf) versions only | +
+
+ 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})
+
+
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)