jsonld
Version:
A JSON-LD Processor and API implementation in JavaScript.
827 lines (751 loc) • 25.3 kB
JavaScript
/*
* Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved.
*/
'use strict';
const {isKeyword} = require('./context');
const graphTypes = require('./graphTypes');
const types = require('./types');
const util = require('./util');
const url = require('./url');
const JsonLdError = require('./JsonLdError');
const {
createNodeMap: _createNodeMap,
mergeNodeMapGraphs: _mergeNodeMapGraphs
} = require('./nodeMap');
const api = {};
module.exports = api;
/**
* Performs JSON-LD `merged` framing.
*
* @param input the expanded JSON-LD to frame.
* @param frame the expanded JSON-LD frame to use.
* @param options the framing options.
*
* @return the framed output.
*/
api.frameMergedOrDefault = (input, frame, options) => {
// create framing state
const state = {
options,
embedded: false,
graph: '@default',
graphMap: {'@default': {}},
subjectStack: [],
link: {},
bnodeMap: {}
};
// produce a map of all graphs and name each bnode
// FIXME: currently uses subjects from @merged graph only
const issuer = new util.IdentifierIssuer('_:b');
_createNodeMap(input, state.graphMap, '@default', issuer);
if(options.merged) {
state.graphMap['@merged'] = _mergeNodeMapGraphs(state.graphMap);
state.graph = '@merged';
}
state.subjects = state.graphMap[state.graph];
// frame the subjects
const framed = [];
api.frame(state, Object.keys(state.subjects).sort(), frame, framed);
// If pruning blank nodes, find those to prune
if(options.pruneBlankNodeIdentifiers) {
// remove all blank nodes appearing only once, done in compaction
options.bnodesToClear =
Object.keys(state.bnodeMap).filter(id => state.bnodeMap[id].length === 1);
}
// remove @preserve from results
options.link = {};
return _cleanupPreserve(framed, options);
};
/**
* Frames subjects according to the given frame.
*
* @param state the current framing state.
* @param subjects the subjects to filter.
* @param frame the frame.
* @param parent the parent subject or top-level array.
* @param property the parent property, initialized to null.
*/
api.frame = (state, subjects, frame, parent, property = null) => {
// validate the frame
_validateFrame(frame);
frame = frame[0];
// get flags for current frame
const options = state.options;
const flags = {
embed: _getFrameFlag(frame, options, 'embed'),
explicit: _getFrameFlag(frame, options, 'explicit'),
requireAll: _getFrameFlag(frame, options, 'requireAll')
};
// get link for current graph
if(!state.link.hasOwnProperty(state.graph)) {
state.link[state.graph] = {};
}
const link = state.link[state.graph];
// filter out subjects that match the frame
const matches = _filterSubjects(state, subjects, frame, flags);
// add matches to output
const ids = Object.keys(matches).sort();
for(const id of ids) {
const subject = matches[id];
/* Note: In order to treat each top-level match as a compartmentalized
result, clear the unique embedded subjects map when the property is null,
which only occurs at the top-level. */
if(property === null) {
state.uniqueEmbeds = {[state.graph]: {}};
} else {
state.uniqueEmbeds[state.graph] = state.uniqueEmbeds[state.graph] || {};
}
if(flags.embed === '@link' && id in link) {
// TODO: may want to also match an existing linked subject against
// the current frame ... so different frames could produce different
// subjects that are only shared in-memory when the frames are the same
// add existing linked subject
_addFrameOutput(parent, property, link[id]);
continue;
}
// start output for subject
const output = {'@id': id};
if(id.indexOf('_:') === 0) {
util.addValue(state.bnodeMap, id, output, {propertyIsArray: true});
}
link[id] = output;
// validate @embed
if((flags.embed === '@first' || flags.embed === '@last') && state.is11) {
throw new JsonLdError(
'Invalid JSON-LD syntax; invalid value of @embed.',
'jsonld.SyntaxError', {code: 'invalid @embed value', frame});
}
if(!state.embedded && state.uniqueEmbeds[state.graph].hasOwnProperty(id)) {
// skip adding this node object to the top level, as it was
// already included in another node object
continue;
}
// if embed is @never or if a circular reference would be created by an
// embed, the subject cannot be embedded, just add the reference;
// note that a circular reference won't occur when the embed flag is
// `@link` as the above check will short-circuit before reaching this point
if(state.embedded &&
(flags.embed === '@never' ||
_createsCircularReference(subject, state.graph, state.subjectStack))) {
_addFrameOutput(parent, property, output);
continue;
}
// if only the first (or once) should be embedded
if(state.embedded &&
(flags.embed == '@first' || flags.embed == '@once') &&
state.uniqueEmbeds[state.graph].hasOwnProperty(id)) {
_addFrameOutput(parent, property, output);
continue;
}
// if only the last match should be embedded
if(flags.embed === '@last') {
// remove any existing embed
if(id in state.uniqueEmbeds[state.graph]) {
_removeEmbed(state, id);
}
}
state.uniqueEmbeds[state.graph][id] = {parent, property};
// push matching subject onto stack to enable circular embed checks
state.subjectStack.push({subject, graph: state.graph});
// subject is also the name of a graph
if(id in state.graphMap) {
let recurse = false;
let subframe = null;
if(!('@graph' in frame)) {
recurse = state.graph !== '@merged';
subframe = {};
} else {
subframe = frame['@graph'][0];
recurse = !(id === '@merged' || id === '@default');
if(!types.isObject(subframe)) {
subframe = {};
}
}
if(recurse) {
// recurse into graph
api.frame(
{...state, graph: id, embedded: false},
Object.keys(state.graphMap[id]).sort(), [subframe], output, '@graph');
}
}
// if frame has @included, recurse over its sub-frame
if('@included' in frame) {
api.frame(
{...state, embedded: false},
subjects, frame['@included'], output, '@included');
}
// iterate over subject properties
for(const prop of Object.keys(subject).sort()) {
// copy keywords to output
if(isKeyword(prop)) {
output[prop] = util.clone(subject[prop]);
if(prop === '@type') {
// count bnode values of @type
for(const type of subject['@type']) {
if(type.indexOf('_:') === 0) {
util.addValue(
state.bnodeMap, type, output, {propertyIsArray: true});
}
}
}
continue;
}
// explicit is on and property isn't in the frame, skip processing
if(flags.explicit && !(prop in frame)) {
continue;
}
// add objects
for(const o of subject[prop]) {
const subframe = (prop in frame ?
frame[prop] : _createImplicitFrame(flags));
// recurse into list
if(graphTypes.isList(o)) {
const subframe =
(frame[prop] && frame[prop][0] && frame[prop][0]['@list']) ?
frame[prop][0]['@list'] :
_createImplicitFrame(flags);
// add empty list
const list = {'@list': []};
_addFrameOutput(output, prop, list);
// add list objects
const src = o['@list'];
for(const oo of src) {
if(graphTypes.isSubjectReference(oo)) {
// recurse into subject reference
api.frame(
{...state, embedded: true},
[oo['@id']], subframe, list, '@list');
} else {
// include other values automatically
_addFrameOutput(list, '@list', util.clone(oo));
}
}
} else if(graphTypes.isSubjectReference(o)) {
// recurse into subject reference
api.frame(
{...state, embedded: true},
[o['@id']], subframe, output, prop);
} else if(_valueMatch(subframe[0], o)) {
// include other values, if they match
_addFrameOutput(output, prop, util.clone(o));
}
}
}
// handle defaults
for(const prop of Object.keys(frame).sort()) {
// skip keywords
if(prop === '@type') {
if(!types.isObject(frame[prop][0]) ||
!('@default' in frame[prop][0])) {
continue;
}
// allow through default types
} else if(isKeyword(prop)) {
continue;
}
// if omit default is off, then include default values for properties
// that appear in the next frame but are not in the matching subject
const next = frame[prop][0] || {};
const omitDefaultOn = _getFrameFlag(next, options, 'omitDefault');
if(!omitDefaultOn && !(prop in output)) {
let preserve = '@null';
if('@default' in next) {
preserve = util.clone(next['@default']);
}
if(!types.isArray(preserve)) {
preserve = [preserve];
}
output[prop] = [{'@preserve': preserve}];
}
}
// if embed reverse values by finding nodes having this subject as a value
// of the associated property
for(const reverseProp of Object.keys(frame['@reverse'] || {}).sort()) {
const subframe = frame['@reverse'][reverseProp];
for(const subject of Object.keys(state.subjects)) {
const nodeValues =
util.getValues(state.subjects[subject], reverseProp);
if(nodeValues.some(v => v['@id'] === id)) {
// node has property referencing this subject, recurse
output['@reverse'] = output['@reverse'] || {};
util.addValue(
output['@reverse'], reverseProp, [], {propertyIsArray: true});
api.frame(
{...state, embedded: true},
[subject], subframe, output['@reverse'][reverseProp],
property);
}
}
}
// add output to parent
_addFrameOutput(parent, property, output);
// pop matching subject from circular ref-checking stack
state.subjectStack.pop();
}
};
/**
* Replace `@null` with `null`, removing it from arrays.
*
* @param input the framed, compacted output.
* @param options the framing options used.
*
* @return the resulting output.
*/
api.cleanupNull = (input, options) => {
// recurse through arrays
if(types.isArray(input)) {
const noNulls = input.map(v => api.cleanupNull(v, options));
return noNulls.filter(v => v); // removes nulls from array
}
if(input === '@null') {
return null;
}
if(types.isObject(input)) {
// handle in-memory linked nodes
if('@id' in input) {
const id = input['@id'];
if(options.link.hasOwnProperty(id)) {
const idx = options.link[id].indexOf(input);
if(idx !== -1) {
// already visited
return options.link[id][idx];
}
// prevent circular visitation
options.link[id].push(input);
} else {
// prevent circular visitation
options.link[id] = [input];
}
}
for(const key in input) {
input[key] = api.cleanupNull(input[key], options);
}
}
return input;
};
/**
* Creates an implicit frame when recursing through subject matches. If
* a frame doesn't have an explicit frame for a particular property, then
* a wildcard child frame will be created that uses the same flags that the
* parent frame used.
*
* @param flags the current framing flags.
*
* @return the implicit frame.
*/
function _createImplicitFrame(flags) {
const frame = {};
for(const key in flags) {
if(flags[key] !== undefined) {
frame['@' + key] = [flags[key]];
}
}
return [frame];
}
/**
* Checks the current subject stack to see if embedding the given subject
* would cause a circular reference.
*
* @param subjectToEmbed the subject to embed.
* @param graph the graph the subject to embed is in.
* @param subjectStack the current stack of subjects.
*
* @return true if a circular reference would be created, false if not.
*/
function _createsCircularReference(subjectToEmbed, graph, subjectStack) {
for(let i = subjectStack.length - 1; i >= 0; --i) {
const subject = subjectStack[i];
if(subject.graph === graph &&
subject.subject['@id'] === subjectToEmbed['@id']) {
return true;
}
}
return false;
}
/**
* Gets the frame flag value for the given flag name.
*
* @param frame the frame.
* @param options the framing options.
* @param name the flag name.
*
* @return the flag value.
*/
function _getFrameFlag(frame, options, name) {
const flag = '@' + name;
let rval = (flag in frame ? frame[flag][0] : options[name]);
if(name === 'embed') {
// default is "@last"
// backwards-compatibility support for "embed" maps:
// true => "@last"
// false => "@never"
if(rval === true) {
rval = '@once';
} else if(rval === false) {
rval = '@never';
} else if(rval !== '@always' && rval !== '@never' && rval !== '@link' &&
rval !== '@first' && rval !== '@last' && rval !== '@once') {
throw new JsonLdError(
'Invalid JSON-LD syntax; invalid value of @embed.',
'jsonld.SyntaxError', {code: 'invalid @embed value', frame});
}
}
return rval;
}
/**
* Validates a JSON-LD frame, throwing an exception if the frame is invalid.
*
* @param frame the frame to validate.
*/
function _validateFrame(frame) {
if(!types.isArray(frame) || frame.length !== 1 || !types.isObject(frame[0])) {
throw new JsonLdError(
'Invalid JSON-LD syntax; a JSON-LD frame must be a single object.',
'jsonld.SyntaxError', {frame});
}
if('@id' in frame[0]) {
for(const id of util.asArray(frame[0]['@id'])) {
// @id must be wildcard or an IRI
if(!(types.isObject(id) || url.isAbsolute(id)) ||
(types.isString(id) && id.indexOf('_:') === 0)) {
throw new JsonLdError(
'Invalid JSON-LD syntax; invalid @id in frame.',
'jsonld.SyntaxError', {code: 'invalid frame', frame});
}
}
}
if('@type' in frame[0]) {
for(const type of util.asArray(frame[0]['@type'])) {
// @type must be wildcard, IRI, or @json
if(!(types.isObject(type) || url.isAbsolute(type) ||
(type === '@json')) ||
(types.isString(type) && type.indexOf('_:') === 0)) {
throw new JsonLdError(
'Invalid JSON-LD syntax; invalid @type in frame.',
'jsonld.SyntaxError', {code: 'invalid frame', frame});
}
}
}
}
/**
* Returns a map of all of the subjects that match a parsed frame.
*
* @param state the current framing state.
* @param subjects the set of subjects to filter.
* @param frame the parsed frame.
* @param flags the frame flags.
*
* @return all of the matched subjects.
*/
function _filterSubjects(state, subjects, frame, flags) {
// filter subjects in @id order
const rval = {};
for(const id of subjects) {
const subject = state.graphMap[state.graph][id];
if(_filterSubject(state, subject, frame, flags)) {
rval[id] = subject;
}
}
return rval;
}
/**
* Returns true if the given subject matches the given frame.
*
* Matches either based on explicit type inclusion where the node has any
* type listed in the frame. If the frame has empty types defined matches
* nodes not having a @type. If the frame has a type of {} defined matches
* nodes having any type defined.
*
* Otherwise, does duck typing, where the node must have all of the
* properties defined in the frame.
*
* @param state the current framing state.
* @param subject the subject to check.
* @param frame the frame to check.
* @param flags the frame flags.
*
* @return true if the subject matches, false if not.
*/
function _filterSubject(state, subject, frame, flags) {
// check ducktype
let wildcard = true;
let matchesSome = false;
for(const key in frame) {
let matchThis = false;
const nodeValues = util.getValues(subject, key);
const isEmpty = util.getValues(frame, key).length === 0;
if(key === '@id') {
// match on no @id or any matching @id, including wildcard
if(types.isEmptyObject(frame['@id'][0] || {})) {
matchThis = true;
} else if(frame['@id'].length >= 0) {
matchThis = frame['@id'].includes(nodeValues[0]);
}
if(!flags.requireAll) {
return matchThis;
}
} else if(key === '@type') {
// check @type (object value means 'any' type,
// fall through to ducktyping)
wildcard = false;
if(isEmpty) {
if(nodeValues.length > 0) {
// don't match on no @type
return false;
}
matchThis = true;
} else if(frame['@type'].length === 1 &&
types.isEmptyObject(frame['@type'][0])) {
// match on wildcard @type if there is a type
matchThis = nodeValues.length > 0;
} else {
// match on a specific @type
for(const type of frame['@type']) {
if(types.isObject(type) && '@default' in type) {
// match on default object
matchThis = true;
} else {
matchThis = matchThis || nodeValues.some(tt => tt === type);
}
}
}
if(!flags.requireAll) {
return matchThis;
}
} else if(isKeyword(key)) {
continue;
} else {
// Force a copy of this frame entry so it can be manipulated
const thisFrame = util.getValues(frame, key)[0];
let hasDefault = false;
if(thisFrame) {
_validateFrame([thisFrame]);
hasDefault = '@default' in thisFrame;
}
// no longer a wildcard pattern if frame has any non-keyword properties
wildcard = false;
// skip, but allow match if node has no value for property, and frame has
// a default value
if(nodeValues.length === 0 && hasDefault) {
continue;
}
// if frame value is empty, don't match if subject has any value
if(nodeValues.length > 0 && isEmpty) {
return false;
}
if(thisFrame === undefined) {
// node does not match if values is not empty and the value of property
// in frame is match none.
if(nodeValues.length > 0) {
return false;
}
matchThis = true;
} else {
if(graphTypes.isList(thisFrame)) {
const listValue = thisFrame['@list'][0];
if(graphTypes.isList(nodeValues[0])) {
const nodeListValues = nodeValues[0]['@list'];
if(graphTypes.isValue(listValue)) {
// match on any matching value
matchThis = nodeListValues.some(lv => _valueMatch(listValue, lv));
} else if(graphTypes.isSubject(listValue) ||
graphTypes.isSubjectReference(listValue)) {
matchThis = nodeListValues.some(lv => _nodeMatch(
state, listValue, lv, flags));
}
}
} else if(graphTypes.isValue(thisFrame)) {
matchThis = nodeValues.some(nv => _valueMatch(thisFrame, nv));
} else if(graphTypes.isSubjectReference(thisFrame)) {
matchThis =
nodeValues.some(nv => _nodeMatch(state, thisFrame, nv, flags));
} else if(types.isObject(thisFrame)) {
matchThis = nodeValues.length > 0;
} else {
matchThis = false;
}
}
}
// all non-defaulted values must match if requireAll is set
if(!matchThis && flags.requireAll) {
return false;
}
matchesSome = matchesSome || matchThis;
}
// return true if wildcard or subject matches some properties
return wildcard || matchesSome;
}
/**
* Removes an existing embed.
*
* @param state the current framing state.
* @param id the @id of the embed to remove.
*/
function _removeEmbed(state, id) {
// get existing embed
const embeds = state.uniqueEmbeds[state.graph];
const embed = embeds[id];
const parent = embed.parent;
const property = embed.property;
// create reference to replace embed
const subject = {'@id': id};
// remove existing embed
if(types.isArray(parent)) {
// replace subject with reference
for(let i = 0; i < parent.length; ++i) {
if(util.compareValues(parent[i], subject)) {
parent[i] = subject;
break;
}
}
} else {
// replace subject with reference
const useArray = types.isArray(parent[property]);
util.removeValue(parent, property, subject, {propertyIsArray: useArray});
util.addValue(parent, property, subject, {propertyIsArray: useArray});
}
// recursively remove dependent dangling embeds
const removeDependents = id => {
// get embed keys as a separate array to enable deleting keys in map
const ids = Object.keys(embeds);
for(const next of ids) {
if(next in embeds && types.isObject(embeds[next].parent) &&
embeds[next].parent['@id'] === id) {
delete embeds[next];
removeDependents(next);
}
}
};
removeDependents(id);
}
/**
* Removes the @preserve keywords from expanded result of framing.
*
* @param input the framed, framed output.
* @param options the framing options used.
*
* @return the resulting output.
*/
function _cleanupPreserve(input, options) {
// recurse through arrays
if(types.isArray(input)) {
return input.map(value => _cleanupPreserve(value, options));
}
if(types.isObject(input)) {
// remove @preserve
if('@preserve' in input) {
return input['@preserve'][0];
}
// skip @values
if(graphTypes.isValue(input)) {
return input;
}
// recurse through @lists
if(graphTypes.isList(input)) {
input['@list'] = _cleanupPreserve(input['@list'], options);
return input;
}
// handle in-memory linked nodes
if('@id' in input) {
const id = input['@id'];
if(options.link.hasOwnProperty(id)) {
const idx = options.link[id].indexOf(input);
if(idx !== -1) {
// already visited
return options.link[id][idx];
}
// prevent circular visitation
options.link[id].push(input);
} else {
// prevent circular visitation
options.link[id] = [input];
}
}
// recurse through properties
for(const prop in input) {
// potentially remove the id, if it is an unreference bnode
if(prop === '@id' && options.bnodesToClear.includes(input[prop])) {
delete input['@id'];
continue;
}
input[prop] = _cleanupPreserve(input[prop], options);
}
}
return input;
}
/**
* Adds framing output to the given parent.
*
* @param parent the parent to add to.
* @param property the parent property.
* @param output the output to add.
*/
function _addFrameOutput(parent, property, output) {
if(types.isObject(parent)) {
util.addValue(parent, property, output, {propertyIsArray: true});
} else {
parent.push(output);
}
}
/**
* Node matches if it is a node, and matches the pattern as a frame.
*
* @param state the current framing state.
* @param pattern used to match value
* @param value to check
* @param flags the frame flags.
*/
function _nodeMatch(state, pattern, value, flags) {
if(!('@id' in value)) {
return false;
}
const nodeObject = state.subjects[value['@id']];
return nodeObject && _filterSubject(state, nodeObject, pattern, flags);
}
/**
* Value matches if it is a value and matches the value pattern
*
* * `pattern` is empty
* * @values are the same, or `pattern[@value]` is a wildcard, and
* * @types are the same or `value[@type]` is not null
* and `pattern[@type]` is `{}`, or `value[@type]` is null
* and `pattern[@type]` is null or `[]`, and
* * @languages are the same or `value[@language]` is not null
* and `pattern[@language]` is `{}`, or `value[@language]` is null
* and `pattern[@language]` is null or `[]`.
*
* @param pattern used to match value
* @param value to check
*/
function _valueMatch(pattern, value) {
const v1 = value['@value'];
const t1 = value['@type'];
const l1 = value['@language'];
const v2 = pattern['@value'] ?
(types.isArray(pattern['@value']) ?
pattern['@value'] : [pattern['@value']]) :
[];
const t2 = pattern['@type'] ?
(types.isArray(pattern['@type']) ?
pattern['@type'] : [pattern['@type']]) :
[];
const l2 = pattern['@language'] ?
(types.isArray(pattern['@language']) ?
pattern['@language'] : [pattern['@language']]) :
[];
if(v2.length === 0 && t2.length === 0 && l2.length === 0) {
return true;
}
if(!(v2.includes(v1) || types.isEmptyObject(v2[0]))) {
return false;
}
if(!(!t1 && t2.length === 0 || t2.includes(t1) || t1 &&
types.isEmptyObject(t2[0]))) {
return false;
}
if(!(!l1 && l2.length === 0 || l2.includes(l1) || l1 &&
types.isEmptyObject(l2[0]))) {
return false;
}
return true;
}