diff --git a/lib/ContextParser.ts b/lib/ContextParser.ts index 64fe917..78c9860 100644 --- a/lib/ContextParser.ts +++ b/lib/ContextParser.ts @@ -7,9 +7,6 @@ import {IJsonLdContext, IJsonLdContextNormalizedRaw, IPrefixValue, JsonLdContext import {JsonLdContextNormalized, defaultExpandOptions, IExpandOptions} from "./JsonLdContextNormalized"; import {Util} from "./Util"; -// tslint:disable-next-line:no-var-requires -const canonicalizeJson = require('canonicalize'); - /** * Parses JSON-LD contexts. */ @@ -93,13 +90,14 @@ export class ContextParser { */ public idifyReverseTerms(context: IJsonLdContextNormalizedRaw): IJsonLdContextNormalizedRaw { for (const key of Object.keys(context)) { - const value: IPrefixValue = context[key]; + let value = context[key]; if (value && typeof value === 'object') { if (value['@reverse'] && !value['@id']) { if (typeof value['@reverse'] !== 'string' || Util.isValidKeyword(value['@reverse'])) { throw new ErrorCoded(`Invalid @reverse value, must be absolute IRI or blank node: '${value['@reverse']}'`, ERROR_CODES.INVALID_IRI_MAPPING); } + value = context[key] = {...value, '@id': value['@reverse']}; value['@id'] = value['@reverse']; if (Util.isPotentialKeyword(value['@reverse'])) { delete value['@reverse']; @@ -118,10 +116,12 @@ export class ContextParser { * @param {IJsonLdContextNormalizedRaw} context A context. * @param {boolean} expandContentTypeToBase If @type inside the context may be expanded * via @base if @vocab is set to null. + * @param {string[]} keys Optional set of keys from the context to expand. If left undefined, all + * keys in the context will be expanded. */ - public expandPrefixedTerms(context: JsonLdContextNormalized, expandContentTypeToBase: boolean) { + public expandPrefixedTerms(context: JsonLdContextNormalized, expandContentTypeToBase: boolean, keys?: string[]) { const contextRaw = context.getContextRaw(); - for (const key of Object.keys(contextRaw)) { + for (const key of (keys || Object.keys(contextRaw))) { // Only expand allowed keys if (Util.EXPAND_KEYS_BLACKLIST.indexOf(key) < 0 && !Util.isReservedInternalKeyword(key)) { // Error if we try to alias a keyword to something else. @@ -162,7 +162,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR if ('@id' in value) { // Use @id value for expansion if (id !== undefined && id !== null && typeof id === 'string') { - contextRaw[key]['@id'] = context.expandTerm(id, true); + contextRaw[key] = { ...contextRaw[key], '@id': context.expandTerm(id, true) }; changed = changed || id !== contextRaw[key]['@id']; } } else if (!Util.isPotentialKeyword(key) && canAddIdEntry) { @@ -170,7 +170,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR const newId = context.expandTerm(key, true); if (newId !== key) { // Don't set @id if expansion failed - contextRaw[key]['@id'] = newId; + contextRaw[key] = { ...contextRaw[key], '@id': newId }; changed = true; } } @@ -178,11 +178,14 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR && (!value['@container'] || !( value['@container'])['@type']) && canAddIdEntry) { // First check @vocab, then fallback to @base - contextRaw[key]['@type'] = context.expandTerm(type, true); - if (expandContentTypeToBase && type === contextRaw[key]['@type']) { - contextRaw[key]['@type'] = context.expandTerm(type, false); + let expandedType = context.expandTerm(type, true); + if (expandContentTypeToBase && type === expandedType) { + expandedType = context.expandTerm(type, false); + } + if (expandedType !== type) { + changed = true; + contextRaw[key] = { ...contextRaw[key], '@type': expandedType }; } - changed = changed || type !== contextRaw[key]['@type']; } } if (!changed) { @@ -209,7 +212,10 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR const value = context[key]; if (value && typeof value === 'object') { if (typeof value['@language'] === 'string') { - value['@language'] = value['@language'].toLowerCase(); + const lowercase = value['@language'].toLowerCase(); + if (lowercase !== value['@language']) { + context[key] = {...value, '@language': lowercase}; + } } } } @@ -226,13 +232,13 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR const value = context[key]; if (value && typeof value === 'object') { if (typeof value['@container'] === 'string') { - value['@container'] = { [value['@container']]: true }; + context[key] = { ...value, '@container': { [value['@container']]: true } }; } else if (Array.isArray(value['@container'])) { const newValue: {[key: string]: boolean} = {}; for (const containerValue of value['@container']) { newValue[containerValue] = true; } - value['@container'] = newValue; + context[key] = { ...value, '@container': newValue }; } } } @@ -256,7 +262,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR if (value && typeof value === 'object') { if (!('@protected' in context[key])) { // Mark terms with object values as protected if they don't have an @protected: false annotation - context[key]['@protected'] = true; + context[key] = {...context[key], '@protected': true}; } } else { // Convert string-based term values to object-based values with @protected: true @@ -265,7 +271,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR '@protected': true, }; if (Util.isSimpleTermDefinitionPrefix(value, expandOptions)) { - context[key]['@prefix'] = true + context[key] = {...context[key], '@prefix': true}; } } } @@ -280,29 +286,29 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR * @param {IJsonLdContextNormalizedRaw} contextBefore The context that may contain some protected terms. * @param {IJsonLdContextNormalizedRaw} contextAfter A new context that is being applied on the first one. * @param {IExpandOptions} expandOptions Options that are needed for any expansions during this validation. + * @param {string[]} keys Optional set of keys from the context to validate. If left undefined, all + * keys defined in contextAfter will be checked. */ public validateKeywordRedefinitions(contextBefore: IJsonLdContextNormalizedRaw, contextAfter: IJsonLdContextNormalizedRaw, - expandOptions: IExpandOptions) { - for (const key of Object.keys(contextAfter)) { + expandOptions?: IExpandOptions, + keys?: string[]) { + for (const key of (keys ?? Object.keys(contextAfter) )) { if (Util.isTermProtected(contextBefore, key)) { // The entry in the context before will always be in object-mode // If the new entry is in string-mode, convert it to object-mode // before checking if it is identical. if (typeof contextAfter[key] === 'string') { - contextAfter[key] = { '@id': contextAfter[key] }; - } - - // Convert term values to strings for each comparison - const valueBefore = canonicalizeJson(contextBefore[key]); + contextAfter[key] = { '@id': contextAfter[key], '@protected': true }; + } else { // We modify this deliberately, // as we need it for the value comparison (they must be identical modulo '@protected')), // and for the fact that this new value will override the first one. - contextAfter[key]['@protected'] = true; - const valueAfter = canonicalizeJson(contextAfter[key]); + contextAfter[key] = {...contextAfter[key], '@protected': true}; + } // Error if they are not identical - if (valueBefore !== valueAfter) { + if (!Util.deepEqual(contextBefore[key], contextAfter[key])) { throw new ErrorCoded(`Attempted to override the protected keyword ${key} from ${ JSON.stringify(Util.getContextValueId(contextBefore[key]))} to ${ JSON.stringify(Util.getContextValueId(contextAfter[key]))}`, @@ -593,10 +599,11 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP * @param {IJsonLdContextNormalizedRaw} context A context. * @param {IParseOptions} options Parsing options. * @return {IJsonLdContextNormalizedRaw} The mutated input context. + * @param {string[]} keys Optional set of keys from the context to parseInnerContexts of. If left undefined, all + * keys in the context will be iterated over. */ - public async parseInnerContexts(context: IJsonLdContextNormalizedRaw, options: IParseOptions) - : Promise { - for (const key of Object.keys(context)) { + public async parseInnerContexts(context: IJsonLdContextNormalizedRaw, options: IParseOptions, keys?: string[]): Promise { + for (const key of (keys ?? Object.keys(context))) { const value = context[key]; if (value && typeof value === 'object') { if ('@context' in value && value['@context'] !== null && !options.ignoreScopedContexts) { @@ -607,8 +614,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP // https://w3c.github.io/json-ld-api/#h-note-10 if (this.validateContext) { try { - const parentContext = {...context}; - parentContext[key] = {...parentContext[key]}; + const parentContext = {...context, [key]: {...context[key]}}; delete parentContext[key]['@context']; await this.parse(value['@context'], { ...options, external: false, parentContext, ignoreProtection: true, ignoreRemoteScopedContexts: true, ignoreScopedContexts: true }); @@ -616,10 +622,9 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP throw new ErrorCoded(e.message, ERROR_CODES.INVALID_SCOPED_CONTEXT); } } - - value['@context'] = (await this.parse(value['@context'], - { ...options, external: false, minimalProcessing: true, ignoreRemoteScopedContexts: true, parentContext: context })) - .getContextRaw(); + context[key] = {...value, '@context': (await this.parse(value['@context'], + { ...options, external: false, minimalProcessing: true, ignoreRemoteScopedContexts: true, parentContext: context })) + .getContextRaw()} } } } @@ -632,18 +637,21 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP * @param {IParseOptions} options Optional parsing options. * @return {Promise} A promise resolving to the context. */ + public async parse(context: JsonLdContext, options?: IParseOptions): Promise public async parse(context: JsonLdContext, - options: IParseOptions = {}): Promise { + options: IParseOptions = {}, + // These options are only for internal use on recursive calls and should not be used by + // libraries consuming this function + internalOptions: { skipValidation?: boolean } = {}): Promise { const { baseIRI, - parentContext: parentContextInitial, + parentContext, external, processingMode = ContextParser.DEFAULT_PROCESSING_MODE, normalizeLanguageTags, ignoreProtection, minimalProcessing, } = options; - let parentContext = parentContextInitial; const remoteContexts = options.remoteContexts || {}; // Avoid remote context overflows @@ -705,7 +713,11 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP external: !!contextIris[i] || options.external, parentContext: accContext.getContextRaw(), remoteContexts: contextIris[i] ? { ...remoteContexts, [contextIris[i]]: true } : remoteContexts, - })), + }, + // @ts-expect-error: This third argument causes a type error because we have hidden it from consumers + { + skipValidation: i < contexts.length - 1, + })), Promise.resolve(new JsonLdContextNormalized(parentContext || {}))); // Override the base IRI if provided. @@ -718,10 +730,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP } // Make a deep clone of the given context, to avoid modifying it. - context = JSON.parse(JSON.stringify(context)); // No better way in JS at the moment. - if (parentContext && !minimalProcessing) { - parentContext = JSON.parse(JSON.stringify(parentContext)); - } + context = {...context}; // According to the JSON-LD spec, @base must be ignored from external contexts. if (external) { @@ -760,46 +769,55 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP } this.applyScopedProtected(importContext, { processingMode }, defaultExpandOptions); - let newContext: IJsonLdContextNormalizedRaw = { ...importContext, ...context }; + + const newContext: IJsonLdContextNormalizedRaw = Object.assign(importContext, context); + + // Handle terms (before protection checks) + this.idifyReverseTerms(newContext); + this.normalize(newContext, { processingMode, normalizeLanguageTags }); + this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions); + + const keys = Object.keys(newContext); + + const overlappingKeys: string[] = []; if (typeof parentContext === 'object') { // Merge different parts of the final context in order - this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions); - newContext = { ...parentContext, ...newContext }; + for (const key in parentContext) { + if (key in newContext) { + overlappingKeys.push(key); + } else { + newContext[key] = parentContext[key]; + } + } } - const newContextWrapped = new JsonLdContextNormalized(newContext); - // Parse inner contexts with minimal processing - await this.parseInnerContexts(newContext, options); + await this.parseInnerContexts(newContext, options, keys); + + const newContextWrapped = new JsonLdContextNormalized(newContext); // In JSON-LD 1.1, @vocab can be relative to @vocab in the parent context, or a compact IRI. if ((newContext && newContext['@version'] || ContextParser.DEFAULT_PROCESSING_MODE) >= 1.1 && ((context['@vocab'] && typeof context['@vocab'] === 'string') || context['@vocab'] === '')) { if (parentContext && '@vocab' in parentContext && context['@vocab'].indexOf(':') < 0) { newContext['@vocab'] = parentContext['@vocab'] + context['@vocab']; - } else { - if (Util.isCompactIri(context['@vocab']) || context['@vocab'] in newContextWrapped.getContextRaw()) { + } else if (Util.isCompactIri(context['@vocab']) || context['@vocab'] in newContext) { // @vocab is a compact IRI or refers exactly to a prefix - newContext['@vocab'] = newContextWrapped.expandTerm(context['@vocab'], true); - } + newContext['@vocab'] = newContextWrapped.expandTerm(context['@vocab'], true); + } } - // Handle terms (before protection checks) - this.idifyReverseTerms(newContext); - this.expandPrefixedTerms(newContextWrapped, this.expandContentTypeToBase); + this.expandPrefixedTerms(newContextWrapped, this.expandContentTypeToBase, keys); // In JSON-LD 1.1, check if we are not redefining any protected keywords if (!ignoreProtection && parentContext && processingMode >= 1.1) { - this.validateKeywordRedefinitions(parentContext, newContext, defaultExpandOptions); + this.validateKeywordRedefinitions(parentContext, newContext, defaultExpandOptions, overlappingKeys); } - this.normalize(newContext, { processingMode, normalizeLanguageTags }); - this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions); - if (this.validateContext) { + if (this.validateContext && !internalOptions.skipValidation) { this.validate(newContext, { processingMode }); } - return newContextWrapped; } else { throw new ErrorCoded(`Tried parsing a context that is not a string, array or object, but got ${context}`, @@ -816,7 +834,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP // First try to retrieve the context from cache const cached = this.documentCache[url]; if (cached) { - return typeof cached === 'string' ? cached : Array.isArray(cached) ? cached.slice() : {... cached}; + return cached; } // If not in cache, load it @@ -863,8 +881,8 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP * @param importContextIri The full URI of an @import value. */ public async loadImportContext(importContextIri: string): Promise { - // Load the context - const importContext = await this.load(importContextIri); + // Load the context - and do a deep clone since we are about to mutate it + let importContext = await this.load(importContextIri); // Require the context to be a non-array object if (typeof importContext !== 'object' || Array.isArray(importContext)) { @@ -877,6 +895,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP throw new ErrorCoded('An imported context can not import another context: ' + importContextIri, ERROR_CODES.INVALID_CONTEXT_ENTRY); } + importContext = {...importContext}; // Containers have to be converted into hash values the same way as for the importing context // Otherwise context validation will fail for container values @@ -972,4 +991,3 @@ export interface IParseOptions { */ ignoreScopedContexts?: boolean; } - diff --git a/lib/Util.ts b/lib/Util.ts index edb41ba..b3f9460 100644 --- a/lib/Util.ts +++ b/lib/Util.ts @@ -247,4 +247,27 @@ export class Util { public static isReservedInternalKeyword(key: string) { return key.startsWith('@__'); } + + /** + * Check if two objects are deepEqual to on another. + * @param object1 The first object to test. + * @param object2 The second object to test. + */ + public static deepEqual(object1: any, object2: any): boolean { + const objKeys1 = Object.keys(object1); + const objKeys2 = Object.keys(object2); + + if (objKeys1.length !== objKeys2.length) return false; + return objKeys1.every((key) => { + const value1 = object1[key]; + const value2 = object2[key]; + return (value1 === value2) || ( + value1 !== null && + value2 !== null && + typeof value1 === "object" && + typeof value2 === "object" && + this.deepEqual(value1, value2) + ); + }); + }; } diff --git a/package.json b/package.json index bf3120a..436d1ac 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "dependencies": { "@types/http-link-header": "^1.0.1", "@types/node": "^18.0.0", - "canonicalize": "^1.0.1", "cross-fetch": "^3.0.6", "http-link-header": "^1.0.2", "relative-to-absolute-iri": "^1.0.5" diff --git a/test/ContextParser-test.ts b/test/ContextParser-test.ts index 148ffe3..e477dc2 100644 --- a/test/ContextParser-test.ts +++ b/test/ContextParser-test.ts @@ -3,6 +3,7 @@ import { ERROR_CODES, ErrorCoded, FetchDocumentLoader, + IDocumentLoader, JsonLdContextNormalized, } from "../index"; @@ -114,25 +115,26 @@ describe('ContextParser', () => { }); describe('when instantiated without options', () => { - let parser: any; + let parser: ContextParser; beforeEach(() => { parser = new ContextParser(); }); it('should have a default document loader', async () => { - expect(parser.documentLoader).toBeInstanceOf(FetchDocumentLoader); + expect((parser).documentLoader).toBeInstanceOf(FetchDocumentLoader); }); }); describe('when instantiated with empty options', () => { - let parser: any; + let parser: ContextParser; beforeEach(() => { parser = new ContextParser({}); }); it('should have a default document loader', async () => { + // @ts-expect-error expect(parser.documentLoader).toBeInstanceOf(FetchDocumentLoader); }); }); @@ -151,8 +153,8 @@ describe('ContextParser', () => { }); describe('when instantiated with options and a document loader', () => { - let documentLoader: any; - let parser: any; + let documentLoader: IDocumentLoader; + let parser: ContextParser; beforeEach(() => { documentLoader = new FetchDocumentLoader(); @@ -160,7 +162,7 @@ describe('ContextParser', () => { }); it('should have the given document loader', async () => { - expect(parser.documentLoader).toBe(documentLoader); + expect(( parser).documentLoader).toBe(documentLoader); }); describe('expandPrefixedTerms with expandContentTypeToBase true', () => { @@ -1445,6 +1447,7 @@ Tried mapping @id to {}`, ERROR_CODES.KEYWORD_REDEFINITION)); describe('parse', () => { it('should error when parsing a context with an invalid context entry', () => { + // @ts-expect-error return expect(parser.parse({ '@base': true })).rejects .toEqual(new ErrorCoded('Found an invalid @base IRI: true', ERROR_CODES.INVALID_BASE_IRI)); @@ -1544,6 +1547,7 @@ Tried mapping @id to {}`, ERROR_CODES.KEYWORD_REDEFINITION)); }); it('should parse with a base IRI and not override the inner @base', () => { + // @ts-expect-error return expect(parser.parse({ '@base': 'http://myotherexample.org/' }, 'http://myexample.org/')) .resolves.toEqual(new JsonLdContextNormalized({ '@base': 'http://myotherexample.org/', @@ -1628,10 +1632,12 @@ Tried mapping @id to {}`, ERROR_CODES.KEYWORD_REDEFINITION)); }); it('should cache documents', async () => { + // @ts-expect-error const spy = jest.spyOn(parser.documentLoader, 'load'); await parser.parse('http://example.org/simple.jsonld'); + // @ts-expect-error expect(parser.documentCache['http://example.org/simple.jsonld']).toEqual({ name: "http://xmlns.com/foaf/0.1/name", xsd: "http://www.w3.org/2001/XMLSchema#", @@ -1803,6 +1809,7 @@ Tried mapping @id to {}`, ERROR_CODES.KEYWORD_REDEFINITION)); }); it('should parse an array with an object and a string resolving to an array when cached', () => { + // @ts-expect-error parser.documentCache['http://example.org/simplearray.jsonld'] = [{ nickname: 'http://xmlns.com/foaf/0.1/nick', }]; @@ -3067,18 +3074,21 @@ Tried mapping @id to {}`, ERROR_CODES.KEYWORD_REDEFINITION)); describe('for parsing invalid values', () => { it('should error when parsing true', () => { + // @ts-expect-error return expect(parser.parse(true)).rejects .toEqual(new ErrorCoded('Tried parsing a context that is not a string, array or object, but got true', ERROR_CODES.INVALID_LOCAL_CONTEXT)); }); it('should error when parsing false', () => { + // @ts-expect-error return expect(parser.parse(false)).rejects .toEqual(new ErrorCoded('Tried parsing a context that is not a string, array or object, but got false', ERROR_CODES.INVALID_LOCAL_CONTEXT)); }); it('should error when parsing a number', () => { + // @ts-expect-error return expect(parser.parse(1)).rejects .toEqual(new ErrorCoded('Tried parsing a context that is not a string, array or object, but got 1', ERROR_CODES.INVALID_LOCAL_CONTEXT)); @@ -3524,5 +3534,26 @@ Tried mapping @id to {}`, ERROR_CODES.KEYWORD_REDEFINITION)); }); }); + describe('#validateKeywordRedefinitions', () => { + it('should return true when validating over the same context', async () => { + const context = new JsonLdContextNormalized({ + '@base': 'http://base.org/', + '@vocab': 'http://vocab.org/', + 'p': 'http://vocab.org/p', + }); + expect(parser.validateKeywordRedefinitions(context, context)).toBeFalsy(); + }); + }); + + describe('#parseInnerContexts', () => { + it('return the same context when calling parseInnerContexts on a context with no inner contexts', async () => { + const context = new JsonLdContextNormalized({ + '@base': 'http://base.org/', + '@vocab': 'http://vocab.org/', + 'p': 'http://vocab.org/p', + }); + expect(parser.parseInnerContexts(context, {})).resolves.toEqual(context); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index a666a5c..d9336aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1137,11 +1137,6 @@ caniuse-lite@^1.0.30001359: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001363.tgz#26bec2d606924ba318235944e1193304ea7c4f15" integrity sha512-HpQhpzTGGPVMnCjIomjt+jvyUu8vNFo3TaDiZ/RcoTrlOq/5+tC8zHdsbgFB6MxmaY+jCpsH09aD80Bb4Ow3Sg== -canonicalize@^1.0.1: - version "1.0.8" - resolved "https://registry.yarnpkg.com/canonicalize/-/canonicalize-1.0.8.tgz#24d1f1a00ed202faafd9bf8e63352cd4450c6df1" - integrity sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A== - caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"