diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c7b4c5..46068ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,16 @@ - Support test environment in EARL output. - Support benchmark output in EARL output. - Benchmark comparison tool. +- Event handler option `"eventHandler"` to allow custom handling of warnings and + potentially other events in the future. Handles event replay for cached + contexts. ### Changed - Change EARL Assertor to Digital Bazaar, Inc. +### Removed +- Experimental non-standard `protectedMode` option. + ## 5.2.0 - 2021-04-07 ### Changed diff --git a/lib/context.js b/lib/context.js index 5f0de789..acece63e 100644 --- a/lib/context.js +++ b/lib/context.js @@ -19,6 +19,10 @@ const { prependBase } = require('./url'); +const { + handleEvent: _handleEvent +} = require('./events'); + const { asArray: _asArray, compareShortestLeast: _compareShortestLeast @@ -61,6 +65,23 @@ api.process = async ({ return activeCtx; } + // event handler for capturing events to replay when using a cached context + const events = []; + const eventHandler = [ + ({event, next}) => { + events.push(event); + next(); + } + ]; + // chain to original handler + if(options.eventHandler) { + eventHandler.push(options.eventHandler); + } + // store original options to use when replaying events + const originalOptions = options; + // shallow clone options with custom event handler + options = {...options, eventHandler}; + // resolve contexts const resolved = await options.contextResolver.resolve({ activeCtx, @@ -98,46 +119,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 +133,12 @@ api.process = async ({ // get processed context from cache if available const processed = resolvedContext.getProcessed(activeCtx); if(processed) { - rval = activeCtx = processed; + // replay events with original non-capturing options + for(const event of processed.events) { + _handleEvent({event, options: originalOptions}); + } + + rval = activeCtx = processed.context; continue; } @@ -414,7 +406,10 @@ api.process = async ({ } // cache processed result - resolvedContext.setProcessed(activeCtx, rval); + resolvedContext.setProcessed(activeCtx, { + context: rval, + events + }); } return rval; @@ -429,9 +424,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 = ({ @@ -482,9 +474,18 @@ api.createTermDefinition = ({ '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}); + _handleEvent({ + event: { + code: 'invalid reserved term', + level: 'warning', + message: + 'Terms beginning with "@" are reserved for future use and ignored.', + details: { + term + } + }, + options + }); return; } else if(term === '') { throw new JsonLdError( @@ -564,10 +565,20 @@ 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(KEYWORD_PATTERN)) { + _handleEvent({ + event: { + code: 'invalid reserved value', + level: 'warning', + message: + 'Values beginning with "@" are reserved for future use and' + + ' ignored.', + details: { + reverse + } + }, + options + }); if(previousMapping) { activeCtx.mappings.set(term, previousMapping); } else { @@ -601,9 +612,19 @@ api.createTermDefinition = ({ // 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}); + _handleEvent({ + event: { + code: 'invalid reserved value', + level: 'warning', + message: + 'Values beginning with "@" are reserved for future use and' + + ' ignored.', + details: { + id + } + }, + options + }); if(previousMapping) { activeCtx.mappings.set(term, previousMapping); } else { @@ -918,23 +939,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}); } } }; diff --git a/lib/events.js b/lib/events.js new file mode 100644 index 00000000..0061a9cd --- /dev/null +++ b/lib/events.js @@ -0,0 +1,92 @@ +/* + * 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; + +/** + * 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. + * + * The final default handler will use 'console.warn' for events of level + * 'warning'. + * + * @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 + */ +api.handleEvent = ({ + event, + options +}) => { + const handlers = [].concat( + options.eventHandler ? _asArray(options.eventHandler) : [], + _defaultHandler + ); + _handle({event, handlers}); +}; + +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; +} + +function _defaultHandler({event}) { + if(event.level === 'warning') { + console.warn(`WARNING: ${event.message}`, { + code: event.code, + details: event.details + }); + return; + } + // fallback to ensure events are handled somehow + throw new JsonLdError( + 'No handler for event.', + 'jsonld.UnhandledEvent', + {event}); +} diff --git a/lib/expand.js b/lib/expand.js index 737def7b..0f64aec3 100644 --- a/lib/expand.js +++ b/lib/expand.js @@ -39,6 +39,10 @@ const { 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})*$/; @@ -609,9 +613,19 @@ 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)) { + _handleEvent({ + event: { + code: 'invalid @language value', + level: 'warning', + message: '@language value must be valid BCP47.', + details: { + language + } + }, + options + }); } } diff --git a/lib/fromRdf.js b/lib/fromRdf.js index fb3567c8..509bde0b 100644 --- a/lib/fromRdf.js +++ b/lib/fromRdf.js @@ -8,6 +8,10 @@ const graphTypes = require('./graphTypes'); const types = require('./types'); const util = require('./util'); +const { + handleEvent: _handleEvent +} = require('./events'); + // constants const { // RDF, @@ -44,15 +48,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' @@ -87,7 +92,7 @@ api.fromRDF = async ( continue; } - const value = _RDFToObject(o, useNativeTypes, rdfDirection); + const value = _RDFToObject(o, useNativeTypes, rdfDirection, options); util.addValue(node, p, value, {propertyIsArray: true}); // object may be an RDF list/partial list node but we can't know easily @@ -275,10 +280,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}; @@ -334,7 +341,17 @@ 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}`); + _handleEvent({ + event: { + code: 'invalid @language value', + level: 'warning', + message: '@language value must be valid BCP47.', + details: { + language + } + }, + options + }); } } rval['@direction'] = direction; diff --git a/lib/jsonld.js b/lib/jsonld.js index ffd974cb..f45fc68c 100644 --- a/lib/jsonld.js +++ b/lib/jsonld.js @@ -119,6 +119,7 @@ const _resolvedContextCache = new LRU({max: RESOLVED_CONTEXT_CACHE_MAX_SIZE}); * unmappable values (or to throw an error when they are detected); * if this function returns `undefined` then the default behavior * will be used. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the compacted output. @@ -257,6 +258,7 @@ jsonld.compact = async function(input, ctx, options) { * unmappable values (or to throw an error when they are detected); * if this function returns `undefined` then the default behavior * will be used. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the expanded output. @@ -354,6 +356,7 @@ jsonld.expand = async function(input, options) { * [base] the base IRI to use. * [expandContext] a context to expand with. * [documentLoader(url, options)] the document loader. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the flattened output. @@ -409,6 +412,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. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the framed output. @@ -507,6 +511,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. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the linked output. @@ -542,6 +547,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 + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the normalized output. @@ -596,6 +602,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). + * [eventHandler] handler for events. * * @return a Promise that resolves to the JSON-LD document. */ @@ -645,6 +654,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. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the RDF dataset. @@ -698,6 +708,7 @@ jsonld.toRDF = async function(input, options) { * [expandContext] a context to expand with. * [issuer] a jsonld.IdentifierIssuer to use to label blank nodes. * [documentLoader(url, options)] the document loader. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the merged node map. @@ -737,6 +748,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. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the merged output. @@ -899,6 +911,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. + * [eventHandler] handler for events. * [contextResolver] internal use only. * * @return a Promise that resolves to the new active context. diff --git a/tests/misc.js b/tests/misc.js index d9dae4fc..0cb3e36f 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -1006,3 +1006,277 @@ describe('expansionMap', () => { }); }); }); + +describe('events', () => { + it('handle warning event with function', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-function-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + let handled = false; + const e = await jsonld.expand(d, { + eventHandler: ({event, next}) => { + if(event.code === 'invalid reserved term') { + handled = true; + } else { + next(); + } + } + }); + assert.deepStrictEqual(e, ex); + assert.equal(handled, true); + }); + it('cached context event replay', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test" + }, + "@RESERVED": "test" +} +; + const ex = []; + + let handled0 = false; + let handled1 = false; + const e0 = await jsonld.expand(d, { + eventHandler: { + 'invalid reserved term': () => { + handled0 = true; + } + } + }); + // FIXME: ensure cache is being used + const e1 = await jsonld.expand(d, { + eventHandler: { + 'invalid reserved term': () => { + handled1 = true; + } + } + }); + assert.deepStrictEqual(e0, ex); + assert.deepStrictEqual(e1, ex); + assert.equal(handled0, true, 'handled 0'); + assert.equal(handled1, true, 'handled 1'); + }); + it('handle warning event with array of functions', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-function-array-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + let ranHandler0 = false; + let ranHandler1 = false; + let handled = false; + const e = await jsonld.expand(d, { + eventHandler: [ + ({next}) => { + ranHandler0 = true; + // skip to next handler + next(); + }, + ({event, next}) => { + ranHandler1 = true; + if(event.code === 'invalid reserved term') { + handled = true; + return; + } + next(); + } + ] + }); + assert.deepStrictEqual(e, ex); + assert.equal(ranHandler0, true, 'ran handler 0'); + assert.equal(ranHandler1, true, 'ran handler 1'); + assert.equal(handled, true, 'handled'); + }); + it('handle warning event with code:function object', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-object-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + let handled = false; + const e = await jsonld.expand(d, { + eventHandler: { + 'invalid reserved term': ({event}) => { + assert.equal(event.details.term, '@RESERVED'); + handled = true; + } + } + }); + assert.deepStrictEqual(e, ex); + assert.equal(handled, true, 'handled'); + }); + it('handle warning event with complex handler', async () => { + const d = +{ + "@context": { + "@RESERVED": "ex:test-complex-handler" + }, + "@RESERVED": "test" +} +; + const ex = []; + + let ranHandler0 = false; + let ranHandler1 = false; + let ranHandler2 = false; + let ranHandler3 = false; + let handled = false; + const e = await jsonld.expand(d, { + eventHandler: [ + ({next}) => { + ranHandler0 = true; + next(); + }, + [ + ({next}) => { + ranHandler1 = true; + next(); + }, + { + 'bogus code': () => {} + } + ], + ({next}) => { + ranHandler2 = true; + next(); + }, + { + 'invalid reserved term': () => { + ranHandler3 = true; + handled = true; + } + } + ] + }); + assert.deepStrictEqual(e, ex); + assert.equal(ranHandler0, true, 'ran handler 0'); + assert.equal(ranHandler1, true, 'ran handler 1'); + assert.equal(ranHandler2, true, 'ran handler 2'); + assert.equal(ranHandler3, true, 'ran handler 3'); + assert.equal(handled, true, 'handled'); + }); + 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 handledReservedTerm = false; + let handledReservedValue = false; + let handledLanguage = false; + const e = await jsonld.expand(d, { + eventHandler: { + 'invalid reserved term': () => { + handledReservedTerm = true; + }, + 'invalid reserved value': () => { + handledReservedValue = true; + }, + 'invalid @language value': () => { + handledLanguage = true; + } + } + }); + assert.deepStrictEqual(e, ex); + assert.equal(handledReservedTerm, true); + assert.equal(handledReservedValue, true); + assert.equal(handledLanguage, true); + + // 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 handledLanguage2 = false; + const e2 = await jsonld.fromRDF(d2, { + rdfDirection: 'i18n-datatype', + eventHandler: { + 'invalid @language value': () => { + handledLanguage2 = true; + } + } + }); + assert.deepStrictEqual(e2, ex2); + assert.equal(handledLanguage2, true); + }); +});