Skip to content

Expand expansion map to support calling when a relative IRI is detected #452

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jun 16, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 92 additions & 9 deletions lib/context.js
Original file line number Diff line number Diff line change
@@ -1041,19 +1041,102 @@ function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) {
}
}

// prepend vocab
if(relativeTo.vocab && '@vocab' in activeCtx) {
return activeCtx['@vocab'] + value;
// A flag that captures whether the iri being expanded is
// the value for an @type
let typeExpansion = false;

if(options !== undefined && options.typeExpansion !== undefined) {
typeExpansion = options.typeExpansion;
}

// prepend base
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);
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,
},
activeCtx,
options
});

}
if(expansionMapResult !== undefined) {
value = expansionMapResult;
} else {
// the null case preserves value as potentially relative
value = prependedResult;
}
} else if(relativeTo.base) {
return prependBase(options.base, value);
// prepend base
let prependedResult;
let expansionMapResult;
let base;
if('@base' in activeCtx) {
if(activeCtx['@base']) {
base = prependBase(options.base, activeCtx['@base']);
prependedResult = prependBase(base, value);
} else {
base = activeCtx['@base'];
prependedResult = value;
}
} else {
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,
},
activeCtx,
options
});
}
if(expansionMapResult !== undefined) {
value = expansionMapResult;
} else {
// 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,
options
});
if(expandedResult !== undefined) {
value = expandedResult;
}
}

return value;
18 changes: 14 additions & 4 deletions lib/expand.js
Original file line number Diff line number Diff line change
@@ -75,6 +75,10 @@ api.expand = async ({
typeScopedContext = null,
expansionMap = () => undefined
}) => {

// add expansion map to the processing options
options = {...options, expansionMap};

// nothing to expand
if(element === null || element === undefined) {
return null;
@@ -420,11 +424,14 @@ async function _expandObject({
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) === '@json';
{vocab: true}, {...options, typeExpansion: true}) === '@json';

for(const key of keys) {
let value = element[key];
@@ -520,7 +527,8 @@ async function _expandObject({
value = Object.fromEntries(Object.entries(value).map(([k, v]) => [
_expandIri(typeScopedContext, k, {vocab: true}),
_asArray(v).map(vv =>
_expandIri(typeScopedContext, vv, {base: true, vocab: true})
_expandIri(typeScopedContext, vv, {base: true, vocab: true},
{...options, typeExpansion: true})
)
]));
}
@@ -530,7 +538,8 @@ async function _expandObject({
_asArray(value).map(v =>
_isString(v) ?
_expandIri(typeScopedContext, v,
{base: true, vocab: true}, options) : v),
{base: true, vocab: true},
{...options, typeExpansion: true}) : v),
{propertyIsArray: options.isFrame});
continue;
}
@@ -930,7 +939,8 @@ function _expandValue({activeCtx, activeProperty, value, options}) {
if(expandedProperty === '@id') {
return _expandIri(activeCtx, value, {base: true}, options);
} else if(expandedProperty === '@type') {
return _expandIri(activeCtx, value, {vocab: true, base: true}, options);
return _expandIri(activeCtx, value, {vocab: true, base: true},
{...options, typeExpansion: true});
}

// get type definition from context
2 changes: 1 addition & 1 deletion lib/util.js
Original file line number Diff line number Diff line change
@@ -130,7 +130,7 @@ api.parseLinkHeader = header => {
while((match = REGEX_LINK_HEADER_PARAMS.exec(params))) {
result[match[1]] = (match[2] === undefined) ? match[3] : match[2];
}
const rel = result['rel'] || '';
const rel = result.rel || '';
if(Array.isArray(rval[rel])) {
rval[rel].push(result);
} else if(rval.hasOwnProperty(rel)) {
528 changes: 528 additions & 0 deletions tests/misc.js
Original file line number Diff line number Diff line change
@@ -478,3 +478,531 @@ 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;
}
};

await jsonld.expand(docWithUnMappedTerm, {expansionMap});

assert.equal(expansionMapCalled, true);
});

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});

