jsonld
Version:
A JSON-LD Processor and API implementation in JavaScript.
456 lines (422 loc) • 12.6 kB
JavaScript
/*
* Copyright (c) 2017-2019 Digital Bazaar, Inc. All rights reserved.
*/
;
const graphTypes = require('./graphTypes');
const types = require('./types');
// TODO: move `IdentifierIssuer` to its own package
const IdentifierIssuer = require('rdf-canonize').IdentifierIssuer;
const JsonLdError = require('./JsonLdError');
// constants
const REGEX_BCP47 = /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/;
const REGEX_LINK_HEADERS = /(?:<[^>]*?>|"[^"]*?"|[^,])+/g;
const REGEX_LINK_HEADER = /\s*<([^>]*?)>\s*(?:;\s*(.*))?/;
const REGEX_LINK_HEADER_PARAMS =
/(.*?)=(?:(?:"([^"]*?)")|([^"]*?))\s*(?:(?:;\s*)|$)/g;
const REGEX_KEYWORD = /^@[a-zA-Z]+$/;
const DEFAULTS = {
headers: {
accept: 'application/ld+json, application/json'
}
};
const api = {};
module.exports = api;
api.IdentifierIssuer = IdentifierIssuer;
api.REGEX_BCP47 = REGEX_BCP47;
api.REGEX_KEYWORD = REGEX_KEYWORD;
/**
* Clones an object, array, Map, Set, or string/number. If a typed JavaScript
* object is given, such as a Date, it will be converted to a string.
*
* @param value the value to clone.
*
* @return the cloned value.
*/
api.clone = function(value) {
if(value && typeof value === 'object') {
let rval;
if(types.isArray(value)) {
rval = [];
for(let i = 0; i < value.length; ++i) {
rval[i] = api.clone(value[i]);
}
} else if(value instanceof Map) {
rval = new Map();
for(const [k, v] of value) {
rval.set(k, api.clone(v));
}
} else if(value instanceof Set) {
rval = new Set();
for(const v of value) {
rval.add(api.clone(v));
}
} else if(types.isObject(value)) {
rval = {};
for(const key in value) {
rval[key] = api.clone(value[key]);
}
} else {
rval = value.toString();
}
return rval;
}
return value;
};
/**
* Ensure a value is an array. If the value is an array, it is returned.
* Otherwise, it is wrapped in an array.
*
* @param value the value to return as an array.
*
* @return the value as an array.
*/
api.asArray = function(value) {
return Array.isArray(value) ? value : [value];
};
/**
* Builds an HTTP headers object for making a JSON-LD request from custom
* headers and asserts the `accept` header isn't overridden.
*
* @param headers an object of headers with keys as header names and values
* as header values.
*
* @return an object of headers with a valid `accept` header.
*/
api.buildHeaders = (headers = {}) => {
const hasAccept = Object.keys(headers).some(
h => h.toLowerCase() === 'accept');
if(hasAccept) {
throw new RangeError(
'Accept header may not be specified; only "' +
DEFAULTS.headers.accept + '" is supported.');
}
return Object.assign({Accept: DEFAULTS.headers.accept}, headers);
};
/**
* Parses a link header. The results will be key'd by the value of "rel".
*
* Link: <http://json-ld.org/contexts/person.jsonld>;
* rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
*
* Parses as: {
* 'http://www.w3.org/ns/json-ld#context': {
* target: http://json-ld.org/contexts/person.jsonld,
* type: 'application/ld+json'
* }
* }
*
* If there is more than one "rel" with the same IRI, then entries in the
* resulting map for that "rel" will be arrays.
*
* @param header the link header to parse.
*/
api.parseLinkHeader = header => {
const rval = {};
// split on unbracketed/unquoted commas
const entries = header.match(REGEX_LINK_HEADERS);
for(let i = 0; i < entries.length; ++i) {
let match = entries[i].match(REGEX_LINK_HEADER);
if(!match) {
continue;
}
const result = {target: match[1]};
const params = match[2];
while((match = REGEX_LINK_HEADER_PARAMS.exec(params))) {
result[match[1]] = (match[2] === undefined) ? match[3] : match[2];
}
const rel = result.rel || '';
if(Array.isArray(rval[rel])) {
rval[rel].push(result);
} else if(rval.hasOwnProperty(rel)) {
rval[rel] = [rval[rel], result];
} else {
rval[rel] = result;
}
}
return rval;
};
/**
* Throws an exception if the given value is not a valid @type value.
*
* @param v the value to check.
*/
api.validateTypeValue = (v, isFrame) => {
if(types.isString(v)) {
return;
}
if(types.isArray(v) && v.every(vv => types.isString(vv))) {
return;
}
if(isFrame && types.isObject(v)) {
switch(Object.keys(v).length) {
case 0:
// empty object is wildcard
return;
case 1:
// default entry is all strings
if('@default' in v &&
api.asArray(v['@default']).every(vv => types.isString(vv))) {
return;
}
}
}
throw new JsonLdError(
'Invalid JSON-LD syntax; "@type" value must a string, an array of ' +
'strings, an empty object, ' +
'or a default object.', 'jsonld.SyntaxError',
{code: 'invalid type value', value: v});
};
/**
* Returns true if the given subject has the given property.
*
* @param subject the subject to check.
* @param property the property to look for.
*
* @return true if the subject has the given property, false if not.
*/
api.hasProperty = (subject, property) => {
if(subject.hasOwnProperty(property)) {
const value = subject[property];
return (!types.isArray(value) || value.length > 0);
}
return false;
};
/**
* Determines if the given value is a property of the given subject.
*
* @param subject the subject to check.
* @param property the property to check.
* @param value the value to check.
*
* @return true if the value exists, false if not.
*/
api.hasValue = (subject, property, value) => {
if(api.hasProperty(subject, property)) {
let val = subject[property];
const isList = graphTypes.isList(val);
if(types.isArray(val) || isList) {
if(isList) {
val = val['@list'];
}
for(let i = 0; i < val.length; ++i) {
if(api.compareValues(value, val[i])) {
return true;
}
}
} else if(!types.isArray(value)) {
// avoid matching the set of values with an array value parameter
return api.compareValues(value, val);
}
}
return false;
};
/**
* Adds a value to a subject. If the value is an array, all values in the
* array will be added.
*
* @param subject the subject to add the value to.
* @param property the property that relates the value to the subject.
* @param value the value to add.
* @param [options] the options to use:
* [propertyIsArray] true if the property is always an array, false
* if not (default: false).
* [valueIsArray] true if the value to be added should be preserved as
* an array (lists) (default: false).
* [allowDuplicate] true to allow duplicates, false not to (uses a
* simple shallow comparison of subject ID or value) (default: true).
* [prependValue] false to prepend value to any existing values.
* (default: false)
*/
api.addValue = (subject, property, value, options) => {
options = options || {};
if(!('propertyIsArray' in options)) {
options.propertyIsArray = false;
}
if(!('valueIsArray' in options)) {
options.valueIsArray = false;
}
if(!('allowDuplicate' in options)) {
options.allowDuplicate = true;
}
if(!('prependValue' in options)) {
options.prependValue = false;
}
if(options.valueIsArray) {
subject[property] = value;
} else if(types.isArray(value)) {
if(value.length === 0 && options.propertyIsArray &&
!subject.hasOwnProperty(property)) {
subject[property] = [];
}
if(options.prependValue) {
value = value.concat(subject[property]);
subject[property] = [];
}
for(let i = 0; i < value.length; ++i) {
api.addValue(subject, property, value[i], options);
}
} else if(subject.hasOwnProperty(property)) {
// check if subject already has value if duplicates not allowed
const hasValue = (!options.allowDuplicate &&
api.hasValue(subject, property, value));
// make property an array if value not present or always an array
if(!types.isArray(subject[property]) &&
(!hasValue || options.propertyIsArray)) {
subject[property] = [subject[property]];
}
// add new value
if(!hasValue) {
if(options.prependValue) {
subject[property].unshift(value);
} else {
subject[property].push(value);
}
}
} else {
// add new value as set or single value
subject[property] = options.propertyIsArray ? [value] : value;
}
};
/**
* Gets all of the values for a subject's property as an array.
*
* @param subject the subject.
* @param property the property.
*
* @return all of the values for a subject's property as an array.
*/
api.getValues = (subject, property) => [].concat(subject[property] || []);
/**
* Removes a property from a subject.
*
* @param subject the subject.
* @param property the property.
*/
api.removeProperty = (subject, property) => {
delete subject[property];
};
/**
* Removes a value from a subject.
*
* @param subject the subject.
* @param property the property that relates the value to the subject.
* @param value the value to remove.
* @param [options] the options to use:
* [propertyIsArray] true if the property is always an array, false
* if not (default: false).
*/
api.removeValue = (subject, property, value, options) => {
options = options || {};
if(!('propertyIsArray' in options)) {
options.propertyIsArray = false;
}
// filter out value
const values = api.getValues(subject, property).filter(
e => !api.compareValues(e, value));
if(values.length === 0) {
api.removeProperty(subject, property);
} else if(values.length === 1 && !options.propertyIsArray) {
subject[property] = values[0];
} else {
subject[property] = values;
}
};
/**
* Relabels all blank nodes in the given JSON-LD input.
*
* @param input the JSON-LD input.
* @param [options] the options to use:
* [issuer] an IdentifierIssuer to use to label blank nodes.
*/
api.relabelBlankNodes = (input, options) => {
options = options || {};
const issuer = options.issuer || new IdentifierIssuer('_:b');
return _labelBlankNodes(issuer, input);
};
/**
* Compares two JSON-LD values for equality. Two JSON-LD values will be
* considered equal if:
*
* 1. They are both primitives of the same type and value.
* 2. They are both @values with the same @value, @type, @language,
* and @index, OR
* 3. They both have @ids they are the same.
*
* @param v1 the first value.
* @param v2 the second value.
*
* @return true if v1 and v2 are considered equal, false if not.
*/
api.compareValues = (v1, v2) => {
// 1. equal primitives
if(v1 === v2) {
return true;
}
// 2. equal @values
if(graphTypes.isValue(v1) && graphTypes.isValue(v2) &&
v1['@value'] === v2['@value'] &&
v1['@type'] === v2['@type'] &&
v1['@language'] === v2['@language'] &&
v1['@index'] === v2['@index']) {
return true;
}
// 3. equal @ids
if(types.isObject(v1) &&
('@id' in v1) &&
types.isObject(v2) &&
('@id' in v2)) {
return v1['@id'] === v2['@id'];
}
return false;
};
/**
* Compares two strings first based on length and then lexicographically.
*
* @param a the first string.
* @param b the second string.
*
* @return -1 if a < b, 1 if a > b, 0 if a === b.
*/
api.compareShortestLeast = (a, b) => {
if(a.length < b.length) {
return -1;
}
if(b.length < a.length) {
return 1;
}
if(a === b) {
return 0;
}
return (a < b) ? -1 : 1;
};
/**
* Labels the blank nodes in the given value using the given IdentifierIssuer.
*
* @param issuer the IdentifierIssuer to use.
* @param element the element with blank nodes to rename.
*
* @return the element.
*/
function _labelBlankNodes(issuer, element) {
if(types.isArray(element)) {
for(let i = 0; i < element.length; ++i) {
element[i] = _labelBlankNodes(issuer, element[i]);
}
} else if(graphTypes.isList(element)) {
element['@list'] = _labelBlankNodes(issuer, element['@list']);
} else if(types.isObject(element)) {
// relabel blank node
if(graphTypes.isBlankNode(element)) {
element['@id'] = issuer.getId(element['@id']);
}
// recursively apply to all keys
const keys = Object.keys(element).sort();
for(let ki = 0; ki < keys.length; ++ki) {
const key = keys[ki];
if(key !== '@id') {
element[key] = _labelBlankNodes(issuer, element[key]);
}
}
}
return element;
}