Skip to content
Merged
Show file tree
Hide file tree
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
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"dependencies": {
"@comunica/context-entries": "^2.8.2",
"@comunica/query-sparql": "^2.9.0",
"@isaacs/ttlcache": "^1.4.1",
"@rdfjs/types": "^1.1.0",
"@solid/access-control-policy": "^0.1.3",
"@solid/access-token-verifier": "^2.1.0",
Expand Down
25 changes: 22 additions & 3 deletions src/storage/conversion/ConversionUtil.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import TTLCache from '@isaacs/ttlcache';
import fetch from 'cross-fetch';
import { readJsonSync } from 'fs-extra';
import type { IJsonLdContext } from 'jsonld-context-parser';
Expand All @@ -17,23 +18,41 @@ import { resolveAssetPath } from '../../util/PathUtil';
* and this is the key that is used to define the document loader.
* See https://github.com/rubensworks/rdf-parse.js/blob/master/lib/RdfParser.ts
* and https://github.com/comunica/comunica/blob/master/packages/actor-rdf-parse-jsonld/lib/ActorRdfParseJsonLd.ts
*
* The loader has an internal cache that stores fetched documents for 30 minutes by default.
* This is to prevent spamming a context URL in case there are many requests.
* This cache can be disabled by setting the `ttl` to 0.
*/
export class ContextDocumentLoader extends FetchDocumentLoader {
private readonly contexts: Record<string, IJsonLdContext>;
private readonly cache?: TTLCache<string, IJsonLdContext>;

public constructor(contexts: Record<string, string>) {
public constructor(contexts: Record<string, string>, ttl = 30 * 60 * 1000) {
super(fetch);
this.contexts = {};
for (const [ key, path ] of Object.entries(contexts)) {
this.contexts[key] = readJsonSync(resolveAssetPath(path)) as IJsonLdContext;
}
if (ttl > 0) {
this.cache = new TTLCache({ ttl, updateAgeOnGet: true });
}
}

public async load(url: string): Promise<IJsonLdContext> {
if (url in this.contexts) {
return this.contexts[url];
}
return super.load(url);
const cached = this.cache?.get(url);
if (cached) {
return cached;
}
const result = await super.load(url);

if (this.cache) {
this.cache.set(url, result);
}

return result;
}
}

Expand All @@ -44,7 +63,7 @@ export class ContextDocumentLoader extends FetchDocumentLoader {
*
* @param preferences - Preferences that need to be updated.
*
* @returns A copy of the the preferences with the necessary updates.
* @returns A copy of the preferences with the necessary updates.
*/
export function cleanPreferences(preferences: ValuePreferences = {}): ValuePreferences {
// No preference means anything is acceptable
Expand Down
84 changes: 84 additions & 0 deletions test/unit/storage/conversion/ConversionUtil.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import fetch, { Headers } from 'cross-fetch';
import * as fsExtra from 'fs-extra';
import type { ValuePreferences } from '../../../../src/http/representation/RepresentationPreferences';
import {
cleanPreferences,
ContextDocumentLoader,
getBestPreference,
getConversionTarget,
getTypeWeight,
Expand All @@ -12,7 +15,88 @@ import {
} from '../../../../src/storage/conversion/ConversionUtil';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';

jest.useFakeTimers();

// All of this is necessary to not break the cross-fetch imports that happen in `rdf-parse`
jest.mock('cross-fetch', (): any => {
const mock = jest.fn();
// Require the original module to not be mocked...
const originalFetch = jest.requireActual('cross-fetch');
return {
__esModule: true,
...originalFetch,
fetch: mock,
default: mock,
};
});

jest.mock('fs-extra', (): any => ({
__esModule: true,
...jest.requireActual('fs-extra'),
}));

describe('ConversionUtil', (): void => {
describe('ContextDocumentLoader', (): void => {
const fetchMock: jest.Mock = fetch as any;
const context1 = {
'@context': {
'@version': 1.1,
test: 'http://example.com/context1#',
},
};
const context2 = {
'@context': {
'@version': 1.1,
test: 'http://example.com/context2#',
},
};
const url = 'http://example.com/foo';

function mockOnce(context: unknown): void {
fetchMock.mockResolvedValueOnce({
json: (): any => context,
status: 200,
ok: true,
headers: new Headers({ 'content-type': 'application/ld+json' }),
});
}

it('fetches the context URL.', async(): Promise<void> => {
const loader = new ContextDocumentLoader({});

mockOnce(context1);

await expect(loader.load(url)).resolves.toEqual(context1);
});

it('returns the stored context if there is a match.', async(): Promise<void> => {
const mock = jest.spyOn(fsExtra, 'readJsonSync');
mock.mockReturnValueOnce(context2);

const loader = new ContextDocumentLoader({ [url]: 'path' });

mockOnce(context1);

await expect(loader.load(url)).resolves.toEqual(context2);
});

it('caches fetched results for the given amount of time.', async(): Promise<void> => {
const loader = new ContextDocumentLoader({}, 100);

mockOnce(context1);
mockOnce(context1);

await expect(loader.load(url)).resolves.toEqual(context1);
await expect(loader.load(url)).resolves.toEqual(context1);

jest.advanceTimersByTime(100);

await expect(loader.load(url)).resolves.toEqual(context1);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

describe('#cleanPreferences', (): void => {
it('supports all types for empty preferences.', async(): Promise<void> => {
expect(cleanPreferences()).toEqual({ '*/*': 1, 'internal/*': 0 });
Expand Down