/* * Copyright (c) 2019 Digital Bazaar, Inc. All rights reserved. */ 'use strict'; const { isArray: _isArray, isObject: _isObject, isString: _isString, } = require('./types'); const { asArray: _asArray } = require('./util'); const {prependBase} = require('./url'); const JsonLdError = require('./JsonLdError'); const ResolvedContext = require('./ResolvedContext'); const MAX_CONTEXT_URLS = 10; module.exports = class ContextResolver { /** * Creates a ContextResolver. * * @param sharedCache a shared LRU cache with `get` and `set` APIs. */ constructor({sharedCache}) { this.perOpCache = new Map(); this.sharedCache = sharedCache; } 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 context = _asArray(context); // resolve each context in the array const allResolved = []; for(const ctx of context) { if(_isString(ctx)) { // see if `ctx` has been resolved before... let resolved = this._get(ctx); if(!resolved) { // not resolved yet, resolve resolved = await this._resolveRemoteContext( {activeCtx, url: ctx, documentLoader, base, cycles}); } // add to output and continue if(_isArray(resolved)) { allResolved.push(...resolved); } else { allResolved.push(resolved); } continue; } if(ctx === null) { // handle `null` context, nothing to cache allResolved.push(new ResolvedContext({document: null})); continue; } if(!_isObject(ctx)) { _throwInvalidLocalContext(context); } // context is an object, get/create `ResolvedContext` for it const key = JSON.stringify(ctx); let resolved = this._get(key); if(!resolved) { // create a new static `ResolvedContext` and cache it resolved = new ResolvedContext({document: ctx}); this._cacheResolvedContext({key, resolved, tag: 'static'}); } allResolved.push(resolved); } return allResolved; } _get(key) { // get key from per operation cache; no `tag` is used with this cache so // any retrieved context will always be the same during a single operation let resolved = this.perOpCache.get(key); if(!resolved) { // see if the shared cache has a `static` entry for this URL const tagMap = this.sharedCache.get(key); if(tagMap) { resolved = tagMap.get('static'); if(resolved) { this.perOpCache.set(key, resolved); } } } return resolved; } _cacheResolvedContext({key, resolved, tag}) { this.perOpCache.set(key, resolved); if(tag !== undefined) { let tagMap = this.sharedCache.get(key); if(!tagMap) { tagMap = new Map(); this.sharedCache.set(key, tagMap); } tagMap.set(tag, resolved); } return resolved; } async _resolveRemoteContext({activeCtx, url, documentLoader, base, cycles}) { // resolve relative URL and fetch context url = prependBase(base, url); const {context, remoteDoc} = await this._fetchContext( {activeCtx, url, documentLoader, cycles}); // update base according to remote document and resolve any relative URLs base = remoteDoc.documentUrl || url; _resolveContextUrls({context, base}); // resolve, cache, and return context const resolved = await this.resolve( {activeCtx, context, documentLoader, base, cycles}); this._cacheResolvedContext({key: url, resolved, tag: remoteDoc.tag}); return resolved; } 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: 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: activeCtx.processingMode === 'json-ld-1.0' ? 'recursive context inclusion' : 'context overflow', url }); } // track cycles cycles.add(url); let context; let remoteDoc; try { remoteDoc = await documentLoader(url); context = remoteDoc.document || null; // parse string context as JSON if(_isString(context)) { context = JSON.parse(context); } } catch(e) { throw new JsonLdError( 'Dereferencing a URL did not result in a valid JSON-LD object. ' + 'Possible causes are an inaccessible URL perhaps due to ' + 'a same-origin policy (ensure the server uses CORS if you are ' + 'using client-side JavaScript), too many redirects, a ' + 'non-JSON response, or more than one HTTP Link Header was ' + 'provided for a remote context.', 'jsonld.InvalidUrl', {code: 'loading remote context failed', url, cause: e}); } // ensure ctx is an object if(!_isObject(context)) { throw new JsonLdError( 'Dereferencing a URL did not result in a JSON object. The ' + 'response was valid JSON, but it was not a JSON object.', 'jsonld.InvalidUrl', {code: 'invalid remote context', url}); } // use empty context if no @context key is present if(!('@context' in context)) { context = {'@context': {}}; } else { context = {'@context': context['@context']}; } // append @context URL to context if given if(remoteDoc.contextUrl) { if(!_isArray(context['@context'])) { context['@context'] = [context['@context']]; } context['@context'].push(remoteDoc.contextUrl); } return {context, remoteDoc}; } }; function _throwInvalidLocalContext(ctx) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context must be an object.', 'jsonld.SyntaxError', { code: 'invalid local context', context: ctx }); } /** * Resolve all relative `@context` URLs in the given context by inline * replacing them with absolute URLs. * * @param context the context. * @param base the base IRI to use to resolve relative IRIs. */ function _resolveContextUrls({context, base}) { if(!context) { return; } const ctx = context['@context']; if(_isString(ctx)) { context['@context'] = prependBase(base, ctx); return; } if(_isArray(ctx)) { for(let i = 0; i < ctx.length; ++i) { const element = ctx[i]; if(_isString(element)) { ctx[i] = prependBase(base, element); continue; } if(_isObject(element)) { _resolveContextUrls({context: {'@context': element}, base}); } } return; } if(!_isObject(ctx)) { // no @context URLs can be found in non-object return; } // ctx is an object, resolve any context URLs in terms for(const term in ctx) { _resolveContextUrls({context: ctx[term], base}); } }