/* * Copyright (c) 2017-2019 Digital Bazaar, Inc. All rights reserved. */ 'use strict'; 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: ; * 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; }