Skip to content

Commit 185ff74

Browse files
dlongleydavidlehn
authored andcommitted
Implement just-in-time context resolution.
- Add optional `tag` feature processing to returned RemoteDocuments. A `tag` will be understood to mean that the same context document needn't be processed twice. A special `tag` value of `static` is interpreted to mean that a context does not even need to be retrieved more than once. - Enables greater reuse of already processed contexts and quicker discovery (via `static` tag) of already processed contexts for a given context URL (instead of requiring the context content itself to be seached for and found in a cache, only its URL needs to be found). - Addresses #339.
1 parent afb0640 commit 185ff74

File tree

6 files changed

+355
-297
lines changed

6 files changed

+355
-297
lines changed

lib/ActiveContextCache.js

Lines changed: 0 additions & 44 deletions
This file was deleted.

lib/ContextResolver.js

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*
2+
* Copyright (c) 2019 Digital Bazaar, Inc. All rights reserved.
3+
*/
4+
'use strict';
5+
6+
const {
7+
isArray: _isArray,
8+
isObject: _isObject,
9+
isString: _isString,
10+
} = require('./types');
11+
const {prependBase} = require('./url');
12+
const JsonLdError = require('./JsonLdError');
13+
const ResolvedContext = require('./ResolvedContext');
14+
15+
const MAX_CONTEXT_URLS = 10;
16+
17+
module.exports = class ContextResolver {
18+
/**
19+
* Creates a ContextResolver.
20+
*
21+
* @param sharedCache a shared LRU cache with `get` and `set` APIs.
22+
*/
23+
constructor({sharedCache}) {
24+
this.perOpCache = new Map();
25+
this.sharedCache = sharedCache;
26+
}
27+
28+
async resolve({context, documentLoader, base, cycles = new Set()}) {
29+
// process `@context`
30+
if(context && _isObject(context) && context['@context']) {
31+
context = context['@context'];
32+
}
33+
34+
// context is one or more contexts
35+
if(!_isArray(context)) {
36+
context = [context];
37+
}
38+
39+
// resolve each context in the array
40+
const allResolved = [];
41+
for(const ctx of context) {
42+
if(_isString(ctx)) {
43+
// see if `ctx` has been resolved before...
44+
let resolved = this._get(ctx);
45+
if(!resolved) {
46+
// not resolved yet, resolve
47+
resolved = await this._resolveRemoteContext(
48+
{url: ctx, documentLoader, base, cycles});
49+
}
50+
51+
// add to output and continue
52+
if(_isArray(resolved)) {
53+
allResolved.push(...resolved);
54+
} else {
55+
allResolved.push(resolved);
56+
}
57+
continue;
58+
}
59+
if(ctx === null) {
60+
// handle `null` context, nothing to cache
61+
allResolved.push(new ResolvedContext({document: null}));
62+
continue;
63+
}
64+
if(!_isObject(ctx)) {
65+
_throwInvalidLocalContext(context);
66+
}
67+
// context is an object, get/create `ResolvedContext` for it
68+
const key = JSON.stringify(ctx);
69+
let resolved = this._get(key);
70+
if(!resolved) {
71+
// create a new static `ResolvedContext` and cache it
72+
resolved = new ResolvedContext({document: ctx});
73+
this._cacheResolvedContext({key, resolved, tag: 'static'});
74+
}
75+
allResolved.push(resolved);
76+
}
77+
78+
return allResolved;
79+
}
80+
81+
_get(key) {
82+
// get key from per operation cache; no `tag` is used with this cache so
83+
// any retrieved context will always be the same during a single operation
84+
let resolved = this.perOpCache.get(key);
85+
if(!resolved) {
86+
// see if the shared cache has a `static` entry for this URL
87+
const tagMap = this.sharedCache.get(key);
88+
if(tagMap) {
89+
resolved = tagMap.get('static');
90+
if(resolved) {
91+
this.perOpCache.set(key, resolved);
92+
}
93+
}
94+
}
95+
return resolved;
96+
}
97+
98+
_cacheResolvedContext({key, resolved, tag}) {
99+
this.perOpCache.set(key, resolved);
100+
if(tag !== undefined) {
101+
let tagMap = this.sharedCache.get(key);
102+
if(!tagMap) {
103+
tagMap = new Map();
104+
this.sharedCache.set(key, tagMap);
105+
}
106+
tagMap.set(tag, resolved);
107+
}
108+
return resolved;
109+
}
110+
111+
async _resolveRemoteContext({url, documentLoader, base, cycles}) {
112+
// resolve relative URL and fetch context
113+
url = prependBase(base, url);
114+
const {context, remoteDoc} = await this._fetchContext(
115+
{url, documentLoader, cycles});
116+
117+
// update base according to remote document and resolve any relative URLs
118+
base = remoteDoc.documentUrl || url;
119+
_resolveContextUrls({context, base});
120+
121+
// resolve, cache, and return context
122+
const resolved = await this.resolve(
123+
{context, documentLoader, base, cycles});
124+
this._cacheResolvedContext({key: url, resolved, tag: remoteDoc.tag});
125+
return resolved;
126+
}
127+
128+
async _fetchContext({url, documentLoader, cycles}) {
129+
// check for max context URLs fetched during a resolve operation
130+
if(cycles.size > MAX_CONTEXT_URLS) {
131+
throw new JsonLdError(
132+
'Maximum number of @context URLs exceeded.',
133+
'jsonld.ContextUrlError',
134+
{code: 'loading remote context failed', max: MAX_CONTEXT_URLS});
135+
}
136+
137+
// check for context URL cycle
138+
if(cycles.has(url)) {
139+
throw new JsonLdError(
140+
'Cyclical @context URLs detected.',
141+
'jsonld.ContextUrlError',
142+
{code: 'recursive context inclusion', url});
143+
}
144+
145+
// track cycles
146+
cycles.add(url);
147+
148+
let context;
149+
let remoteDoc;
150+
151+
try {
152+
remoteDoc = await documentLoader(url);
153+
context = remoteDoc.document || null;
154+
// parse string context as JSON
155+
if(_isString(context)) {
156+
context = JSON.parse(context);
157+
}
158+
} catch(e) {
159+
throw new JsonLdError(
160+
'Dereferencing a URL did not result in a valid JSON-LD object. ' +
161+
'Possible causes are an inaccessible URL perhaps due to ' +
162+
'a same-origin policy (ensure the server uses CORS if you are ' +
163+
'using client-side JavaScript), too many redirects, a ' +
164+
'non-JSON response, or more than one HTTP Link Header was ' +
165+
'provided for a remote context.',
166+
'jsonld.InvalidUrl',
167+
{code: 'loading remote context failed', url, cause: e});
168+
}
169+
170+
// ensure ctx is an object
171+
if(!_isObject(context)) {
172+
throw new JsonLdError(
173+
'Dereferencing a URL did not result in a JSON object. The ' +
174+
'response was valid JSON, but it was not a JSON object.',
175+
'jsonld.InvalidUrl', {code: 'invalid remote context', url});
176+
}
177+
178+
// use empty context if no @context key is present
179+
if(!('@context' in context)) {
180+
context = {'@context': {}};
181+
} else {
182+
context = {'@context': context['@context']};
183+
}
184+
185+
// append @context URL to context if given
186+
if(remoteDoc.contextUrl) {
187+
if(!_isArray(context['@context'])) {
188+
context['@context'] = [context['@context']];
189+
}
190+
context['@context'].push(remoteDoc.contextUrl);
191+
}
192+
193+
return {context, remoteDoc};
194+
}
195+
};
196+
197+
function _throwInvalidLocalContext(ctx) {
198+
throw new JsonLdError(
199+
'Invalid JSON-LD syntax; @context must be an object.',
200+
'jsonld.SyntaxError', {
201+
code: 'invalid local context', context: ctx
202+
});
203+
}
204+
205+
/**
206+
* Resolve all relative `@context` URLs in the given context by inline
207+
* replacing them with absolute URLs.
208+
*
209+
* @param context the context.
210+
* @param base the base IRI to use to resolve relative IRIs.
211+
*/
212+
function _resolveContextUrls({context, base}) {
213+
const ctx = context['@context'];
214+
215+
if(_isString(ctx)) {
216+
context['@context'] = prependBase(base, ctx);
217+
return;
218+
}
219+
220+
if(_isArray(ctx)) {
221+
for(let i = 0; i < ctx.length; ++i) {
222+
const element = ctx[i];
223+
if(_isString(element)) {
224+
ctx[i] = prependBase(base, element);
225+
continue;
226+
}
227+
if(_isObject(element)) {
228+
_resolveContextUrls({context: {'@context': element}, base});
229+
}
230+
}
231+
return;
232+
}
233+
234+
if(!_isObject(ctx)) {
235+
// no @context URLs can be found in non-object
236+
return;
237+
}
238+
239+
// ctx is an object, resolve any context URLs in terms
240+
for(const term in ctx) {
241+
_resolveContextUrls({context: ctx[term], base});
242+
}
243+
}

lib/ResolvedContext.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright (c) 2019 Digital Bazaar, Inc. All rights reserved.
3+
*/
4+
'use strict';
5+
6+
const LRU = require('lru-cache');
7+
8+
const MAX_ACTIVE_CONTEXTS = 10;
9+
10+
module.exports = class ResolvedContext {
11+
/**
12+
* Creates a ResolvedContext.
13+
*
14+
* @param document the context document.
15+
*/
16+
constructor({document}) {
17+
this.document = document;
18+
// TODO: enable customization of processed context cache
19+
// TODO: limit based on size of processed contexts vs. number of them
20+
this.cache = new LRU({max: MAX_ACTIVE_CONTEXTS});
21+
}
22+
23+
getProcessed(activeCtx) {
24+
return this.cache.get(activeCtx);
25+
}
26+
27+
setProcessed(activeCtx, processedCtx) {
28+
this.cache.set(activeCtx, processedCtx);
29+
}
30+
};

0 commit comments

Comments
 (0)