diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd43fbf..5039de4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # jsonld ChangeLog +### Fixed +- Support recusrive scoped contexts. +- Various EARL report updates. + +### Changed +- Better support for using a processed context for `null` and caching + `@import`. +- Don't set `@base` in initial context and don't resolve a relative IRI + when setting `@base` in a context, so that the document location can + be kept separate from the context itself. + ## 3.0.1 - 2020-03-10 ### Fixed diff --git a/lib/ContextResolver.js b/lib/ContextResolver.js index 9f29a8f4..e70ba98a 100644 --- a/lib/ContextResolver.js +++ b/lib/ContextResolver.js @@ -8,6 +8,9 @@ const { isObject: _isObject, isString: _isString, } = require('./types'); +const { + asArray: _asArray +} = require('./util'); const {prependBase} = require('./url'); const JsonLdError = require('./JsonLdError'); const ResolvedContext = require('./ResolvedContext'); @@ -25,16 +28,16 @@ module.exports = class ContextResolver { this.sharedCache = sharedCache; } - async resolve({context, documentLoader, base, cycles = new Set()}) { + async resolve({ + activeCtx, context, documentLoader, base, cycles = new Set() + }) { // process `@context` if(context && _isObject(context) && context['@context']) { context = context['@context']; } // context is one or more contexts - if(!_isArray(context)) { - context = [context]; - } + context = _asArray(context); // resolve each context in the array const allResolved = []; @@ -45,7 +48,7 @@ module.exports = class ContextResolver { if(!resolved) { // not resolved yet, resolve resolved = await this._resolveRemoteContext( - {url: ctx, documentLoader, base, cycles}); + {activeCtx, url: ctx, documentLoader, base, cycles}); } // add to output and continue @@ -108,11 +111,11 @@ module.exports = class ContextResolver { return resolved; } - async _resolveRemoteContext({url, documentLoader, base, cycles}) { + async _resolveRemoteContext({activeCtx, url, documentLoader, base, cycles}) { // resolve relative URL and fetch context url = prependBase(base, url); const {context, remoteDoc} = await this._fetchContext( - {url, documentLoader, cycles}); + {activeCtx, url, documentLoader, cycles}); // update base according to remote document and resolve any relative URLs base = remoteDoc.documentUrl || url; @@ -120,26 +123,37 @@ module.exports = class ContextResolver { // resolve, cache, and return context const resolved = await this.resolve( - {context, documentLoader, base, cycles}); + {activeCtx, context, documentLoader, base, cycles}); this._cacheResolvedContext({key: url, resolved, tag: remoteDoc.tag}); return resolved; } - async _fetchContext({url, documentLoader, cycles}) { + async _fetchContext({activeCtx, url, documentLoader, cycles}) { // check for max context URLs fetched during a resolve operation if(cycles.size > MAX_CONTEXT_URLS) { throw new JsonLdError( 'Maximum number of @context URLs exceeded.', 'jsonld.ContextUrlError', - {code: 'loading remote context failed', max: MAX_CONTEXT_URLS}); + { + code: activeCtx.processingMode === 'json-ld-1.0' ? + 'loading remote context failed' : + 'context overflow', + max: MAX_CONTEXT_URLS + }); } // check for context URL cycle + // shortcut to avoid extra work that would eventually hit the max above if(cycles.has(url)) { throw new JsonLdError( 'Cyclical @context URLs detected.', 'jsonld.ContextUrlError', - {code: 'recursive context inclusion', url}); + { + code: activeCtx.processingMode === 'json-ld-1.0' ? + 'recursive context inclusion' : + 'context overflow', + url + }); } // track cycles diff --git a/lib/compact.js b/lib/compact.js index 994a4ba5..1bcdb4bb 100644 --- a/lib/compact.js +++ b/lib/compact.js @@ -29,7 +29,8 @@ const { } = require('./context'); const { - removeBase: _removeBase + removeBase: _removeBase, + prependBase: _prependBase } = require('./url'); const { @@ -226,7 +227,8 @@ api.compact = async ({ expandedIri => api.compactIri({ activeCtx, iri: expandedIri, - relativeTo: {vocab: false} + relativeTo: {vocab: false}, + base: options.base })); if(compactedValue.length === 1) { compactedValue = compactedValue[0]; @@ -485,7 +487,8 @@ api.compact = async ({ // index on @id or @index or alias of @none const key = (container.includes('@id') ? expandedItem['@id'] : expandedItem['@index']) || - api.compactIri({activeCtx, iri: '@none', vocab: true}); + api.compactIri({activeCtx, iri: '@none', + relativeTo: {vocab: true}}); // add compactedItem to map, using value of `@id` or a new blank // node identifier @@ -570,7 +573,7 @@ api.compact = async ({ const indexKey = _getContextValue( activeCtx, itemActiveProperty, '@index') || '@index'; const containerKey = api.compactIri( - {activeCtx, iri: indexKey, vocab: true}); + {activeCtx, iri: indexKey, relativeTo: {vocab: true}}); if(indexKey === '@index') { key = expandedItem['@index']; delete compactedItem[containerKey]; @@ -595,14 +598,15 @@ api.compact = async ({ } } } else if(container.includes('@id')) { - const idKey = api.compactIri({activeCtx, iri: '@id', vocab: true}); + const idKey = api.compactIri({activeCtx, iri: '@id', + relativeTo: {vocab: true}}); key = compactedItem[idKey]; delete compactedItem[idKey]; } else if(container.includes('@type')) { const typeKey = api.compactIri({ activeCtx, iri: '@type', - vocab: true + relativeTo: {vocab: true} }); let types; [key, ...types] = _asArray(compactedItem[typeKey] || []); @@ -634,7 +638,8 @@ api.compact = async ({ // if compacting this value which has no key, index on @none if(!key) { - key = api.compactIri({activeCtx, iri: '@none', vocab: true}); + key = api.compactIri({activeCtx, iri: '@none', + relativeTo: {vocab: true}}); } // add compact value to map object using key from expanded value // based on the container type @@ -676,6 +681,7 @@ api.compact = async ({ * @param relativeTo options for how to compact IRIs: * vocab: true to split after @vocab, false not to. * @param reverse true if a reverse property is being compacted, false if not. + * @param base the absolute URL to use for compacting document-relative IRIs. * * @return the compacted term, prefix, keyword alias, or the original IRI. */ @@ -684,7 +690,8 @@ api.compactIri = ({ iri, value = null, relativeTo = {vocab: false}, - reverse = false + reverse = false, + base = null }) => { // can't compact null if(iri === null) { @@ -933,7 +940,16 @@ api.compactIri = ({ // compact IRI relative to base if(!relativeTo.vocab) { - return _removeBase(activeCtx['@base'], iri); + if('@base' in activeCtx) { + if(!activeCtx['@base']) { + // The None case preserves rval as potentially relative + return iri; + } else { + return _removeBase(_prependBase(base, activeCtx['@base']), iri); + } + } else { + return _removeBase(base, iri); + } } // return IRI as is @@ -1050,8 +1066,11 @@ api.compactValue = ({activeCtx, activeProperty, value, options}) => { const expandedProperty = _expandIri(activeCtx, activeProperty, {vocab: true}, options); const type = _getContextValue(activeCtx, activeProperty, '@type'); - const compacted = api.compactIri( - {activeCtx, iri: value['@id'], relativeTo: {vocab: type === '@vocab'}}); + const compacted = api.compactIri({ + activeCtx, + iri: value['@id'], + relativeTo: {vocab: type === '@vocab'}, + base: options.base}); // compact to scalar if(type === '@id' || type === '@vocab' || expandedProperty === '@graph') { diff --git a/lib/context.js b/lib/context.js index d83d8daa..672b80fa 100644 --- a/lib/context.js +++ b/lib/context.js @@ -47,7 +47,8 @@ module.exports = api; api.process = async ({ activeCtx, localCtx, options, propagate = true, - overrideProtected = false + overrideProtected = false, + cycles = new Set() }) => { // normalize local context to an array of @context objects if(_isObject(localCtx) && '@context' in localCtx && @@ -63,6 +64,7 @@ api.process = async ({ // resolve contexts const resolved = await options.contextResolver.resolve({ + activeCtx, context: localCtx, documentLoader: options.documentLoader, base: options.base @@ -109,6 +111,14 @@ api.process = async ({ } 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(); @@ -191,12 +201,10 @@ api.process = async ({ if('@base' in ctx) { let base = ctx['@base']; - if(base === null) { + if(base === null || _isAbsoluteIri(base)) { // no action - } else if(_isAbsoluteIri(base)) { - base = parseUrl(base); } else if(_isRelativeIri(base)) { - base = parseUrl(prependBase(rval['@base'].href, base)); + base = prependBase(rval['@base'], base); } else { throw new JsonLdError( 'Invalid JSON-LD syntax; the value of "@base" in a ' + @@ -310,6 +318,7 @@ api.process = async ({ // resolve contexts const resolvedImport = await options.contextResolver.resolve({ + activeCtx, context: value, documentLoader: options.documentLoader, base: options.base @@ -320,19 +329,34 @@ api.process = async ({ 'jsonld.SyntaxError', {code: 'invalid remote context', context: localCtx}); } - const importCtx = resolvedImport[0].document; - if('@import' in importCtx) { - throw new JsonLdError( - 'Invalid JSON-LD syntax; imported context must not include @import.', - 'jsonld.SyntaxError', - {code: 'invalid context entry', context: localCtx}); - } + const processedImport = resolvedImport[0].getProcessed(activeCtx); + if(processedImport) { + // Note: if the same context were used in this active context + // as a reference context, then processed_input might not + // be a dict. + ctx = processedImport; + } else { + const importCtx = resolvedImport[0].document; + if('@import' in importCtx) { + throw new JsonLdError( + 'Invalid JSON-LD syntax: ' + + 'imported context must not include @import.', + 'jsonld.SyntaxError', + {code: 'invalid context entry', context: localCtx}); + } - // merge ctx into importCtx and replace rval with the result - for(const key in importCtx) { - if(!ctx.hasOwnProperty(key)) { - ctx[key] = importCtx[key]; + // merge ctx into importCtx and replace rval with the result + for(const key in importCtx) { + if(!ctx.hasOwnProperty(key)) { + ctx[key] = importCtx[key]; + } } + + // Note: this could potenially conflict if the import + // were used in the same active context as a referenced + // context and an import. In this case, we + // could override the cached result, but seems unlikely. + resolvedImport[0].setProcessed(activeCtx, ctx); } defined.set('@import', true); @@ -355,23 +379,37 @@ api.process = async ({ }); if(_isObject(ctx[key]) && '@context' in ctx[key]) { + const keyCtx = ctx[key]['@context']; + let process = true; + if(_isString(keyCtx)) { + const url = prependBase(options.base, keyCtx); + // track processed contexts to avoid scoped context recursion + if(cycles.has(url)) { + process = false; + } else { + cycles.add(url); + } + } // parse context to validate - try { - await api.process({ - activeCtx: rval, - localCtx: ctx[key]['@context'], - overrideProtected: true, - options - }); - } catch(e) { - throw new JsonLdError( - 'Invalid JSON-LD syntax; invalid scoped context.', - 'jsonld.SyntaxError', - { - code: 'invalid scoped context', - context: ctx[key]['@context'], - term: key + if(process) { + try { + await api.process({ + activeCtx: rval, + localCtx: ctx[key]['@context'], + overrideProtected: true, + options, + cycles }); + } catch(e) { + throw new JsonLdError( + 'Invalid JSON-LD syntax; invalid scoped context.', + 'jsonld.SyntaxError', + { + code: 'invalid scoped context', + context: ctx[key]['@context'], + term: key + }); + } } } } @@ -1009,8 +1047,13 @@ function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) { } // prepend base - if(relativeTo.base) { - return prependBase(activeCtx['@base'], value); + if(relativeTo.base && '@base' in activeCtx) { + if(activeCtx['@base']) { + // The null case preserves value as potentially relative + return prependBase(prependBase(options.base, activeCtx['@base']), value); + } + } else if(relativeTo.base) { + return prependBase(options.base, value); } return value; @@ -1025,15 +1068,13 @@ function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) { * @return the initial context. */ api.getInitialContext = options => { - const base = parseUrl(options.base || ''); - const key = JSON.stringify({base, processingMode: options.processingMode}); + const key = JSON.stringify({processingMode: options.processingMode}); const cached = INITIAL_CONTEXT_CACHE.get(key); if(cached) { return cached; } const initialContext = { - '@base': base, processingMode: options.processingMode, mappings: new Map(), inverse: null, @@ -1239,7 +1280,6 @@ api.getInitialContext = options => { */ function _cloneActiveContext() { const child = {}; - child['@base'] = this['@base']; child.mappings = util.clone(this.mappings); child.clone = this.clone; child.inverse = null; @@ -1249,6 +1289,9 @@ api.getInitialContext = options => { child.previousContext = this.previousContext.clone(); } child.revertToPreviousContext = this.revertToPreviousContext; + if('@base' in this) { + child['@base'] = this['@base']; + } if('@language' in this) { child['@language'] = this['@language']; } diff --git a/lib/url.js b/lib/url.js index 07741914..98e2b848 100644 --- a/lib/url.js +++ b/lib/url.js @@ -71,7 +71,7 @@ api.prependBase = (base, iri) => { } // parse base if it is a string - if(types.isString(base)) { + if(!base || types.isString(base)) { base = api.parse(base || ''); } @@ -158,7 +158,7 @@ api.removeBase = (base, iri) => { return iri; } - if(types.isString(base)) { + if(!base || types.isString(base)) { base = api.parse(base || ''); } diff --git a/tests/earl-report.js b/tests/earl-report.js index ab3cffd4..fbdaa403 100644 --- a/tests/earl-report.js +++ b/tests/earl-report.js @@ -36,7 +36,8 @@ function EarlReport(options) { 'earl:mode': {'@type': '@id'}, 'earl:test': {'@type': '@id'}, 'earl:outcome': {'@type': '@id'}, - 'dc:date': {'@type': 'xsd:date'} + 'dc:date': {'@type': 'xsd:dateTime'}, + 'doap:created': {'@type': 'xsd:date'} }, '@id': 'https://github.com/digitalbazaar/jsonld.js', '@type': [ @@ -61,16 +62,21 @@ function EarlReport(options) { 'foaf:name': 'Dave Longley', 'foaf:homepage': 'https://github.com/dlongley' }, - 'dc:date': { - '@value': today, - '@type': 'xsd:date' + 'doap:release': { + 'doap:name': '', + 'doap:revision': '', + 'doap:created': today }, 'subjectOf': [] }; /* eslint-enable quote-props */ + // FIXME: read this from somewhere + version = 'v3.0.1' this._report['@id'] += '#' + this.id; this._report['doap:name'] += ' ' + this.id; this._report['dc:title'] += ' ' + this.id; + this._report['doap:release']['doap:name'] = this._report['doap:name'] + ' ' + version; + this._report['doap:release']['doap:revision'] = version; } EarlReport.prototype.addAssertion = function(test, pass) { diff --git a/tests/test-common.js b/tests/test-common.js index 3a854fe7..b51c7555 100644 --- a/tests/test-common.js +++ b/tests/test-common.js @@ -33,13 +33,13 @@ const TEST_TYPES = { specVersion: ['json-ld-1.0'], // FIXME // NOTE: idRegex format: - //MMM-manifest.jsonld#tNNN$/, + //MMM-manifest#tNNN$/, idRegex: [ // html - /html-manifest.jsonld#tc001$/, - /html-manifest.jsonld#tc002$/, - /html-manifest.jsonld#tc003$/, - /html-manifest.jsonld#tc004$/, + /html-manifest#tc001$/, + /html-manifest#tc002$/, + /html-manifest#tc003$/, + /html-manifest#tc004$/, ] }, fn: 'compact', @@ -57,38 +57,41 @@ const TEST_TYPES = { specVersion: ['json-ld-1.0'], // FIXME // NOTE: idRegex format: - //MMM-manifest.jsonld#tNNN$/, + //MMM-manifest#tNNN$/, idRegex: [ + // IRI resolution (PR #384) + /expand-manifest#t0129$/, + // html - /html-manifest.jsonld#te001$/, - /html-manifest.jsonld#te002$/, - /html-manifest.jsonld#te003$/, - /html-manifest.jsonld#te004$/, - /html-manifest.jsonld#te005$/, - /html-manifest.jsonld#te006$/, - /html-manifest.jsonld#te007$/, - /html-manifest.jsonld#te010$/, - /html-manifest.jsonld#te011$/, - /html-manifest.jsonld#te012$/, - /html-manifest.jsonld#te013$/, - /html-manifest.jsonld#te014$/, - /html-manifest.jsonld#te015$/, - /html-manifest.jsonld#te016$/, - /html-manifest.jsonld#te017$/, - /html-manifest.jsonld#te018$/, - /html-manifest.jsonld#te019$/, - /html-manifest.jsonld#te020$/, - /html-manifest.jsonld#te021$/, - /html-manifest.jsonld#te022$/, - /html-manifest.jsonld#tex01$/, + /html-manifest#te001$/, + /html-manifest#te002$/, + /html-manifest#te003$/, + /html-manifest#te004$/, + /html-manifest#te005$/, + /html-manifest#te006$/, + /html-manifest#te007$/, + /html-manifest#te010$/, + /html-manifest#te011$/, + /html-manifest#te012$/, + /html-manifest#te013$/, + /html-manifest#te014$/, + /html-manifest#te015$/, + /html-manifest#te016$/, + /html-manifest#te017$/, + /html-manifest#te018$/, + /html-manifest#te019$/, + /html-manifest#te020$/, + /html-manifest#te021$/, + /html-manifest#te022$/, + /html-manifest#tex01$/, // HTML extraction - /expand-manifest.jsonld#thc01$/, - /expand-manifest.jsonld#thc02$/, - /expand-manifest.jsonld#thc03$/, - /expand-manifest.jsonld#thc04$/, - /expand-manifest.jsonld#thc05$/, + /expand-manifest#thc01$/, + /expand-manifest#thc02$/, + /expand-manifest#thc03$/, + /expand-manifest#thc04$/, + /expand-manifest#thc05$/, // remote - /remote-doc-manifest.jsonld#t0013$/, // HTML + /remote-doc-manifest#t0013$/, // HTML ] }, fn: 'expand', @@ -105,13 +108,13 @@ const TEST_TYPES = { specVersion: ['json-ld-1.0'], // FIXME // NOTE: idRegex format: - //MMM-manifest.jsonld#tNNN$/, + //MMM-manifest#tNNN$/, idRegex: [ // html - /html-manifest.jsonld#tf001$/, - /html-manifest.jsonld#tf002$/, - /html-manifest.jsonld#tf003$/, - /html-manifest.jsonld#tf004$/, + /html-manifest#tf001$/, + /html-manifest#tf002$/, + /html-manifest#tf003$/, + /html-manifest#tf004$/, ] }, fn: 'flatten', @@ -129,7 +132,7 @@ const TEST_TYPES = { specVersion: ['json-ld-1.0'], // FIXME // NOTE: idRegex format: - //MMM-manifest.jsonld#tNNN$/, + //MMM-manifest#tNNN$/, idRegex: [ ] }, @@ -148,11 +151,11 @@ const TEST_TYPES = { specVersion: ['json-ld-1.0'], // FIXME // NOTE: idRegex format: - //MMM-manifest.jsonld#tNNN$/, + //MMM-manifest#tNNN$/, idRegex: [ // direction (compound-literal) - /fromRdf-manifest.jsonld#tdi11$/, - /fromRdf-manifest.jsonld#tdi12$/, + /fromRdf-manifest#tdi11$/, + /fromRdf-manifest#tdi12$/, ] }, fn: 'fromRDF', @@ -177,38 +180,42 @@ const TEST_TYPES = { specVersion: ['json-ld-1.0'], // FIXME // NOTE: idRegex format: - //MMM-manifest.jsonld#tNNN$/, + //MMM-manifest#tNNN$/, idRegex: [ + // IRI resolution (PR #384) + /toRdf-manifest#te129$/, + // well formed - /toRdf-manifest.jsonld#twf05$/, + /toRdf-manifest#twf05$/, + // html - /html-manifest.jsonld#tr001$/, - /html-manifest.jsonld#tr002$/, - /html-manifest.jsonld#tr003$/, - /html-manifest.jsonld#tr004$/, - /html-manifest.jsonld#tr005$/, - /html-manifest.jsonld#tr006$/, - /html-manifest.jsonld#tr007$/, - /html-manifest.jsonld#tr010$/, - /html-manifest.jsonld#tr011$/, - /html-manifest.jsonld#tr012$/, - /html-manifest.jsonld#tr013$/, - /html-manifest.jsonld#tr014$/, - /html-manifest.jsonld#tr015$/, - /html-manifest.jsonld#tr016$/, - /html-manifest.jsonld#tr017$/, - /html-manifest.jsonld#tr018$/, - /html-manifest.jsonld#tr019$/, - /html-manifest.jsonld#tr020$/, - /html-manifest.jsonld#tr021$/, - /html-manifest.jsonld#tr022$/, + /html-manifest#tr001$/, + /html-manifest#tr002$/, + /html-manifest#tr003$/, + /html-manifest#tr004$/, + /html-manifest#tr005$/, + /html-manifest#tr006$/, + /html-manifest#tr007$/, + /html-manifest#tr010$/, + /html-manifest#tr011$/, + /html-manifest#tr012$/, + /html-manifest#tr013$/, + /html-manifest#tr014$/, + /html-manifest#tr015$/, + /html-manifest#tr016$/, + /html-manifest#tr017$/, + /html-manifest#tr018$/, + /html-manifest#tr019$/, + /html-manifest#tr020$/, + /html-manifest#tr021$/, + /html-manifest#tr022$/, // Invalid Statement - /toRdf-manifest.jsonld#te075$/, - /toRdf-manifest.jsonld#te111$/, - /toRdf-manifest.jsonld#te112$/, + /toRdf-manifest#te075$/, + /toRdf-manifest#te111$/, + /toRdf-manifest#te112$/, // direction (compound-literal) - /toRdf-manifest.jsonld#tdi11$/, - /toRdf-manifest.jsonld#tdi12$/, + /toRdf-manifest#tdi11$/, + /toRdf-manifest#tdi12$/, ] }, fn: 'toRDF', @@ -387,7 +394,7 @@ function addTest(manifest, test, tests) { // expand @id and input base const test_id = test['@id'] || test['id']; //var number = test_id.substr(2); - test['@id'] = manifest.baseIri + basename(manifest.filename) + test_id; + test['@id'] = manifest.baseIri + basename(manifest.filename).replace('.jsonld', '') + test_id; test.base = manifest.baseIri + test.input; test.manifest = manifest; const description = test_id + ' ' + (test.purpose || test.name); @@ -786,13 +793,15 @@ async function compareExpectedError(test, err) { result = getJsonLdErrorCode(err); assert.ok(err, 'no error present'); assert.strictEqual(result, expect); - } catch(err) { + } catch(_err) { if(options.bailOnError) { console.log('\nTEST FAILED\n'); console.log('EXPECTED: ' + expect); console.log('ACTUAL: ' + result); } - throw err; + // log the unexpected error to help with debugging + console.log('Unexpected error:', err); + throw _err; } }