diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf4494b..08f310ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,25 @@ # jsonld ChangeLog +### Changed +- Change EARL Assertor to Digital Bazaar, Inc. +- Update eslint dependencies. + ### Added - Support benchmarks in Karma tests. - Support test environment in EARL output. - Support benchmark output in EARL output. - Benchmark comparison tool. +- Add "safe mode" to all APIs. Enable by adding `{safe: true}` to API options. + This mode causes processing to fail when data constructs are encountered that + result in lossy behavior or other data warnings. This is intended to be the + common way that digital signing and similar applications use this library. -### Changed -- Change EARL Assertor to Digital Bazaar, Inc. -- Update eslint dependencies. +### Removed +- Experimental non-standard `protectedMode` option. +- **BREAKING**: Various console warnings were removed. The newly added "safe + mode" can stop processing where these warnings occurred. +- **BREAKING**: Remove `compactionMap` and `expansionMap`. Their known use + cases are addressed with "safe mode" and future planned features. ## 6.0.0 - 2022-06-06 diff --git a/README.md b/README.md index ca71d8e0..045325a4 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,24 @@ It is recommended to set a default `user-agent` header for Node.js applications. The default for the default Node.js document loader is `jsonld.js`. +### Safe Mode + +A common use case is to avoid JSON-LD constructs that will result in lossy +behavior. The JSON-LD specifications have notes about when data is dropped. +This can be especially important when calling [`canonize`][] in order to +digitally sign data. A special "safe mode" is available that will detect these +situations and cause processing to fail. + +**Note**: This mode is designed to be the common way that digital signing and +similar applications use this library. + +The `safe` options flag set to `true` enables this behavior: + +```js +// expand a document in safe mode +const expanded = await jsonld.expand(data, {safe: true}); +``` + Related Modules --------------- diff --git a/lib/compact.js b/lib/compact.js index 1bcdb4bb..ccccc47f 100644 --- a/lib/compact.js +++ b/lib/compact.js @@ -51,7 +51,6 @@ module.exports = api; * to compact, null for none. * @param element the element to compact. * @param options the compaction options. - * @param compactionMap the compaction map to use. * * @return a promise that resolves to the compacted value. */ @@ -59,33 +58,21 @@ api.compact = async ({ activeCtx, activeProperty = null, element, - options = {}, - compactionMap = () => undefined + options = {} }) => { // recursively compact array if(_isArray(element)) { let rval = []; for(let i = 0; i < element.length; ++i) { - // compact, dropping any null values unless custom mapped - let compacted = await api.compact({ + const compacted = await api.compact({ activeCtx, activeProperty, element: element[i], - options, - compactionMap + options }); if(compacted === null) { - compacted = await compactionMap({ - unmappedValue: element[i], - activeCtx, - activeProperty, - parent: element, - index: i, - options - }); - if(compacted === undefined) { - continue; - } + // FIXME: need event? + continue; } rval.push(compacted); } @@ -149,8 +136,7 @@ api.compact = async ({ activeCtx, activeProperty, element: element['@list'], - options, - compactionMap + options }); } } @@ -278,8 +264,7 @@ api.compact = async ({ activeCtx, activeProperty: '@reverse', element: expandedValue, - options, - compactionMap + options }); // handle double-reversed properties @@ -316,8 +301,7 @@ api.compact = async ({ activeCtx, activeProperty, element: expandedValue, - options, - compactionMap + options }); if(!(_isArray(compactedValue) && compactedValue.length === 0)) { @@ -434,8 +418,7 @@ api.compact = async ({ activeCtx, activeProperty: itemActiveProperty, element: (isList || isGraph) ? inner : expandedItem, - options, - compactionMap + options }); // handle @list @@ -630,8 +613,7 @@ api.compact = async ({ activeCtx, activeProperty: itemActiveProperty, element: {'@id': expandedItem['@id']}, - options, - compactionMap + options }); } } diff --git a/lib/context.js b/lib/context.js index 5f0de789..175a6775 100644 --- a/lib/context.js +++ b/lib/context.js @@ -20,13 +20,18 @@ const { } = require('./url'); const { + handleEvent: _handleEvent +} = require('./events'); + +const { + REGEX_BCP47, + REGEX_KEYWORD, asArray: _asArray, compareShortestLeast: _compareShortestLeast } = require('./util'); const INITIAL_CONTEXT_CACHE = new Map(); const INITIAL_CONTEXT_CACHE_MAX_SIZE = 10000; -const KEYWORD_PATTERN = /^@[a-zA-Z]+$/; const api = {}; module.exports = api; @@ -61,6 +66,23 @@ api.process = async ({ return activeCtx; } + // event handler for capturing events to replay when using a cached context + const events = []; + const eventCaptureHandler = [ + ({event, next}) => { + events.push(event); + next(); + } + ]; + // chain to original handler + if(options.eventHandler) { + eventCaptureHandler.push(options.eventHandler); + } + // store original options to use when replaying events + const originalOptions = options; + // shallow clone options with event capture handler + options = {...options, eventHandler: eventCaptureHandler}; + // resolve contexts const resolved = await options.contextResolver.resolve({ activeCtx, @@ -98,46 +120,12 @@ api.process = async ({ if(ctx === null) { // We can't nullify if there are protected terms and we're // not allowing overrides (e.g. processing a property term scoped context) - if(!overrideProtected && - Object.keys(activeCtx.protected).length !== 0) { - const protectedMode = (options && options.protectedMode) || 'error'; - if(protectedMode === 'error') { - throw new JsonLdError( - 'Tried to nullify a context with protected terms outside of ' + - 'a term definition.', - 'jsonld.SyntaxError', - {code: 'invalid context nullification'}); - } else if(protectedMode === 'warn') { - // FIXME: remove logging and use a handler - console.warn('WARNING: invalid context nullification'); - - // get processed context from cache if available - const processed = resolvedContext.getProcessed(activeCtx); - if(processed) { - rval = activeCtx = processed; - continue; - } - - const oldActiveCtx = activeCtx; - // copy all protected term definitions to fresh initial context - rval = activeCtx = api.getInitialContext(options).clone(); - for(const [term, _protected] of - Object.entries(oldActiveCtx.protected)) { - if(_protected) { - activeCtx.mappings[term] = - util.clone(oldActiveCtx.mappings[term]); - } - } - activeCtx.protected = util.clone(oldActiveCtx.protected); - - // cache processed result - resolvedContext.setProcessed(oldActiveCtx, rval); - continue; - } + if(!overrideProtected && Object.keys(activeCtx.protected).length !== 0) { throw new JsonLdError( - 'Invalid protectedMode.', + 'Tried to nullify a context with protected terms outside of ' + + 'a term definition.', 'jsonld.SyntaxError', - {code: 'invalid protected mode', context: localCtx, protectedMode}); + {code: 'invalid context nullification'}); } rval = activeCtx = api.getInitialContext(options).clone(); continue; @@ -146,7 +134,14 @@ api.process = async ({ // get processed context from cache if available const processed = resolvedContext.getProcessed(activeCtx); if(processed) { - rval = activeCtx = processed; + if(originalOptions.eventHandler) { + // replay events with original non-capturing options + for(const event of processed.events) { + _handleEvent({event, options: originalOptions}); + } + } + + rval = activeCtx = processed.context; continue; } @@ -231,8 +226,25 @@ api.process = async ({ '@context must be an absolute IRI.', 'jsonld.SyntaxError', {code: 'invalid vocab mapping', context: ctx}); } else { - rval['@vocab'] = _expandIri(rval, value, {vocab: true, base: true}, + const vocab = _expandIri(rval, value, {vocab: true, base: true}, undefined, undefined, options); + if(!_isAbsoluteIri(vocab)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative @vocab reference', + level: 'warning', + message: 'Relative @vocab reference found.', + details: { + vocab + } + }, + options + }); + } + } + rval['@vocab'] = vocab; } defined.set('@vocab', true); } @@ -249,6 +261,22 @@ api.process = async ({ 'jsonld.SyntaxError', {code: 'invalid default language', context: ctx}); } else { + if(!value.match(REGEX_BCP47)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'invalid @language value', + level: 'warning', + message: '@language value must be valid BCP47.', + details: { + language: value + } + }, + options + }); + } + } rval['@language'] = value.toLowerCase(); } defined.set('@language', true); @@ -414,7 +442,10 @@ api.process = async ({ } // cache processed result - resolvedContext.setProcessed(activeCtx, rval); + resolvedContext.setProcessed(activeCtx, { + context: rval, + events + }); } return rval; @@ -429,9 +460,6 @@ api.process = async ({ * @param defined a map of defining/defined keys to detect cycles and prevent * double definitions. * @param {Object} [options] - creation options. - * @param {string} [options.protectedMode="error"] - "error" to throw error - * on `@protected` constraint violation, "warn" to allow violations and - * signal a warning. * @param overrideProtected `false` allows protected terms to be modified. */ api.createTermDefinition = ({ @@ -481,10 +509,23 @@ api.createTermDefinition = ({ 'Invalid JSON-LD syntax; keywords cannot be overridden.', 'jsonld.SyntaxError', {code: 'keyword redefinition', context: localCtx, term}); - } else if(term.match(KEYWORD_PATTERN)) { - // FIXME: remove logging and use a handler - console.warn('WARNING: terms beginning with "@" are reserved' + - ' for future use and ignored', {term}); + } else if(term.match(REGEX_KEYWORD)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'reserved term', + level: 'warning', + message: + 'Terms beginning with "@" are ' + + 'reserved for future use and dropped.', + details: { + term + } + }, + options + }); + } return; } else if(term === '') { throw new JsonLdError( @@ -564,10 +605,23 @@ api.createTermDefinition = ({ 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx}); } - if(!api.isKeyword(reverse) && reverse.match(KEYWORD_PATTERN)) { - // FIXME: remove logging and use a handler - console.warn('WARNING: values beginning with "@" are reserved' + - ' for future use and ignored', {reverse}); + if(reverse.match(REGEX_KEYWORD)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'reserved @reverse value', + level: 'warning', + message: + '@reverse values beginning with "@" are ' + + 'reserved for future use and dropped.', + details: { + reverse + } + }, + options + }); + } if(previousMapping) { activeCtx.mappings.set(term, previousMapping); } else { @@ -600,10 +654,23 @@ api.createTermDefinition = ({ if(id === null) { // reserve a null term, which may be protected mapping['@id'] = null; - } else if(!api.isKeyword(id) && id.match(KEYWORD_PATTERN)) { - // FIXME: remove logging and use a handler - console.warn('WARNING: values beginning with "@" are reserved' + - ' for future use and ignored', {id}); + } else if(!api.isKeyword(id) && id.match(REGEX_KEYWORD)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'reserved @id value', + level: 'warning', + message: + '@id values beginning with "@" are ' + + 'reserved for future use and dropped.', + details: { + id + } + }, + options + }); + } if(previousMapping) { activeCtx.mappings.set(term, previousMapping); } else { @@ -918,23 +985,10 @@ api.createTermDefinition = ({ activeCtx.protected[term] = true; mapping.protected = true; if(!_deepCompare(previousMapping, mapping)) { - const protectedMode = (options && options.protectedMode) || 'error'; - if(protectedMode === 'error') { - throw new JsonLdError( - `Invalid JSON-LD syntax; tried to redefine "${term}" which is a ` + - 'protected term.', - 'jsonld.SyntaxError', - {code: 'protected term redefinition', context: localCtx, term}); - } else if(protectedMode === 'warn') { - // FIXME: remove logging and use a handler - console.warn('WARNING: protected term redefinition', {term}); - return; - } throw new JsonLdError( - 'Invalid protectedMode.', + 'Invalid JSON-LD syntax; tried to redefine a protected term.', 'jsonld.SyntaxError', - {code: 'invalid protected mode', context: localCtx, term, - protectedMode}); + {code: 'protected term redefinition', context: localCtx, term}); } } }; @@ -983,7 +1037,7 @@ function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) { } // ignore non-keyword things that look like a keyword - if(value.match(KEYWORD_PATTERN)) { + if(value.match(REGEX_KEYWORD)) { return null; } @@ -1043,45 +1097,41 @@ function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) { // A flag that captures whether the iri being expanded is // the value for an @type - let typeExpansion = false; + //let typeExpansion = false; - if(options !== undefined && options.typeExpansion !== undefined) { - typeExpansion = options.typeExpansion; - } + //if(options !== undefined && options.typeExpansion !== undefined) { + // typeExpansion = options.typeExpansion; + //} if(relativeTo.vocab && '@vocab' in activeCtx) { // prepend vocab const prependedResult = activeCtx['@vocab'] + value; - let expansionMapResult = undefined; - if(options && options.expansionMap) { - // if we are about to expand the value by prepending - // @vocab then call the expansion map to inform - // interested callers that this is occurring - - // TODO: use `await` to support async - expansionMapResult = options.expansionMap({ - prependedIri: { - type: '@vocab', - vocab: activeCtx['@vocab'], - value, - result: prependedResult, - typeExpansion, + // FIXME: needed? may be better as debug event. + /* + if(options && options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'prepending @vocab during expansion', + level: 'info', + message: 'Prepending @vocab during expansion.', + details: { + type: '@vocab', + vocab: activeCtx['@vocab'], + value, + result: prependedResult, + typeExpansion + } }, - activeCtx, options }); - - } - if(expansionMapResult !== undefined) { - value = expansionMapResult; - } else { - // the null case preserves value as potentially relative - value = prependedResult; } + */ + // the null case preserves value as potentially relative + value = prependedResult; } else if(relativeTo.base) { // prepend base let prependedResult; - let expansionMapResult; let base; if('@base' in activeCtx) { if(activeCtx['@base']) { @@ -1095,49 +1145,53 @@ function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) { base = options.base; prependedResult = prependBase(options.base, value); } - if(options && options.expansionMap) { - // if we are about to expand the value by pre-pending - // @base then call the expansion map to inform - // interested callers that this is occurring - - // TODO: use `await` to support async - expansionMapResult = options.expansionMap({ - prependedIri: { - type: '@base', - base, - value, - result: prependedResult, - typeExpansion, + // FIXME: needed? may be better as debug event. + /* + if(options && options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'prepending @base during expansion', + level: 'info', + message: 'Prepending @base during expansion.', + details: { + type: '@base', + base, + value, + result: prependedResult, + typeExpansion + } }, - activeCtx, options }); } - if(expansionMapResult !== undefined) { - value = expansionMapResult; - } else { - // the null case preserves value as potentially relative - value = prependedResult; - } + */ + // the null case preserves value as potentially relative + value = prependedResult; } - if(!_isAbsoluteIri(value) && options && options.expansionMap) { - // if the result of the expansion is not an absolute iri then - // call the expansion map to inform interested callers that - // the resulting value is a relative iri, which can result in - // it being dropped when converting to other RDF representations - - // TODO: use `await` to support async - const expandedResult = options.expansionMap({ - relativeIri: value, - activeCtx, - typeExpansion, + // FIXME: duplicate? needed? maybe just enable in a verbose debug mode + /* + if(!_isAbsoluteIri(value) && options && options.eventHandler) { + // emit event indicating a relative IRI was found, which can result in it + // being dropped when converting to other RDF representations + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative IRI after expansion', + // FIXME: what level? + level: 'warning', + message: 'Relative IRI after expansion.', + details: { + relativeIri: value, + typeExpansion + } + }, options }); - if(expandedResult !== undefined) { - value = expandedResult; - } + // NOTE: relative reference events emitted at calling sites as needed } + */ return value; } diff --git a/lib/events.js b/lib/events.js new file mode 100644 index 00000000..939885a1 --- /dev/null +++ b/lib/events.js @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2020 Digital Bazaar, Inc. All rights reserved. + */ +'use strict'; + +const JsonLdError = require('./JsonLdError'); + +const { + isArray: _isArray +} = require('./types'); + +const { + asArray: _asArray +} = require('./util'); + +const api = {}; +module.exports = api; + +// default handler, store as null or an array +// exposed to allow fast external pre-handleEvent() checks +api.defaultEventHandler = null; + +/** + * Setup event handler. + * + * Return an array event handler constructed from an optional safe mode + * handler, an optional options event handler, and an optional default handler. + * + * @param {object} options - processing options + * {function|object|array} [eventHandler] - an event handler. + * + * @return an array event handler. + */ +api.setupEventHandler = ({options = {}}) => { + // build in priority order + const eventHandler = [].concat( + options.safe ? api.safeEventHandler : [], + options.eventHandler ? _asArray(options.eventHandler) : [], + api.defaultEventHandler ? api.defaultEventHandler : [] + ); + // null if no handlers + return eventHandler.length === 0 ? null : eventHandler; +}; + +/** + * Handle an event. + * + * Top level APIs have a common 'eventHandler' option. This option can be a + * function, array of functions, object mapping event.code to functions (with a + * default to call next()), or any combination of such handlers. Handlers will + * be called with an object with an 'event' entry and a 'next' function. Custom + * handlers should process the event as appropriate. The 'next()' function + * should be called to let the next handler process the event. + * + * NOTE: Only call this function if options.eventHandler is set and is an + * array of hanlers. This is an optimization. Callers are expected to check + * for an event handler before constructing events and calling this function. + * + * @param {object} event - event structure: + * {string} code - event code + * {string} level - severity level, one of: ['warning'] + * {string} message - human readable message + * {object} details - event specific details + * @param {object} options - processing options + * {array} eventHandler - an event handler array. + */ +api.handleEvent = ({ + event, + options +}) => { + _handle({event, handlers: options.eventHandler}); +}; + +function _handle({event, handlers}) { + let doNext = true; + for(let i = 0; doNext && i < handlers.length; ++i) { + doNext = false; + const handler = handlers[i]; + if(_isArray(handler)) { + doNext = _handle({event, handlers: handler}); + } else if(typeof handler === 'function') { + handler({event, next: () => { + doNext = true; + }}); + } else if(typeof handler === 'object') { + if(event.code in handler) { + handler[event.code]({event, next: () => { + doNext = true; + }}); + } else { + doNext = true; + } + } else { + throw new JsonLdError( + 'Invalid event handler.', + 'jsonld.InvalidEventHandler', + {event}); + } + } + return doNext; +} + +const _notSafeEventCodes = new Set([ + 'empty object', + 'free-floating scalar', + 'invalid @language value', + 'invalid property', + // NOTE: spec edge case + 'null @id value', + 'null @value value', + 'object with only @id', + 'object with only @language', + 'object with only @list', + 'object with only @value', + 'relative @id reference', + 'relative @type reference', + 'relative @vocab reference', + 'reserved @id value', + 'reserved @reverse value', + 'reserved term', + // toRDF + 'blank node predicate', + 'relative graph reference', + 'relative property reference', + 'relative subject reference', + 'relative type reference' +]); + +// safe handler that rejects unsafe warning conditions +api.safeEventHandler = function safeEventHandler({event, next}) { + // fail on all unsafe warnings + if(event.level === 'warning' && _notSafeEventCodes.has(event.code)) { + throw new JsonLdError( + 'Safe mode validation error.', + 'jsonld.ValidationError', + {event} + ); + } + next(); +}; + +// logs all events and continues +api.logEventHandler = function logEventHandler({event, next}) { + console.log(`EVENT: ${event.message}`, {event}); + next(); +}; + +// log 'warning' level events +api.logWarningEventHandler = function logWarningEventHandler({event, next}) { + if(event.level === 'warning') { + console.warn(`WARNING: ${event.message}`, {event}); + } + next(); +}; + +// fallback to throw errors for any unhandled events +api.unhandledEventHandler = function unhandledEventHandler({event}) { + throw new JsonLdError( + 'No handler for event.', + 'jsonld.UnhandledEvent', + {event} + ); +}; + +/** + * Set default event handler. + * + * By default, all event are unhandled. It is recommended to pass in an + * eventHandler into each call. However, this call allows using a default + * eventHandler when one is not otherwise provided. + * + * @param {object} options - default handler options: + * {function|object|array} eventHandler - a default event handler. + * falsey to unset. + */ +api.setDefaultEventHandler = function({eventHandler} = {}) { + api.defaultEventHandler = eventHandler ? _asArray(eventHandler) : null; +}; diff --git a/lib/expand.js b/lib/expand.js index 737def7b..d2d94a20 100644 --- a/lib/expand.js +++ b/lib/expand.js @@ -33,15 +33,20 @@ const { } = require('./url'); const { + REGEX_BCP47, + REGEX_KEYWORD, addValue: _addValue, asArray: _asArray, getValues: _getValues, validateTypeValue: _validateTypeValue } = require('./util'); +const { + handleEvent: _handleEvent +} = require('./events'); + const api = {}; module.exports = api; -const REGEX_BCP47 = /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/; /** * Recursively expands an element using the given context. Any context in @@ -58,10 +63,6 @@ const REGEX_BCP47 = /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/; * @param typeScopedContext an optional type-scoped active context for * expanding values of nodes that were expressed according to * a type-scoped context. - * @param expansionMap(info) a function that can be used to custom map - * unmappable values (or to throw an error when they are detected); - * if this function returns `undefined` then the default behavior - * will be used. * * @return a Promise that resolves to the expanded value. */ @@ -72,13 +73,8 @@ api.expand = async ({ options = {}, insideList = false, insideIndex = false, - typeScopedContext = null, - expansionMap = () => undefined + typeScopedContext = null }) => { - - // add expansion map to the processing options - options = {...options, expansionMap}; - // nothing to expand if(element === null || element === undefined) { return null; @@ -90,21 +86,28 @@ api.expand = async ({ } if(!_isArray(element) && !_isObject(element)) { - // drop free-floating scalars that are not in lists unless custom mapped + // drop free-floating scalars that are not in lists if(!insideList && (activeProperty === null || _expandIri(activeCtx, activeProperty, {vocab: true}, options) === '@graph')) { - const mapped = await expansionMap({ - unmappedValue: element, - activeCtx, - activeProperty, - options, - insideList - }); - if(mapped === undefined) { - return null; + // FIXME + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'free-floating scalar', + level: 'warning', + message: 'Dropping free-floating scalar not in a list.', + details: { + value: element + //activeProperty + //insideList + } + }, + options + }); } - return mapped; + return null; } // expand element according to value expansion rules @@ -124,7 +127,6 @@ api.expand = async ({ activeProperty, element: element[i], options, - expansionMap, insideIndex, typeScopedContext }); @@ -133,19 +135,16 @@ api.expand = async ({ } if(e === null) { - e = await expansionMap({ - unmappedValue: element[i], - activeCtx, - activeProperty, - parent: element, - index: i, - options, - expandedParent: rval, - insideList - }); - if(e === undefined) { - continue; - } + // FIXME: add debug event? + //unmappedValue: element[i], + //activeProperty, + //parent: element, + //index: i, + //expandedParent: rval, + //insideList + + // NOTE: no-value events emitted at calling sites as needed + continue; } if(_isArray(e)) { @@ -258,8 +257,8 @@ api.expand = async ({ options, insideList, typeKey, - typeScopedContext, - expansionMap}); + typeScopedContext + }); // get property count on expanded output keys = Object.keys(rval); @@ -296,24 +295,27 @@ api.expand = async ({ const values = rval['@value'] === null ? [] : _asArray(rval['@value']); const types = _getValues(rval, '@type'); - // drop null @values unless custom mapped + // drop null @values if(_processingMode(activeCtx, 1.1) && types.includes('@json') && types.length === 1) { // Any value of @value is okay if @type: @json } else if(values.length === 0) { - const mapped = await expansionMap({ - unmappedValue: rval, - activeCtx, - activeProperty, - element, - options, - insideList - }); - if(mapped !== undefined) { - rval = mapped; - } else { - rval = null; + // FIXME + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'null @value value', + level: 'warning', + message: 'Dropping null @value value.', + details: { + value: rval + } + }, + options + }); } + rval = null; } else if(!values.every(v => (_isString(v) || _isEmptyObject(v))) && '@language' in rval) { // if @language is present, @value must be a string @@ -348,43 +350,64 @@ api.expand = async ({ count = keys.length; } } else if(count === 1 && '@language' in rval) { - // drop objects with only @language unless custom mapped - const mapped = await expansionMap(rval, { - unmappedValue: rval, - activeCtx, - activeProperty, - element, - options, - insideList - }); - if(mapped !== undefined) { - rval = mapped; - } else { - rval = null; + // drop objects with only @language + // FIXME + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'object with only @language', + level: 'warning', + message: 'Dropping object with only @language.', + details: { + value: rval + } + }, + options + }); } + rval = null; } - // drop certain top-level objects that do not occur in lists, unless custom - // mapped + // drop certain top-level objects that do not occur in lists if(_isObject(rval) && !options.keepFreeFloatingNodes && !insideList && (activeProperty === null || expandedActiveProperty === '@graph')) { // drop empty object, top-level @value/@list, or object with only @id if(count === 0 || '@value' in rval || '@list' in rval || (count === 1 && '@id' in rval)) { - const mapped = await expansionMap({ - unmappedValue: rval, - activeCtx, - activeProperty, - element, - options, - insideList - }); - if(mapped !== undefined) { - rval = mapped; - } else { - rval = null; + // FIXME + if(options.eventHandler) { + // FIXME: one event or diff event for empty, @v/@l, {@id}? + let code; + let message; + if(count === 0) { + code = 'empty object'; + message = 'Dropping empty object.'; + } else if('@value' in rval) { + code = 'object with only @value'; + message = 'Dropping object with only @value.'; + } else if('@list' in rval) { + code = 'object with only @list'; + message = 'Dropping object with only @list.'; + } else if(count === 1 && '@id' in rval) { + code = 'object with only @id'; + message = 'Dropping object with only @id.'; + } + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code, + level: 'warning', + message, + details: { + value: rval + } + }, + options + }); } + rval = null; } } @@ -403,10 +426,6 @@ api.expand = async ({ * @param insideList true if the element is a list, false if not. * @param typeKey first key found expanding to @type. * @param typeScopedContext the context before reverting. - * @param expansionMap(info) a function that can be used to custom map - * unmappable values (or to throw an error when they are detected); - * if this function returns `undefined` then the default behavior - * will be used. */ async function _expandObject({ activeCtx, @@ -417,21 +436,20 @@ async function _expandObject({ options = {}, insideList, typeKey, - typeScopedContext, - expansionMap + typeScopedContext }) { const keys = Object.keys(element).sort(); const nests = []; let unexpandedValue; - // add expansion map to the processing options - options = {...options, expansionMap}; - // Figure out if this is the type for a JSON literal const isJsonType = element[typeKey] && _expandIri(activeCtx, (_isArray(element[typeKey]) ? element[typeKey][0] : element[typeKey]), - {vocab: true}, {...options, typeExpansion: true}) === '@json'; + {vocab: true}, { + ...options, + typeExpansion: true + }) === '@json'; for(const key of keys) { let value = element[key]; @@ -443,25 +461,28 @@ async function _expandObject({ } // expand property - let expandedProperty = _expandIri(activeCtx, key, {vocab: true}, options); + const expandedProperty = _expandIri(activeCtx, key, {vocab: true}, options); - // drop non-absolute IRI keys that aren't keywords unless custom mapped + // drop non-absolute IRI keys that aren't keywords if(expandedProperty === null || !(_isAbsoluteIri(expandedProperty) || _isKeyword(expandedProperty))) { - // TODO: use `await` to support async - expandedProperty = expansionMap({ - unmappedProperty: key, - activeCtx, - activeProperty, - parent: element, - options, - insideList, - value, - expandedParent - }); - if(expandedProperty === undefined) { - continue; + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'invalid property', + level: 'warning', + message: 'Dropping property that did not expand into an ' + + 'absolute IRI or keyword.', + details: { + property: key, + expandedProperty + } + }, + options + }); } + continue; } if(_isKeyword(expandedProperty)) { @@ -514,8 +535,61 @@ async function _expandObject({ _addValue( expandedParent, '@id', - _asArray(value).map(v => - _isString(v) ? _expandIri(activeCtx, v, {base: true}, options) : v), + _asArray(value).map(v => { + if(_isString(v)) { + const ve = _expandIri(activeCtx, v, {base: true}, options); + if(options.eventHandler) { + if(ve === null) { + // NOTE: spec edge case + // See https://github.com/w3c/json-ld-api/issues/480 + if(v === null) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'null @id value', + level: 'warning', + message: 'Null @id found.', + details: { + id: v + } + }, + options + }); + } else { + // matched KEYWORD regex + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'reserved @id value', + level: 'warning', + message: 'Reserved @id found.', + details: { + id: v + } + }, + options + }); + } + } else if(!_isAbsoluteIri(ve)) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative @id reference', + level: 'warning', + message: 'Relative @id reference found.', + details: { + id: v, + expandedId: ve + } + }, + options + }); + } + } + return ve; + } + return v; + }), {propertyIsArray: options.isFrame}); continue; } @@ -535,11 +609,31 @@ async function _expandObject({ _validateTypeValue(value, options.isFrame); _addValue( expandedParent, '@type', - _asArray(value).map(v => - _isString(v) ? - _expandIri(typeScopedContext, v, + _asArray(value).map(v => { + if(_isString(v)) { + const ve = _expandIri(typeScopedContext, v, {base: true, vocab: true}, - {...options, typeExpansion: true}) : v), + {...options, typeExpansion: true}); + if(!_isAbsoluteIri(ve)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative @type reference', + level: 'warning', + message: 'Relative @type reference found.', + details: { + type: v + } + }, + options + }); + } + } + return ve; + } + return v; + }), {propertyIsArray: options.isFrame}); continue; } @@ -552,8 +646,7 @@ async function _expandObject({ activeCtx, activeProperty, element: value, - options, - expansionMap + options })); // Expanded values must be node objects @@ -609,9 +702,22 @@ async function _expandObject({ value = _asArray(value).map(v => _isString(v) ? v.toLowerCase() : v); // ensure language tag matches BCP47 - for(const lang of value) { - if(_isString(lang) && !lang.match(REGEX_BCP47)) { - console.warn(`@language must be valid BCP47: ${lang}`); + for(const language of value) { + if(_isString(language) && !language.match(REGEX_BCP47)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'invalid @language value', + level: 'warning', + message: '@language value must be valid BCP47.', + details: { + language + } + }, + options + }); + } } } @@ -672,8 +778,7 @@ async function _expandObject({ activeProperty: '@reverse', element: value, - options, - expansionMap + options }); // properties double-reversed if('@reverse' in expandedValue) { @@ -748,7 +853,6 @@ async function _expandObject({ options, activeProperty: key, value, - expansionMap, asGraph, indexKey, propertyIndex @@ -761,7 +865,6 @@ async function _expandObject({ options, activeProperty: key, value, - expansionMap, asGraph, indexKey: '@id' }); @@ -773,7 +876,6 @@ async function _expandObject({ options, activeProperty: key, value, - expansionMap, asGraph: false, indexKey: '@type' }); @@ -790,8 +892,7 @@ async function _expandObject({ activeProperty: nextActiveProperty, element: value, options, - insideList: isList, - expansionMap + insideList: isList }); } else if( _getContextValue(activeCtx, key, '@type') === '@json') { @@ -806,29 +907,18 @@ async function _expandObject({ activeProperty: key, element: value, options, - insideList: false, - expansionMap + insideList: false }); } } // drop null values if property is not @value if(expandedValue === null && expandedProperty !== '@value') { - // TODO: use `await` to support async - expandedValue = expansionMap({ - unmappedValue: value, - expandedProperty, - activeCtx: termCtx, - activeProperty, - parent: element, - options, - insideList, - key, - expandedParent - }); - if(expandedValue === undefined) { - continue; - } + // FIXME: event? + //unmappedValue: value, + //expandedProperty, + //key, + continue; } // convert expanded value to @list if container specifies it @@ -910,8 +1000,8 @@ async function _expandObject({ options, insideList, typeScopedContext, - typeKey, - expansionMap}); + typeKey + }); } } } @@ -948,7 +1038,25 @@ function _expandValue({activeCtx, activeProperty, value, options}) { // do @id expansion (automatic for @graph) if((type === '@id' || expandedProperty === '@graph') && _isString(value)) { - return {'@id': _expandIri(activeCtx, value, {base: true}, options)}; + const expandedValue = _expandIri(activeCtx, value, {base: true}, options); + // NOTE: handle spec edge case and avoid invalid {"@id": null} + if(expandedValue === null && value.match(REGEX_KEYWORD)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'reserved @id value', + level: 'warning', + message: 'Reserved @id found.', + details: { + id: activeProperty + } + }, + options + }); + } + } + return {'@id': expandedValue}; } // do @id expansion w/vocab if(type === '@vocab' && _isString(value)) { @@ -1019,6 +1127,22 @@ function _expandLanguageMap(activeCtx, languageMap, direction, options) { } const val = {'@value': item}; if(expandedKey !== '@none') { + if(!key.match(REGEX_BCP47)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'invalid @language value', + level: 'warning', + message: '@language value must be valid BCP47.', + details: { + language: key + } + }, + options + }); + } + } val['@language'] = key.toLowerCase(); } if(direction) { @@ -1030,9 +1154,9 @@ function _expandLanguageMap(activeCtx, languageMap, direction, options) { return rval; } -async function _expandIndexMap( - {activeCtx, options, activeProperty, value, expansionMap, asGraph, - indexKey, propertyIndex}) { +async function _expandIndexMap({ + activeCtx, options, activeProperty, value, asGraph, indexKey, propertyIndex +}) { const rval = []; const keys = Object.keys(value).sort(); const isTypeIndex = indexKey === '@type'; @@ -1061,8 +1185,7 @@ async function _expandIndexMap( element: val, options, insideList: false, - insideIndex: true, - expansionMap + insideIndex: true }); // expand for @type, but also for @none diff --git a/lib/fromRdf.js b/lib/fromRdf.js index fb3567c8..afddfdb9 100644 --- a/lib/fromRdf.js +++ b/lib/fromRdf.js @@ -6,7 +6,15 @@ const JsonLdError = require('./JsonLdError'); const graphTypes = require('./graphTypes'); const types = require('./types'); -const util = require('./util'); + +const { + REGEX_BCP47, + addValue: _addValue +} = require('./util'); + +const { + handleEvent: _handleEvent +} = require('./events'); // constants const { @@ -29,8 +37,6 @@ const { XSD_STRING, } = require('./constants'); -const REGEX_BCP47 = /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/; - const api = {}; module.exports = api; @@ -44,15 +50,16 @@ module.exports = api; */ api.fromRDF = async ( dataset, - { - useRdfType = false, - useNativeTypes = false, - rdfDirection = null - } + options ) => { const defaultGraph = {}; const graphMap = {'@default': defaultGraph}; const referencedOnce = {}; + const { + useRdfType = false, + useNativeTypes = false, + rdfDirection = null + } = options; for(const quad of dataset) { // TODO: change 'name' to 'graph' @@ -83,12 +90,12 @@ api.fromRDF = async ( } if(p === RDF_TYPE && !useRdfType && objectIsNode) { - util.addValue(node, '@type', o.value, {propertyIsArray: true}); + _addValue(node, '@type', o.value, {propertyIsArray: true}); continue; } - const value = _RDFToObject(o, useNativeTypes, rdfDirection); - util.addValue(node, p, value, {propertyIsArray: true}); + const value = _RDFToObject(o, useNativeTypes, rdfDirection, options); + _addValue(node, p, value, {propertyIsArray: true}); // object may be an RDF list/partial list node but we can't know easily // until all triples are read @@ -147,12 +154,12 @@ api.fromRDF = async ( } if(p === RDF_TYPE && !useRdfType && objectIsId) { - util.addValue(node, '@type', o.value, {propertyIsArray: true}); + _addValue(node, '@type', o.value, {propertyIsArray: true}); continue; } const value = _RDFToObject(o, useNativeTypes); - util.addValue(node, p, value, {propertyIsArray: true}); + _addValue(node, p, value, {propertyIsArray: true}); // object may be an RDF list/partial list node but we can't know easily // until all triples are read @@ -275,10 +282,12 @@ api.fromRDF = async ( * * @param o the RDF triple object to convert. * @param useNativeTypes true to output native types, false not to. + * @param rdfDirection text direction mode [null, i18n-datatype] + * @param options top level API options * * @return the JSON-LD object. */ -function _RDFToObject(o, useNativeTypes, rdfDirection) { +function _RDFToObject(o, useNativeTypes, rdfDirection, options) { // convert NamedNode/BlankNode object to JSON-LD if(o.termType.endsWith('Node')) { return {'@id': o.value}; @@ -289,6 +298,22 @@ function _RDFToObject(o, useNativeTypes, rdfDirection) { // add language if(o.language) { + if(!o.language.match(REGEX_BCP47)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'invalid @language value', + level: 'warning', + message: '@language value must be valid BCP47.', + details: { + language: o.language + } + }, + options + }); + } + } rval['@language'] = o.language; } else { let type = o.datatype.value; @@ -334,7 +359,20 @@ function _RDFToObject(o, useNativeTypes, rdfDirection) { if(language.length > 0) { rval['@language'] = language; if(!language.match(REGEX_BCP47)) { - console.warn(`@language must be valid BCP47: ${language}`); + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'invalid @language value', + level: 'warning', + message: '@language value must be valid BCP47.', + details: { + language + } + }, + options + }); + } } } rval['@direction'] = direction; diff --git a/lib/graphTypes.js b/lib/graphTypes.js index ea8ef26b..3d06d6cf 100644 --- a/lib/graphTypes.js +++ b/lib/graphTypes.js @@ -106,11 +106,12 @@ api.isSimpleGraph = v => { api.isBlankNode = v => { // Note: A value is a blank node if all of these hold true: // 1. It is an Object. - // 2. If it has an @id key its value begins with '_:'. + // 2. If it has an @id key that is not a string OR begins with '_:'. // 3. It has no keys OR is not a @value, @set, or @list. if(types.isObject(v)) { if('@id' in v) { - return (v['@id'].indexOf('_:') === 0); + const id = v['@id']; + return !types.isString(id) || id.indexOf('_:') === 0; } return (Object.keys(v).length === 0 || !(('@value' in v) || ('@set' in v) || ('@list' in v))); diff --git a/lib/jsonld.js b/lib/jsonld.js index ffd974cb..8073de26 100644 --- a/lib/jsonld.js +++ b/lib/jsonld.js @@ -4,7 +4,7 @@ * @author Dave Longley * * @license BSD 3-Clause License - * Copyright (c) 2011-2019 Digital Bazaar, Inc. + * Copyright (c) 2011-2022 Digital Bazaar, Inc. * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -80,6 +80,16 @@ const { mergeNodeMaps: _mergeNodeMaps } = require('./nodeMap'); +const { + logEventHandler: _logEventHandler, + logWarningEventHandler: _logWarningEventHandler, + safeEventHandler: _safeEventHandler, + setDefaultEventHandler: _setDefaultEventHandler, + setupEventHandler: _setupEventHandler, + strictEventHandler: _strictEventHandler, + unhandledEventHandler: _unhandledEventHandler +} = require('./events'); + /* eslint-disable indent */ // attaches jsonld API to the given object const wrapper = function(jsonld) { @@ -110,15 +120,8 @@ const _resolvedContextCache = new LRU({max: RESOLVED_CONTEXT_CACHE_MAX_SIZE}); * [skipExpansion] true to assume the input is expanded and skip * expansion, false not to, defaults to false. * [documentLoader(url, options)] the document loader. - * [expansionMap(info)] a function that can be used to custom map - * unmappable values (or to throw an error when they are detected); - * if this function returns `undefined` then the default behavior - * will be used. * [framing] true if compaction is occuring during a framing operation. - * [compactionMap(info)] a function that can be used to custom map - * unmappable values (or to throw an error when they are detected); - * if this function returns `undefined` then the default behavior - * will be used. + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the compacted output. @@ -176,8 +179,7 @@ jsonld.compact = async function(input, ctx, options) { let compacted = await _compact({ activeCtx, element: expanded, - options, - compactionMap: options.compactionMap + options }); // perform clean up @@ -253,10 +255,7 @@ jsonld.compact = async function(input, ctx, options) { * [keepFreeFloatingNodes] true to keep free-floating nodes, * false not to, defaults to false. * [documentLoader(url, options)] the document loader. - * [expansionMap(info)] a function that can be used to custom map - * unmappable values (or to throw an error when they are detected); - * if this function returns `undefined` then the default behavior - * will be used. + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the expanded output. @@ -272,9 +271,6 @@ jsonld.expand = async function(input, options) { contextResolver: new ContextResolver( {sharedCache: _resolvedContextCache}) }); - if(options.expansionMap === false) { - options.expansionMap = undefined; - } // build set of objects that may have @contexts to resolve const toResolve = {}; @@ -325,8 +321,7 @@ jsonld.expand = async function(input, options) { let expanded = await _expand({ activeCtx, element: toResolve.input, - options, - expansionMap: options.expansionMap + options }); // optimize away @graph with no other properties @@ -409,6 +404,7 @@ jsonld.flatten = async function(input, ctx, options) { * [requireAll] default @requireAll flag (default: true). * [omitDefault] default @omitDefault flag (default: false). * [documentLoader(url, options)] the document loader. + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the framed output. @@ -507,6 +503,7 @@ jsonld.frame = async function(input, frame, options) { * [base] the base IRI to use. * [expandContext] a context to expand with. * [documentLoader(url, options)] the document loader. + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the linked output. @@ -542,6 +539,7 @@ jsonld.link = async function(input, ctx, options) { * 'application/n-quads' for N-Quads. * [documentLoader(url, options)] the document loader. * [useNative] true to use a native canonize algorithm + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the normalized output. @@ -596,6 +594,9 @@ jsonld.normalize = jsonld.canonize = async function(input, options) { * (default: false). * [useNativeTypes] true to convert XSD types into native types * (boolean, integer, double), false not to (default: false). + * [rdfDirection] 'i18n-datatype' to support RDF transformation of + * @direction (default: null). + * [safe] true to use safe mode. (default: false) * * @return a Promise that resolves to the JSON-LD document. */ @@ -645,6 +646,7 @@ jsonld.fromRDF = async function(dataset, options) { * [produceGeneralizedRdf] true to output generalized RDF, false * to produce only standard RDF (default: false). * [documentLoader(url, options)] the document loader. + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the RDF dataset. @@ -737,6 +739,7 @@ jsonld.createNodeMap = async function(input, options) { * new properties where a node is in the `object` position * (default: true). * [documentLoader(url, options)] the document loader. + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the merged output. @@ -899,6 +902,7 @@ jsonld.get = async function(url, options) { * @param localCtx the local context to process. * @param [options] the options to use: * [documentLoader(url, options)] the document loader. + * [safe] true to use safe mode. (default: false) * [contextResolver] internal use only. * * @return a Promise that resolves to the new active context. @@ -984,6 +988,14 @@ jsonld.registerRDFParser('application/nquads', NQuads.parse); /* URL API */ jsonld.url = require('./url'); +/* Events API and handlers */ +jsonld.logEventHandler = _logEventHandler; +jsonld.logWarningEventHandler = _logWarningEventHandler; +jsonld.safeEventHandler = _safeEventHandler; +jsonld.setDefaultEventHandler = _setDefaultEventHandler; +jsonld.strictEventHandler = _strictEventHandler; +jsonld.unhandledEventHandler = _unhandledEventHandler; + /* Utility API */ jsonld.util = util; // backwards compatibility @@ -1005,7 +1017,24 @@ function _setDefaults(options, { documentLoader = jsonld.documentLoader, ...defaults }) { - return Object.assign({}, {documentLoader}, defaults, options); + // fail if obsolete options present + if(options && 'compactionMap' in options) { + throw new JsonLdError( + '"compactionMap" not supported.', + 'jsonld.OptionsError'); + } + if(options && 'expansionMap' in options) { + throw new JsonLdError( + '"expansionMap" not supported.', + 'jsonld.OptionsError'); + } + return Object.assign( + {}, + {documentLoader}, + defaults, + options, + {eventHandler: _setupEventHandler({options})} + ); } // end of jsonld API `wrapper` factory diff --git a/lib/toRdf.js b/lib/toRdf.js index d19980c1..eac6bad3 100644 --- a/lib/toRdf.js +++ b/lib/toRdf.js @@ -10,6 +10,10 @@ const jsonCanonicalize = require('canonicalize'); const types = require('./types'); const util = require('./util'); +const { + handleEvent: _handleEvent +} = require('./events'); + const { // RDF, // RDF_LIST, @@ -66,6 +70,20 @@ api.toRDF = (input, options) => { graphTerm.value = graphName; } else { // skip relative IRIs (not valid RDF) + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative graph reference', + level: 'warning', + message: 'Relative graph reference found.', + details: { + graph: graphName + } + }, + options + }); + } continue; } _graphToRDF(dataset, nodeMap[graphName], graphTerm, issuer, options); @@ -107,6 +125,20 @@ function _graphToRDF(dataset, graph, graphTerm, issuer, options) { // skip relative IRI subjects (not valid RDF) if(!_isAbsoluteIri(id)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative subject reference', + level: 'warning', + message: 'Relative subject reference found.', + details: { + subject: id + } + }, + options + }); + } continue; } @@ -118,18 +150,48 @@ function _graphToRDF(dataset, graph, graphTerm, issuer, options) { // skip relative IRI predicates (not valid RDF) if(!_isAbsoluteIri(property)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative property reference', + level: 'warning', + message: 'Relative property reference found.', + details: { + property + } + }, + options + }); + } continue; } // skip blank node predicates unless producing generalized RDF if(predicate.termType === 'BlankNode' && !options.produceGeneralizedRdf) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'blank node predicate', + level: 'warning', + message: 'Dropping blank node predicate.', + details: { + // FIXME: add better issuer API to get reverse mapping + property: issuer.getOldIds() + .find(key => issuer.getId(key) === property) + } + }, + options + }); + } continue; } // convert list, value or node object to triple - const object = - _objectToRDF(item, issuer, dataset, graphTerm, options.rdfDirection); + const object = _objectToRDF( + item, issuer, dataset, graphTerm, options.rdfDirection, options); // skip null objects (they are relative IRIs) if(object) { dataset.push({ @@ -152,10 +214,11 @@ function _graphToRDF(dataset, graph, graphTerm, issuer, options) { * @param issuer a IdentifierIssuer for assigning blank node names. * @param dataset the array of quads to append to. * @param graphTerm the graph term for each quad. + * @param options the RDF serialization options. * * @return the head of the list. */ -function _listToRDF(list, issuer, dataset, graphTerm, rdfDirection) { +function _listToRDF(list, issuer, dataset, graphTerm, rdfDirection, options) { const first = {termType: 'NamedNode', value: RDF_FIRST}; const rest = {termType: 'NamedNode', value: RDF_REST}; const nil = {termType: 'NamedNode', value: RDF_NIL}; @@ -166,7 +229,8 @@ function _listToRDF(list, issuer, dataset, graphTerm, rdfDirection) { let subject = result; for(const item of list) { - const object = _objectToRDF(item, issuer, dataset, graphTerm, rdfDirection); + const object = _objectToRDF( + item, issuer, dataset, graphTerm, rdfDirection, options); const next = {termType: 'BlankNode', value: issuer.getId()}; dataset.push({ subject, @@ -185,7 +249,8 @@ function _listToRDF(list, issuer, dataset, graphTerm, rdfDirection) { // Tail of list if(last) { - const object = _objectToRDF(last, issuer, dataset, graphTerm, rdfDirection); + const object = _objectToRDF( + last, issuer, dataset, graphTerm, rdfDirection, options); dataset.push({ subject, predicate: first, @@ -211,10 +276,13 @@ function _listToRDF(list, issuer, dataset, graphTerm, rdfDirection) { * @param issuer a IdentifierIssuer for assigning blank node names. * @param dataset the dataset to append RDF quads to. * @param graphTerm the graph term for each quad. + * @param options the RDF serialization options. * * @return the RDF literal or RDF resource. */ -function _objectToRDF(item, issuer, dataset, graphTerm, rdfDirection) { +function _objectToRDF( + item, issuer, dataset, graphTerm, rdfDirection, options +) { const object = {}; // convert value object to RDF @@ -260,8 +328,8 @@ function _objectToRDF(item, issuer, dataset, graphTerm, rdfDirection) { object.datatype.value = datatype || XSD_STRING; } } else if(graphTypes.isList(item)) { - const _list = - _listToRDF(item['@list'], issuer, dataset, graphTerm, rdfDirection); + const _list = _listToRDF( + item['@list'], issuer, dataset, graphTerm, rdfDirection, options); object.termType = _list.termType; object.value = _list.value; } else { @@ -273,6 +341,20 @@ function _objectToRDF(item, issuer, dataset, graphTerm, rdfDirection) { // skip relative IRIs, not valid RDF if(object.termType === 'NamedNode' && !_isAbsoluteIri(object.value)) { + if(options.eventHandler) { + _handleEvent({ + event: { + type: ['JsonLdEvent'], + code: 'relative type reference', + level: 'warning', + message: 'Relative type reference found.', + details: { + type: object.value + } + }, + options + }); + } return null; } diff --git a/lib/util.js b/lib/util.js index 1458005a..57bf9f74 100644 --- a/lib/util.js +++ b/lib/util.js @@ -10,10 +10,12 @@ 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: { @@ -24,6 +26,8 @@ const DEFAULTS = { 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 diff --git a/tests/misc.js b/tests/misc.js index d9dae4fc..b5761900 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -479,530 +479,2941 @@ describe('literal JSON', () => { }); }); -describe('expansionMap', () => { - describe('unmappedProperty', () => { - it('should be called on unmapped term', async () => { - const docWithUnMappedTerm = { - '@context': { - 'definedTerm': 'https://example.com#definedTerm' - }, - definedTerm: "is defined", - testUndefined: "is undefined" - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.unmappedProperty === 'testUndefined') { - expansionMapCalled = true; - } - }; +// test events +describe('events', () => { + // track all the event counts + // use simple count object (don't use tricky test keys!) + function addEventCounts(counts, event) { + // overall call counts + counts.events = counts.events || 0; + counts.codes = counts.codes || {}; + + counts.codes[event.code] = counts.codes[event.code] || 0; + + counts.events++; + counts.codes[event.code]++; + } - await jsonld.expand(docWithUnMappedTerm, {expansionMap}); + // create event structure + function makeEvents() { + return {counts: {}, log: []}; + } - assert.equal(expansionMapCalled, true); + // track event and counts + // use simple count object (don't use tricky test keys!) + function trackEvent({events, event}) { + events.counts = events.counts || {}; + events.log = events.log || []; + + addEventCounts(events.counts, event); + // just log useful comparison details + events.log.push({ + code: event.code, + level: event.level, + details: event.details }); + } - it('should be called on nested unmapped term', async () => { - const docWithUnMappedTerm = { - '@context': { - 'definedTerm': 'https://example.com#definedTerm' - }, - definedTerm: { - testUndefined: "is undefined" - } - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.unmappedProperty === 'testUndefined') { - expansionMapCalled = true; - } - }; - - await jsonld.expand(docWithUnMappedTerm, {expansionMap}); + function isObject(v) { + return Object.prototype.toString.call(v) === '[object Object]'; + } - assert.equal(expansionMapCalled, true); - }); - }); + // compare partial event array structures + // for each source, only check fields present in target + // allows easier checking of just a few key fields + function comparePartialEvents(source, target, path = []) { + if(Array.isArray(source)) { + assert(Array.isArray(target), + `target not an array, path: ${JSON.stringify(path)}`); + assert.equal(source.length, target.length, + `event arrays size mismatch: ${JSON.stringify(path)}`); + for(let i = 0; i < source.length; ++i) { + comparePartialEvents(source[i], target[i], [...path, i]); + } + } else if(isObject(target)) { + // check all target keys recursively + for(const key of Object.keys(target)) { + assert(key in source, + `missing expected key: "${key}", path: ${JSON.stringify(path)}`); + comparePartialEvents(source[key], target[key], [...path, key]); + } + } else { + assert.deepStrictEqual(source, target, + `not equal, path: ${JSON.stringify(path)}`); + } + } - describe('relativeIri', () => { - it('should be called on relative iri for id term', async () => { - const docWithRelativeIriId = { - '@context': { - 'definedTerm': 'https://example.com#definedTerm' - }, - '@id': "relativeiri", - definedTerm: "is defined" - }; + // test different apis + // use appropriate options + async function _test({ + // expand, compact, frame, fromRDF, toRDF, normalize, etc + type, + input, + options, + expected, + exception, + eventCounts, + // event array + eventLog, + // parial event array + eventPartialLog, + // event code array + eventCodeLog, + testSafe, + testNotSafe, + verbose + }) { + const events = makeEvents(); + const eventHandler = ({event}) => { + trackEvent({events, event}); + }; - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri') { - expansionMapCalled = true; - } - }; + let result; + let error; + const opts = {...options}; + if(eventCounts || eventLog || eventPartialLog || eventCodeLog) { + opts.eventHandler = eventHandler; + } + if(!['expand', 'fromRDF', 'toRDF', 'canonize'].includes(type)) { + throw new Error(`Unknown test type: "${type}"`); + } + try { + if(type === 'expand') { + result = await jsonld.expand(input, opts); + } + if(type === 'fromRDF') { + result = await jsonld.fromRDF(input, opts); + } + if(type === 'toRDF') { + result = await jsonld.toRDF(input, { + // default to n-quads + format: 'application/n-quads', + ...opts + }); + } + if(type === 'canonize') { + result = await jsonld.canonize(input, opts); + } + } catch(e) { + error = e; + } - await jsonld.expand(docWithRelativeIriId, {expansionMap}); + if(verbose) { + console.log(JSON.stringify({ + type, + input, + options, + expected, + result, + events + }, null, 2)); + } + if(exception) { + assert(error); + assert.equal(error.name, exception); + } + if(!exception && error) { + throw error; + } + if(expected !== undefined) { + assert.deepStrictEqual(result, expected); + } + if(eventCounts) { + assert.deepStrictEqual(events.counts, eventCounts); + } + if(eventLog) { + assert.deepStrictEqual(events.log, eventLog); + } + if(eventPartialLog) { + comparePartialEvents(events.log, eventPartialLog); + } + if(eventCodeLog) { + assert.deepStrictEqual(events.log.map(e => e.code), eventCodeLog); + } + if(eventLog) { + assert.deepStrictEqual(events.log, eventLog); + } + // test passes with safe=true + if(testSafe) { + await _test({type, input, options: {...options, safe: true}}); + } + // test fails with safe=true + if(testNotSafe) { + let error; + try { + await _test({type, input, options: {...options, safe: true}}); + } catch(e) { + error = e; + } - assert.equal(expansionMapCalled, true); - }); + assert(error, 'missing safe validation error'); + } + } - it('should be called on relative iri for id term (nested)', async () => { - const docWithRelativeIriId = { - '@context': { - 'definedTerm': 'https://example.com#definedTerm' - }, - '@id': "urn:absoluteIri", - definedTerm: { - '@id': "relativeiri" - } - }; + describe('event system', () => { + it('check default handler called', async () => { + const d = +{ + "relative": "test" +} +; + const ex = []; - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri') { - expansionMapCalled = true; - } + const events = makeEvents(); + const eventHandler = ({event}) => { + trackEvent({events, event}); }; - await jsonld.expand(docWithRelativeIriId, {expansionMap}); + jsonld.setDefaultEventHandler({eventHandler}); - assert.equal(expansionMapCalled, true); - }); + const e = await jsonld.expand(d); - it('should be called on relative iri for aliased id term', async () => { - const docWithRelativeIriId = { - '@context': { - 'id': '@id', - 'definedTerm': 'https://example.com#definedTerm' + assert.deepStrictEqual(e, ex); + assert.deepStrictEqual(events.counts, { + codes: { + 'empty object': 1, + 'invalid property': 1 }, - 'id': "relativeiri", - definedTerm: "is defined" - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri') { - expansionMapCalled = true; + events: 2 + }); + comparePartialEvents(events.log, [ + { + code: 'invalid property', + details: { + property: 'relative', + expandedProperty: 'relative' + } + }, + { + code: 'empty object' } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); + ]); - assert.equal(expansionMapCalled, true); + // reset default + jsonld.setDefaultEventHandler(); }); - it('should be called on relative iri for type term', async () => { - const docWithRelativeIriId = { - '@context': { - 'definedTerm': 'https://example.com#definedTerm' - }, - 'id': "urn:absoluteiri", - '@type': "relativeiri", - definedTerm: "is defined" - }; + it('handle warning event with function', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-function-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri') { - expansionMapCalled = true; + const counts = {}; + const e = await jsonld.expand(d, { + eventHandler: ({event}) => { + addEventCounts(counts, event); } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalled, true); + }); + assert.deepStrictEqual(e, ex); + assert.deepStrictEqual(counts, { + codes: { + 'empty object': 1, + 'invalid property': 1, + 'reserved term': 1 + }, + events: 3 + }); }); - it('should be called on relative iri for type\ - term in scoped context', async () => { - const docWithRelativeIriId = { - '@context': { - 'definedType': { - '@id': 'https://example.com#definedType', - '@context': { - 'definedTerm': 'https://example.com#definedTerm' - - } + it('cached context event replay', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test" + }, + "@RESERVED": "test" +} +; + const ex = []; + + const counts0 = {}; + const counts1 = {}; + const e0 = await jsonld.expand(d, { + eventHandler: { + 'reserved term': ({event}) => { + addEventCounts(counts0, event); } - }, - 'id': "urn:absoluteiri", - '@type': "definedType", - definedTerm: { - '@type': 'relativeiri' } - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri') { - expansionMapCalled = true; + }); + // FIXME: ensure cache is being used + const e1 = await jsonld.expand(d, { + eventHandler: { + 'reserved term': ({event}) => { + addEventCounts(counts1, event); + } } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it('should be called on relative iri for \ - type term with multiple relative iri types', async () => { - const docWithRelativeIriId = { - '@context': { - 'definedTerm': 'https://example.com#definedTerm' + }); + assert.deepStrictEqual(e0, ex); + assert.deepStrictEqual(e1, ex); + assert.deepStrictEqual(counts0, { + codes: { + 'reserved term': 1 }, - 'id': "urn:absoluteiri", - '@type': ["relativeiri", "anotherRelativeiri" ], - definedTerm: "is defined" - }; - - let expansionMapCalledTimes = 0; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri' || - info.relativeIri === 'anotherRelativeiri') { - expansionMapCalledTimes++; - } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalledTimes, 3); + events: 1 + }, 'counts 0'); + assert.deepStrictEqual(counts1, { + codes: { + 'reserved term': 1 + }, + events: 1 + }, 'counts 1'); }); - it('should be called on relative iri for \ - type term with multiple relative iri types in scoped context', async () => { - const docWithRelativeIriId = { - '@context': { - 'definedType': { - '@id': 'https://example.com#definedType', - '@context': { - 'definedTerm': 'https://example.com#definedTerm' - + it('handle warning event with array of functions', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-function-array-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + const handlerCounts0 = {}; + const handlerCounts1 = {}; + const handledCounts = {}; + const e = await jsonld.expand(d, { + eventHandler: [ + ({event, next}) => { + addEventCounts(handlerCounts0, event); + // skip to next handler + next(); + }, + ({event}) => { + addEventCounts(handlerCounts1, event); + if(event.code === 'reserved term') { + addEventCounts(handledCounts, event); + return; } } + ] + }); + assert.deepStrictEqual(e, ex); + assert.deepStrictEqual(handlerCounts0, { + codes: { + 'empty object': 1, + 'invalid property': 1, + 'reserved term': 1 }, - 'id': "urn:absoluteiri", - '@type': "definedType", - definedTerm: { - '@type': ["relativeiri", "anotherRelativeiri" ] - } - }; - - let expansionMapCalledTimes = 0; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri' || - info.relativeIri === 'anotherRelativeiri') { - expansionMapCalledTimes++; - } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalledTimes, 3); - }); - - it('should be called on relative iri for \ - type term with multiple types', async () => { - const docWithRelativeIriId = { - '@context': { - 'definedTerm': 'https://example.com#definedTerm' + events: 3 + }, 'counts handler 0'); + assert.deepStrictEqual(handlerCounts1, { + codes: { + 'empty object': 1, + 'invalid property': 1, + 'reserved term': 1 }, - 'id': "urn:absoluteiri", - '@type': ["relativeiri", "definedTerm" ], - definedTerm: "is defined" - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri') { - expansionMapCalled = true; - } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it('should be called on relative iri for aliased type term', async () => { - const docWithRelativeIriId = { - '@context': { - 'type': "@type", - 'definedTerm': 'https://example.com#definedTerm' + events: 3 + }, 'counts handler 1'); + assert.deepStrictEqual(handledCounts, { + codes: { + 'reserved term': 1 }, - 'id': "urn:absoluteiri", - 'type': "relativeiri", - definedTerm: "is defined" - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === 'relativeiri') { - expansionMapCalled = true; - } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalled, true); + events: 1 + }, 'counts handled'); }); - it("should be called on relative iri when \ - @base value is './'", async () => { - const docWithRelativeIriId = { - '@context': { - "@base": "./", + it('handle warning event early with array of functions', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-function-array-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + const handlerCounts0 = {}; + const handlerCounts1 = {}; + const handledCounts = {}; + const e = await jsonld.expand(d, { + eventHandler: [ + ({event}) => { + addEventCounts(handlerCounts0, event); + // don't skip to next handler + }, + ({event}) => { + addEventCounts(handlerCounts1, event); + if(event.code === 'reserved term') { + addEventCounts(handledCounts, event); + return; + } + } + ] + }); + assert.deepStrictEqual(e, ex); + assert.deepStrictEqual(handlerCounts0, { + codes: { + 'empty object': 1, + 'invalid property': 1, + 'reserved term': 1 }, - '@id': "relativeiri", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === '/relativeiri') { - expansionMapCalled = true; - } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalled, true); + events: 3 + }, 'counts handler 0'); + assert.deepStrictEqual(handlerCounts1, {}, 'counts handler 1'); + assert.deepStrictEqual(handledCounts, {}, 'counts handled'); }); - it("should be called on relative iri when \ - @base value is './'", async () => { - const docWithRelativeIriId = { - '@context': { - "@base": "./", - }, - '@id': "relativeiri", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === '/relativeiri') { - expansionMapCalled = true; + it('handle warning event with code:function object', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-object-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + const counts = {}; + const e = await jsonld.expand(d, { + eventHandler: { + 'reserved term': ({event}) => { + addEventCounts(counts, event); + assert.strictEqual(event.details.term, '@RESERVED'); + } } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it("should be called on relative iri when \ - @vocab value is './'", async () => { - const docWithRelativeIriId = { - '@context': { - "@vocab": "./", + }); + assert.deepStrictEqual(e, ex); + assert.deepStrictEqual(counts, { + codes: { + 'reserved term': 1 }, - '@type': "relativeiri", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.relativeIri === '/relativeiri') { - expansionMapCalled = true; - } - }; - - await jsonld.expand(docWithRelativeIriId, {expansionMap}); - - assert.equal(expansionMapCalled, true); + events: 1 + }, 'counts'); }); - }); - describe('prependedIri', () => { - it("should be called when property is \ - being expanded with `@vocab`", async () => { - const doc = { - '@context': { - "@vocab": "http://example.com/", + it('handle warning event with complex handler', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-complex-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + const handlerCounts0 = {}; + const handlerCounts1 = {}; + const handlerCounts2 = {}; + const handlerCounts3 = {}; + const e = await jsonld.expand(d, { + eventHandler: [ + ({event, next}) => { + addEventCounts(handlerCounts0, event); + next(); + }, + [ + ({event, next}) => { + addEventCounts(handlerCounts1, event); + next(); + }, + { + 'bogus code': () => {} + } + ], + ({event, next}) => { + addEventCounts(handlerCounts2, event); + next(); + }, + { + 'reserved term': ({event}) => { + addEventCounts(handlerCounts3, event); + } + } + ] + }); + assert.deepStrictEqual(e, ex); + assert.deepStrictEqual(handlerCounts0, { + codes: { + 'empty object': 1, + 'invalid property': 1, + 'reserved term': 1 }, - 'term': "termValue", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - assert.deepStrictEqual(info.prependedIri, { - type: '@vocab', - vocab: 'http://example.com/', - value: 'term', - typeExpansion: false, - result: 'http://example.com/term' - }); - expansionMapCalled = true; - }; - - await jsonld.expand(doc, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it("should be called when '@type' is \ - being expanded with `@vocab`", async () => { - const doc = { - '@context': { - "@vocab": "http://example.com/", + events: 3 + }, 'counts handler 0'); + assert.deepStrictEqual(handlerCounts1, { + codes: { + 'empty object': 1, + 'invalid property': 1, + 'reserved term': 1 }, - '@type': "relativeIri", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - assert.deepStrictEqual(info.prependedIri, { - type: '@vocab', - vocab: 'http://example.com/', - value: 'relativeIri', - typeExpansion: true, - result: 'http://example.com/relativeIri' - }); - expansionMapCalled = true; - }; - - await jsonld.expand(doc, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it("should be called when aliased '@type' is \ - being expanded with `@vocab`", async () => { - const doc = { - '@context': { - "@vocab": "http://example.com/", - "type": "@type" + events: 3 + }, 'counts handler 1'); + assert.deepStrictEqual(handlerCounts2, { + codes: { + 'empty object': 1, + 'invalid property': 1, + 'reserved term': 1 }, - 'type': "relativeIri", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - assert.deepStrictEqual(info.prependedIri, { - type: '@vocab', - vocab: 'http://example.com/', - value: 'relativeIri', - typeExpansion: true, - result: 'http://example.com/relativeIri' - }); - expansionMapCalled = true; - }; - - await jsonld.expand(doc, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it("should be called when '@id' is being \ - expanded with `@base`", async () => { - const doc = { - '@context': { - "@base": "http://example.com/", + events: 3 + }, 'counts handler 2'); + assert.deepStrictEqual(handlerCounts3, { + codes: { + 'reserved term': 1 }, - '@id': "relativeIri", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.prependedIri) { - assert.deepStrictEqual(info.prependedIri, { - type: '@base', - base: 'http://example.com/', - value: 'relativeIri', - typeExpansion: false, - result: 'http://example.com/relativeIri' - }); - expansionMapCalled = true; - } - }; - - await jsonld.expand(doc, {expansionMap}); - - assert.equal(expansionMapCalled, true); + events: 1 + }, 'counts handler 3'); }); - it("should be called when aliased '@id' \ - is being expanded with `@base`", async () => { - const doc = { - '@context': { - "@base": "http://example.com/", - "id": "@id" - }, - 'id': "relativeIri", - }; + it('handle known warning events', async () => { + const d = +{ + "@context": { + "id-at": {"@id": "@test"}, + "@RESERVED": "ex:test" + }, + "@RESERVED": "test", + "ex:language": { + "@value": "test", + "@language": "!" + } +} +; + const ex = +[ + { + "ex:language": [ + { + "@value": "test", + "@language": "!" + } + ] + } +] +; - let expansionMapCalled = false; - const expansionMap = info => { - if(info.prependedIri) { - assert.deepStrictEqual(info.prependedIri, { - type: '@base', - base: 'http://example.com/', - value: 'relativeIri', - typeExpansion: false, - result: 'http://example.com/relativeIri' - }); - expansionMapCalled = true; + const handledReservedTermCounts = {}; + const handledReservedIdValueCounts = {}; + const handledLanguageCounts = {}; + const e = await jsonld.expand(d, { + eventHandler: { + 'reserved term': ({event}) => { + addEventCounts(handledReservedTermCounts, event); + }, + 'reserved @id value': ({event}) => { + addEventCounts(handledReservedIdValueCounts, event); + }, + 'invalid @language value': ({event}) => { + addEventCounts(handledLanguageCounts, event); + } } - }; - - await jsonld.expand(doc, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it("should be called when '@type' is \ - being expanded with `@base`", async () => { - const doc = { - '@context': { - "@base": "http://example.com/", + }); + assert.deepStrictEqual(e, ex); + assert.deepStrictEqual(handledReservedTermCounts, { + codes: { + 'reserved term': 1 }, - '@type': "relativeIri", - }; - - let expansionMapCalled = false; - const expansionMap = info => { - if(info.prependedIri) { - assert.deepStrictEqual(info.prependedIri, { - type: '@base', - base: 'http://example.com/', - value: 'relativeIri', - typeExpansion: true, - result: 'http://example.com/relativeIri' - }); - expansionMapCalled = true; - } - }; - - await jsonld.expand(doc, {expansionMap}); - - assert.equal(expansionMapCalled, true); - }); - - it("should be called when aliased '@type' is \ - being expanded with `@base`", async () => { - const doc = { - '@context': { - "@base": "http://example.com/", - "type": "@type" + events: 1 + }, 'handled reserved term counts'); + assert.deepStrictEqual(handledReservedIdValueCounts, { + codes: { + 'reserved @id value': 1 }, - 'type': "relativeIri", - }; + events: 1 + }, 'handled reserved value counts'); + assert.deepStrictEqual(handledLanguageCounts, { + codes: { + 'invalid @language value': 1 + }, + events: 1 + }, 'handled language counts'); + + // dataset with invalid language tag + // Equivalent N-Quads: + // "..."^^ .' + // Using JSON dataset to bypass N-Quads parser checks. + const d2 = +[ + { + "subject": { + "termType": "NamedNode", + "value": "ex:s" + }, + "predicate": { + "termType": "NamedNode", + "value": "ex:p" + }, + "object": { + "termType": "Literal", + "value": "invalid @language value", + "datatype": { + "termType": "NamedNode", + "value": "https://www.w3.org/ns/i18n#!_rtl" + } + }, + "graph": { + "termType": "DefaultGraph", + "value": "" + } + } +] +; + const ex2 = +[ + { + "@id": "ex:s", + "ex:p": [ + { + "@value": "invalid @language value", + "@language": "!", + "@direction": "rtl" + } + ] + } +] +; - let expansionMapCalled = false; - const expansionMap = info => { - if(info.prependedIri) { - assert.deepStrictEqual(info.prependedIri, { - type: '@base', - base: 'http://example.com/', - value: 'relativeIri', - typeExpansion: true, - result: 'http://example.com/relativeIri' - }); - expansionMapCalled = true; + const handledLanguageCounts2 = {}; + const e2 = await jsonld.fromRDF(d2, { + rdfDirection: 'i18n-datatype', + eventHandler: { + 'invalid @language value': ({event}) => { + addEventCounts(handledLanguageCounts2, event); + } } - }; + }); + assert.deepStrictEqual(e2, ex2); + assert.deepStrictEqual(handledLanguageCounts2, { + codes: { + 'invalid @language value': 1 + }, + events: 1 + }, 'handled language counts'); + }); + }); - await jsonld.expand(doc, {expansionMap}); + describe('reserved', () => { + it('should handle reserved context @id values [1]', async () => { + const input = +{ + "@context": { + "resId": {"@id": "@RESERVED"} + }, + "@id": "ex:id", + "resId": "resIdValue", + "ex:p": "v" +} +; + const expected = +[ + { + "@id": "ex:id", + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ + "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventPartialLog: [ + { + code: 'reserved @id value', + details: { + id: '@RESERVED' + } + }, + { + code: 'invalid property', + details: { + property: 'resId', + expandedProperty: 'resId' + } + } + ], + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle reserved context @id values [2]', async () => { + const input = +{ + "@context": { + "resId": "@RESERVED" + }, + "@id": "ex:id", + "resId": "resIdValue", + "ex:p": "v" +} +; + const expected = +[ + { + "@id": "ex:id", + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ + "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'reserved @id value', + 'invalid property' + // .. resId + ], + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle reserved content @id values', async () => { + const input = +{ + "@id": "@RESERVED", + "ex:p": "v" +} +; + const expected = +[ + { + "@id": null, + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'reserved @id value' + ], + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle reserved content id values [1]', async () => { + const input = +{ + "@context": { + "p": {"@id": "ex:idp", "@type": "@id"} + }, + "p": "@RESERVED", + "ex:p": "v" +} +; + const expected = +[ + { + "ex:idp": [ + { + "@id": null + } + ], + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'reserved @id value' + ], + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle reserved content id values [2]', async () => { + const input = +{ + "@context": { + "id": "@id" + }, + "id": "@RESERVED", + "ex:p": "v" +} +; + const expected = +[ + { + "@id": null, + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'reserved @id value': 1 + // .. '@RESERVED' + }, + events: 1 + }, + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle reserved content id values [3]', async () => { + const input = +{ + "@context": { + "p": {"@id": "ex:idp", "@type": "@id"} + }, + "p": {"@id": "@RESERVED"}, + "ex:p": "v" +} +; + const expected = +[ + { + "ex:idp": [ + { + "@id": null + } + ], + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'reserved @id value': 1 + // .. '@RESERVED' + }, + events: 1 + }, + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle reserved context terms', async () => { + const input = +{ + "@context": { + "@RESERVED": "ex:test" + }, + "@RESERVED": "test", + "ex:p": "v" +} +; + const expected = +[ + { + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'reserved term', + // .. @RESERVED + 'invalid property' + // .. @RESERVED + ], + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle reserved content terms', async () => { + const input = +{ + "@RESERVED": "test", + "ex:p": "v" +} +; + const expected = +[ + { + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'invalid property': 1, + // .. '@RESERVED' + }, + events: 1 + }, + testNotSafe: true + }); + + await _test({ + type: 'toRDF', + input: expected, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + }); + + describe('values', () => { + it('should have zero counts with empty list', async () => { + const input = []; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: {}, + testSafe: true + }); + }); + + it('should count empty top-level object', async () => { + const input = {}; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'empty object': 1 + }, + events: 1 + }, + testNotSafe: true + }); + }); + + it('should count empty top-level object with only context', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + } +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'empty object': 1 + }, + events: 1 + }, + testNotSafe: true + }); + }); + + it('should not emit for ok @set', async () => { + const input = +{ + "@set": [ + { + "@id": "http://example.com/node", + "urn:property": "nodes with properties are not removed" + } + ] +} +; + const expected = +[ + { + "@id": "http://example.com/node", + "urn:property": [ + { + "@value": "nodes with properties are not removed" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: {}, + testSafe: true + }); + }); + + it('should emit for @set free-floating scaler', async () => { + const input = +{ + "@set": [ + "free-floating strings in set objects are removed", + { + "@id": "http://example.com/free-floating-node" + }, + { + "@id": "http://example.com/node", + "urn:property": "nodes with properties are not removed" + } + ] +} +; + const expected = +[ + { + "@id": "http://example.com/node", + "urn:property": [ + { + "@value": "nodes with properties are not removed" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'free-floating scalar': 1, + // .. 'http://example.com/free-floating-node' + 'object with only @id': 1 + }, + events: 2 + }, + testNotSafe: true + }); + }); + + it('should emit for only @list', async () => { + const input = +{ + "@list": [ + { + "@id": "http://example.com/node", + "urn:property": "nodes are removed with the @list" + } + ] +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'object with only @list': 1 + }, + events: 1 + }, + testNotSafe: true + }); + }); + + it('should emit for @list free-floating scaler', async () => { + const input = +{ + "@list": [ + "free-floating strings in list objects are removed", + { + "@id": "http://example.com/free-floating-node" + }, + { + "@id": "http://example.com/node", + "urn:property": "nodes are removed with the @list" + } + ] +} +; + const expected = []; + + console.error('FIXME'); + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'free-floating scalar': 1, + // .. 'http://example.com/free-floating-node' + 'object with only @id': 1, + 'object with only @list': 1 + }, + events: 3 + }, + testNotSafe: true + }); + }); + + it('should not emit for ok @graph', async () => { + const input = +{ + "@graph": [ + { + "@id": "http://example.com/node", + "urn:property": "nodes with properties are not removed" + } + ] +} +; + const expected = +[ + { + "@id": "http://example.com/node", + "urn:property": [ + { + "@value": "nodes with properties are not removed" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: {}, + testSafe: true + }); + }); + + it('should emit for @graph free-floating scaler', async () => { + const input = +{ + "@graph": [ + "free-floating strings in set objects are removed", + {}, + { + "@value": "v" + }, + { + "@list": [{ + "urn:p": "lv" + }] + }, + { + "@id": "http://example.com/node", + "urn:property": "nodes with properties are not removed" + } + ] +} +; + const expected = +[ + { + "@id": "http://example.com/node", + "urn:property": [ + { + "@value": "nodes with properties are not removed" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'empty object': 1, + 'free-floating scalar': 1, + // .. 'free-floating strings in set objects are removed' + 'object with only @list': 1, + 'object with only @value': 1 + }, + events: 4 + }, + testNotSafe: true + }); + }); + + it('should emit for null @value', async () => { + const input = +{ + "urn:property": { + "@value": null + } +} +; + const expected = []; + + console.error('FIXME'); + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'empty object': 1, + 'null @value value': 1 + }, + events: 2 + }, + testNotSafe: true + }); + }); + + it('should emit for @language alone', async () => { + const input = +{ + "urn:property": { + "@language": "en" + } +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'empty object': 1, + 'object with only @language': 1, + }, + events: 2 + }, + testNotSafe: true + }); + }); + + it('should emit for invalid @language value', async () => { + const input = +{ + "urn:property": { + "@language": "en_bad", + "@value": "test" + } +} +; + const expected = +[ + { + "urn:property": [ + { + "@language": "en_bad", + "@value": "test" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCounts: { + codes: { + 'invalid @language value': 1 + }, + events: 1 + }, + testNotSafe: true + }); + }); + + it('should emit for invalid default @language value', async () => { + const input = +{ + "@context": { + "@language": "en_bad" + }, + "urn:property": "value" +} +; + const expected = +[ + { + "urn:property": [ + { + "@language": "en_bad", + "@value": "value" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid @language value' + ], + testNotSafe: true + }); + }); + + it('should emit for invalid @language map value', async () => { + const input = +{ + "@context": { + "urn:property": { + "@container": "@language" + } + }, + "urn:property": { + "en_bad": "en", + "de": "de" + } +} +; + const expected = +[ + { + "urn:property": [ + { + "@language": "de", + "@value": "de" + }, + { + "@language": "en_bad", + "@value": "en" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid @language value' + // .. en_bad + ], + testNotSafe: true + }); + }); + + it('should emit for reserved @reverse value', async () => { + const input = +{ + "@context": { + "children": { + "@reverse": "@RESERVED" + } + }, + "@id": "ex:parent", + "children": [ + { + "@id": "ex:child" + } + ] +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'reserved @reverse value', + // .. '@RESERVED' + 'invalid property', + // .. children + 'object with only @id' + ], + testNotSafe: true + }); + }); + }); + + describe('properties', () => { + it('should have zero events with absolute term', async () => { + const input = +{ + "urn:definedTerm": "is defined" +} +; + const expected = +[ + { + "urn:definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should have zero events with mapped term', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "definedTerm": "is defined" +} +; + const expected = +[ + { + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should be called on unmapped term with no context', async () => { + const input = +{ + "testUndefined": "is undefined" +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventLog: [ + { + code: 'invalid property', + details: { + expandedProperty: 'testUndefined', + property: 'testUndefined' + }, + level: 'warning' + }, + { + code: 'empty object', + level: 'warning', + details: { + value: {} + } + } + ], + testNotSafe: true + }); + }); + + it('should be called only on top unmapped term', async () => { + // value of undefined property is dropped and not checked + const input = +{ + "testUndefined": { + "subUndefined": "undefined" + } +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid property', + // .. 'testUndefined' + 'empty object' + ], + testNotSafe: true + }); + }); + + it('should be called on sub unmapped term', async () => { + const input = +{ + "ex:defined": { + "testundefined": "undefined" + } +} +; + const expected = +[ + { + "ex:defined": [ + {} + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid property' + // .. 'testUndefined' + ], + testNotSafe: true + }); + }); + + it('should be called on unmapped term with context [1]', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "testUndefined": "is undefined" +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid property', + // .. 'testUndefined' + 'empty object' + ], + testNotSafe: true + }); + }); + + it('should be called on unmapped term with context [2]', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "definedTerm": "is defined", + "testUndefined": "is undefined" +} +; + const expected = +[ + { + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid property' + // .. 'testUndefined' + ], + testNotSafe: true + }); + }); + + it('should be called on nested unmapped term', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "definedTerm": { + "testUndefined": "is undefined" + } +} +; + const expected = +[ + { + "https://example.com#definedTerm": [ + {} + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid property' + // .. 'testUndefined' + ], + testNotSafe: true + }); + }); + + it('should be called on reserved term', async () => { + const input = +{ + "@context": { + "@RESERVED": "ex:test-function-handler" + }, + "@RESERVED": "test" +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'reserved term', + // .. '@RESERVED' + 'invalid property', + // .. '@RESERVED' + 'empty object' + ], + testNotSafe: true + }); + }); + }); + + // FIXME naming + describe('relativeIri', () => { + it('should be called on relative IRI for id term [1]', async () => { + const input = +{ + "@id": "relativeiri" +} +; + const expected = []; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @id reference', + // .. 'relativeiri' + 'object with only @id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for id term [2]', async () => { + const input = +{ + "@id": "relativeiri", + "urn:test": "value" +} +; + const expected = +[ + { + "@id": "relativeiri", + "urn:test": [ + { + "@value": "value" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + ////'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @id reference' + // .. 'relativeiri' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for id term [3]', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "@id": "relativeiri", + "definedTerm": "is defined" +} +; + const expected = +[ + { + "@id": "relativeiri", + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @id reference' + // .. 'relativeiri' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for id term (nested)', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "@id": "urn:absoluteIri", + "definedTerm": { + "@id": "relativeiri" + } +} +; + const expected = +[ + { + "@id": "urn:absoluteIri", + "https://example.com#definedTerm": [ + { + "@id": "relativeiri" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @id reference' + // .. 'relativeiri' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for aliased id term', async () => { + const input = +{ + "@context": { + "id": "@id", + "definedTerm": "https://example.com#definedTerm" + }, + "id": "relativeiri", + "definedTerm": "is defined" +} +; + const expected = +[ + { + "@id": "relativeiri", + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @id reference' + // .. 'relativeiri' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for type term', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "id": "urn:absoluteiri", + "@type": "relativeiri", + "definedTerm": "is defined" +} +; + const expected = +[ + { + "@type": [ + "relativeiri" + ], + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @type reference', + // .. 'relativeiri' + 'invalid property' + // .. 'id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for type ' + + 'term in scoped context', async () => { + const input = +{ + "@context": { + "definedType": { + "@id": "https://example.com#definedType", + "@context": { + "definedTerm": "https://example.com#definedTerm" + + } + } + }, + "id": "urn:absoluteiri", + "@type": "definedType", + "definedTerm": { + "@type": "relativeiri" + } +} +; + const expected = +[ + { + "@type": [ + "https://example.com#definedType" + ], + "https://example.com#definedTerm": [ + { + "@type": [ + "relativeiri" + ] + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @type reference', + // .. 'relativeiri' + 'invalid property' + // .. 'id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for ' + + 'type term with multiple relative IRI types', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "id": "urn:absoluteiri", + "@type": ["relativeiri", "anotherRelativeiri"], + "definedTerm": "is defined" +} +; + const expected = +[ + { + "@type": [ + "relativeiri", + "anotherRelativeiri" + ], + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @type reference', + // .. 'relativeiri' + //'prepending @base during expansion', + //// .. 'anotherRelativeiri' + 'relative @type reference', + // .. 'anotherRelativeiri' + 'invalid property' + // 'id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for ' + + 'type term with multiple relative IRI types in scoped context' + + '', async () => { + const input = +{ + "@context": { + "definedType": { + "@id": "https://example.com#definedType", + "@context": { + "definedTerm": "https://example.com#definedTerm" + } + } + }, + "id": "urn:absoluteiri", + "@type": "definedType", + "definedTerm": { + "@type": ["relativeiri", "anotherRelativeiri" ] + } +} +; + const expected = +[ + { + "@type": [ + "https://example.com#definedType" + ], + "https://example.com#definedTerm": [ + { + "@type": [ + "relativeiri", + "anotherRelativeiri" + ] + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @type reference', + // .. 'relativeiri' + //'prepending @base during expansion', + //// .. 'anotherRelativeiri' + 'relative @type reference', + // .. 'anotherRelativeiri' + 'invalid property' + // .. 'id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for ' + + 'type term with multiple types', async () => { + const input = +{ + "@context": { + "definedTerm": "https://example.com#definedTerm" + }, + "id": "urn:absoluteiri", + "@type": ["relativeiri", "definedTerm"], + "definedTerm": "is defined" +} +; + const expected = +[ + { + "@type": [ + "relativeiri", + "https://example.com#definedTerm" + ], + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @type reference', + // .. 'relativeiri' + 'invalid property' + // .. 'id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI for aliased type term', async () => { + const input = +{ + "@context": { + "type": "@type", + "definedTerm": "https://example.com#definedTerm" + }, + "id": "urn:absoluteiri", + "type": "relativeiri", + "definedTerm": "is defined" +}; + const expected = +[ + { + "@type": [ + "relativeiri" + ], + "https://example.com#definedTerm": [ + { + "@value": "is defined" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + 'invalid property', + // .. 'relativeiri' + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @type reference' + // .. 'id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI when ' + + '@base value is `null`', async () => { + const input = +{ + "@context": { + "@base": null + }, + "@id": "relativeiri" +} +; + const expected = +[ +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @id reference', + // .. 'relativeiri' + 'object with only @id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI when ' + + '@base value is `./`', async () => { + const input = +{ + "@context": { + "@base": "./" + }, + "@id": "relativeiri" +} +; + const expected = +[ +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @id reference', + // .. 'relativeiri' + 'object with only @id' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI when ' + + '`@vocab` value is `null`', async () => { + const input = +{ + "@context": { + "@vocab": null + }, + "@type": "relativeiri" +} +; + const expected = +[ + { + "@type": [ + "relativeiri" + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeiri' + 'relative @type reference', + // .. 'relativeiri' + ], + testNotSafe: true + }); + }); + + it('should be called on relative IRI when ' + + '`@vocab` value is `./`', async () => { + const input = +{ + "@context": { + "@vocab": "./" + }, + "@type": "relativeiri" +} +; + const expected = +[ + { + "@type": [ + "/relativeiri" + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. './' + 'relative @vocab reference', + // .. './' + //'prepending @vocab during expansion', + //// .. 'relativeiri' + //'prepending @vocab during expansion', + //// .. 'relativeiri' + 'relative @type reference' + // .. 'relativeiri' + ], + testNotSafe: true + }); + }); + }); + + describe('prependedIri', () => { + it('should be called when property is ' + + 'being expanded with `@vocab`', async () => { + const input = +{ + "@context": { + "@vocab": "http://example.com/" + }, + "term": "termValue" +}; + const expected = +[ + { + "http://example.com/term": [ + { + "@value": "termValue" + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @vocab during expansion', + //// .. 'term' + //'prepending @vocab during expansion', + //// .. 'term' + //'prepending @vocab during expansion', + //// .. 'term' + //'prepending @vocab during expansion' + //// .. 'term' + ], + testSafe: true + }); + }); + + it('should be called when `@type` is ' + + 'being expanded with `@vocab`', async () => { + const input = +{ + "@context": { + "@vocab": "http://example.com/" + }, + "@type": "relativeIri" +} +; + const expected = +[ + { + "@type": [ + "http://example.com/relativeIri" + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @vocab during expansion', + //// .. 'relativeIri' + //'prepending @vocab during expansion' + //// .. 'relativeIri' + ], + testSafe: true + }); + }); + + it('should be called when aliased `@type` is ' + + 'being expanded with `@vocab`', async () => { + const input = +{ + "@context": { + "@vocab": "http://example.com/", + "type": "@type" + }, + "type": "relativeIri" +} +; + const expected = +[ + { + "@type": [ + "http://example.com/relativeIri" + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @vocab during expansion', + //// .. 'relativeIri' + //'prepending @vocab during expansion' + //// .. 'relativeIri' + ], + testSafe: true + }); + }); + + it('should handle scoped relative `@vocab`', async () => { + const input = +{ + "@context": { + "@vocab": "urn:abs/" + }, + "@type": "ta", + "e:a": { + "@context": { + "@vocab": "rel/" + }, + "@type": "tb" + } +} +; + const expected = +[ + { + "@type": [ + "urn:abs/ta" + ], + "e:a": [ + { + "@type": [ + "urn:abs/rel/tb" + ] + } + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @vocab during expansion', + //// .. 'ta' + //'prepending @vocab during expansion', + //// .. 'ta' + //'prepending @vocab during expansion', + //// .. 'rel/' + //'prepending @vocab during expansion', + //// .. 'tb' + //'prepending @vocab during expansion' + //// .. 'tb' + ], + testSafe: true + }); + }); + + it('should be called when `@id` is being ' + + 'expanded with `@base`', async () => { + const input = +{ + "@context": { + "@base": "http://example.com/" + }, + "@id": "relativeIri" +} +; + const expected = +[ +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeIri' + 'object with only @id' + ], + testNotSafe: true + }); + }); + + it('should be called when aliased `@id` ' + + 'is being expanded with `@base`', async () => { + const input = +{ + "@context": { + "@base": "http://example.com/", + "id": "@id" + }, + "id": "relativeIri" +} +; + const expected = +[ +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion', + //// .. 'relativeIri' + 'object with only @id' + ], + testNotSafe: true + }); + }); + + it('should be called when `@type` is ' + + 'being expanded with `@base`', async () => { + const input = +{ + "@context": { + "@base": "http://example.com/" + }, + "@type": "relativeIri" +} +; + const expected = +[ + { + "@type": [ + "http://example.com/relativeIri" + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion' + //// .. 'relativeIri' + ], + // FIXME + testSafe: true + }); + }); + + it('should be called when aliased `@type` is ' + + 'being expanded with `@base`', async () => { + const input = +{ + "@context": { + "@base": "http://example.com/", + "type": "@type" + }, + "type": "relativeIri" +} +; + const expected = +[ + { + "@type": [ + "http://example.com/relativeIri" + ] + } +] +; + + await _test({ + type: 'expand', + input, + expected, + eventCodeLog: [ + //'prepending @base during expansion' + //// .. 'relativeIri' + ], + testSafe: true + }); + }); + }); + + describe('fromRDF', () => { + it('should emit for invalid N-Quads @language value', async () => { + // N-Quads with invalid language tag (too long) + // FIXME: should N-Quads parser catch this instead? + const input = +'_:b0 "test"@abcdefghi .' +; + const expected = +[ + { + "@id": "_:b0", + "urn:property": [ + { + "@language": "abcdefghi", + "@value": "test" + } + ] + } +] +; + + console.error('FIXME'); + await _test({ + type: 'fromRDF', + input, + expected, + eventCodeLog: [ + 'invalid @language value' + // .. 'abcdefghi' + ], + testNotSafe: true + }); + }); + + it('should emit for invalid Dataset @language value', async () => { + // dataset with invalid language tag (too long) + // Equivalent N-Quads: + // ' "test"^^ .' + // Using JSON dataset to bypass N-Quads parser checks. + const input = +[ + { + "subject": { + "termType": "NamedNode", + "value": "ex:s" + }, + "predicate": { + "termType": "NamedNode", + "value": "ex:p" + }, + "object": { + "termType": "Literal", + "value": "test", + "datatype": { + "termType": "NamedNode", + "value": "https://www.w3.org/ns/i18n#abcdefghi_rtl" + } + }, + "graph": { + "termType": "DefaultGraph", + "value": "" + } + } +] +; + const expected = +[ + { + "@id": "ex:s", + "ex:p": [ + { + "@value": "test", + "@language": "abcdefghi", + "@direction": "rtl" + } + ] + } +] +; + + await _test({ + type: 'fromRDF', + input, + options: { + rdfDirection: 'i18n-datatype', + }, + expected, + eventCodeLog: [ + 'invalid @language value' + ], + testNotSafe: true + }); + }); + }); + + describe('toRDF', () => { + it('should handle relative graph reference', async () => { + const input = +[ + { + "@id": "rel", + "@graph": [ + { + "@id": "s:1", + "ex:p": [ + { + "@value": "v1" + } + ] + } + ] + } +] +; + const nq = `\ +`; + + await _test({ + type: 'toRDF', + input, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [ + 'relative graph reference' + // .. 'rel' + ], + testNotSafe: true + }); + }); - assert.equal(expansionMapCalled, true); + it('should handle relative subject reference', async () => { + const input = +[ + { + "@id": "rel", + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +`; + + await _test({ + type: 'toRDF', + input, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [ + 'relative subject reference' + // .. 'rel' + ], + testNotSafe: true + }); + }); + + it('should handle relative property reference', async () => { + const input = +[ + { + "rel": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +`; + + await _test({ + type: 'toRDF', + input, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [ + 'relative property reference' + // .. 'rel' + ], + testNotSafe: true + }); + }); + + it('should handle relative property reference', async () => { + const input = +[ + { + "@type": [ + "rel" + ], + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'toRDF', + input, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [ + 'relative type reference' + // .. 'rel' + ], + testNotSafe: true + }); + }); + + it('should handle blank node predicates', async () => { + const input = +[ + { + "_:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +`; + + await _test({ + type: 'toRDF', + input, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [ + 'blank node predicate' + // .. '_:p' + ], + testNotSafe: true + }); + }); + + it('should handle generlized RDF blank node predicates', async () => { + const input = +[ + { + "_:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 <_:b1> "v" . +`; + + await _test({ + type: 'toRDF', + input, + options: { + skipExpansion: true, + produceGeneralizedRdf: true + }, + expected: nq, + eventCodeLog: [], + testSafe: true + }); + }); + + it('should handle null @id', async () => { + const input = +[ + { + "@id": null, + "ex:p": [ + { + "@value": "v" + } + ] + } +] +; + const nq = `\ +_:b0 "v" . +`; + + await _test({ + type: 'toRDF', + input, + options: {skipExpansion: true}, + expected: nq, + eventCodeLog: [], + testSafe: true + }); }); }); }); diff --git a/tests/test-common.js b/tests/test-common.js index 8d86fdbe..093b2355 100644 --- a/tests/test-common.js +++ b/tests/test-common.js @@ -62,6 +62,12 @@ const TEST_TYPES = { // NOTE: idRegex format: //MMM-manifest#tNNN$/, idRegex: [ + // spec issues + // Unclear how to handle {"@id": null} edge case + // See https://github.com/w3c/json-ld-api/issues/480 + // non-normative test, also see toRdf-manifest#te122 + ///expand-manifest#t0122$/, + // misc /expand-manifest#tc037$/, /expand-manifest#tc038$/, @@ -187,6 +193,12 @@ const TEST_TYPES = { // NOTE: idRegex format: //MMM-manifest#tNNN$/, idRegex: [ + // spec issues + // Unclear how to handle {"@id": null} edge case + // See https://github.com/w3c/json-ld-api/issues/480 + // normative test, also see expand-manifest#t0122 + ///toRdf-manifest#te122$/, + // misc /toRdf-manifest#tc037$/, /toRdf-manifest#tc038$/, @@ -520,6 +532,8 @@ function addTest(manifest, test, tests) { } const testOptions = getJsonLdValues(test, 'option'); + // allow special handling in case of normative test failures + let normativeTest = true; testOptions.forEach(function(opt) { const processingModes = getJsonLdValues(opt, 'processingMode'); @@ -555,6 +569,13 @@ function addTest(manifest, test, tests) { }); }); + testOptions.forEach(function(opt) { + const normative = getJsonLdValues(opt, 'normative'); + normative.forEach(function(n) { + normativeTest = normativeTest && n; + }); + }); + const fn = testInfo.fn; const params = testInfo.params.map(param => param(test)); // resolve test data @@ -608,6 +629,16 @@ function addTest(manifest, test, tests) { }); } } catch(err) { + // FIXME: improve handling of non-normative errors + // FIXME: for now, explicitly disabling tests. + //if(!normativeTest) { + // // failure ok + // if(options.verboseSkip) { + // console.log('Skipping non-normative test due to failure:', + // {id: test['@id'], name: test.name}); + // } + // self.skip(); + //} if(options.bailOnError) { if(err.name !== 'AssertionError') { console.error('\nError: ', JSON.stringify(err, null, 2));