assert.equal(expansionMapCalled, true);
});
});

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"
};

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 id term (nested)', async () => {
const docWithRelativeIriId = {
'@context': {
'definedTerm': 'https://example.com#definedTerm'
},
'@id': "urn:absoluteIri",
definedTerm: {
'@id': "relativeiri"
}
};

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 id term', async () => {
const docWithRelativeIriId = {
'@context': {
'id': '@id',
'definedTerm': 'https://example.com#definedTerm'
},
'id': "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);
});

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"
};

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 type\
term in scoped context', async () => {
const docWithRelativeIriId = {
'@context': {
'definedType': {
'@id': 'https://example.com#definedType',
'@context': {
'definedTerm': 'https://example.com#definedTerm'

}
}
},
'id': "urn:absoluteiri",
'@type': "definedType",
definedTerm: {
'@type': 'relativeiri'
}
};

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 \
type term with multiple relative iri types', async () => {
const docWithRelativeIriId = {
'@context': {
'definedTerm': 'https://example.com#definedTerm'
},
'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);
});

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'

}
}
},
'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'
},
'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'
},
'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);
});

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;
}
};

await jsonld.expand(docWithRelativeIriId, {expansionMap});

assert.equal(expansionMapCalled, true);
});

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;
}
};

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": "./",
},
'@type': "relativeiri",
};

let expansionMapCalled = false;
const expansionMap = info => {
if(info.relativeIri === '/relativeiri') {
expansionMapCalled = true;
}
};

await jsonld.expand(docWithRelativeIriId, {expansionMap});

assert.equal(expansionMapCalled, true);
});
});

describe('prependedIri', () => {
it("should be called when property is \
being expanded with `@vocab`", async () => {
const doc = {
'@context': {
"@vocab": "http://example.com/",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was there a release that included this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, because we're working on a new "safe mode" feature right now that more cleanly and efficiently addresses the problem of "lossy" JSON that goes through JSON-LD transformations.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OR13 -- a release was made with the new safe mode that does a more low-level and targeted approach to solving the "undefined term / relative URL" problem with a much simpler API (set safe: true in the options). The latest version of jsonld.js (v8) now sets safe: true by default in canonize so that method will fail closed if you don't define your terms or use absolute URLs. You can override the default settings of course, as usual.

},
'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/",
},
'@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"
},
'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/",
},
'@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);
});

it("should be called when aliased '@id' \
is being expanded with `@base`", async () => {
const doc = {
'@context': {
"@base": "http://example.com/",
"id": "@id"
},
'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);
});

it("should be called when '@type' is \
being expanded with `@base`", async () => {
const doc = {
'@context': {
"@base": "http://example.com/",
},
'@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"
},
'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);
});
});
});
8 changes: 4 additions & 4 deletions tests/test-common.js
Original file line number Diff line number Diff line change
@@ -398,7 +398,7 @@ function addManifest(manifest, parent) {
*/
function addTest(manifest, test, tests) {
// expand @id and input base
const test_id = test['@id'] || test['id'];
const test_id = test['@id'] || test.id;
//var number = test_id.substr(2);
test['@id'] =
manifest.baseIri +
@@ -958,10 +958,10 @@ function createDocumentLoader(test) {
}

// If not JSON-LD, alternate may point there
if(linkHeaders['alternate'] &&
linkHeaders['alternate'].type == 'application/ld+json' &&
if(linkHeaders.alternate &&
linkHeaders.alternate.type == 'application/ld+json' &&
!(contentType || '').match(/^application\/(\w*\+)?json$/)) {
doc.documentUrl = prependBase(url, linkHeaders['alternate'].target);
doc.documentUrl = prependBase(url, linkHeaders.alternate.target);
}
}
}