feat: Support writer prefixes.

Closes https://github.com/solid/community-server/issues/470
This commit is contained in:
Ruben Verborgh 2021-01-05 23:40:05 +01:00
parent 3b63786ae0
commit 87752ddf20
10 changed files with 108 additions and 24 deletions

View File

@ -59,6 +59,8 @@ module.exports = {
'new-cap': 'off', 'new-cap': 'off',
// Necessary in constructor overloading // Necessary in constructor overloading
'no-param-reassign': 'off', 'no-param-reassign': 'off',
// Checked by @typescript-eslint/no-redeclare
'no-redeclare': 'off',
// Conflicts with external libraries // Conflicts with external libraries
'no-underscore-dangle': 'off', 'no-underscore-dangle': 'off',
// Already checked by @typescript-eslint/no-unused-vars // Already checked by @typescript-eslint/no-unused-vars

12
package-lock.json generated
View File

@ -6803,9 +6803,9 @@
"dev": true "dev": true
}, },
"n3": { "n3": {
"version": "1.6.4", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/n3/-/n3-1.6.4.tgz", "resolved": "https://registry.npmjs.org/n3/-/n3-1.7.0.tgz",
"integrity": "sha512-qiiBhW2nJ59cfQzi0nvZs5tSXkXgDXedIy3zNNuKjTwE8Bcvv95DTFJpOY9geg6of5T7z6cg+ZWcaHIij3svrA==", "integrity": "sha512-8R0Qj545WnVLQxOfxxyFKzOpO13hF3jhSMJfO0FNqvbsPZDiR9ZDmGGjXAlcoZDf/88OsCYd7rHML284vm1h6A==",
"requires": { "requires": {
"queue-microtask": "^1.1.2", "queue-microtask": "^1.1.2",
"readable-stream": "^3.6.0" "readable-stream": "^3.6.0"
@ -7629,9 +7629,9 @@
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
}, },
"queue-microtask": { "queue-microtask": {
"version": "1.2.0", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.0.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.2.tgz",
"integrity": "sha512-J95OVUiS4b8qqmpqhCodN8yPpHG2mpZUPQ8tDGyIY0VhM+kBHszOuvsMJVGNQ1OH2BnTFbqz45i+2jGpDw9H0w==" "integrity": "sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg=="
}, },
"quick-lru": { "quick-lru": {
"version": "4.0.1", "version": "4.0.1",

View File

@ -94,7 +94,7 @@
"fetch-sparql-endpoint": "^1.8.0", "fetch-sparql-endpoint": "^1.8.0",
"handlebars": "^4.7.6", "handlebars": "^4.7.6",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"n3": "^1.6.4", "n3": "^1.7.0",
"rdf-parse": "^1.5.0", "rdf-parse": "^1.5.0",
"rdf-serialize": "^1.0.0", "rdf-serialize": "^1.0.0",
"rdf-terms": "^1.5.1", "rdf-terms": "^1.5.1",

View File

@ -1,7 +1,7 @@
import { DataFactory, Store } from 'n3'; import { DataFactory, Store } from 'n3';
import type { BlankNode, Literal, NamedNode, Quad, Term } from 'rdf-js'; import type { BlankNode, Literal, NamedNode, Quad, Term } from 'rdf-js';
import { getLoggerFor } from '../../logging/LogUtil'; import { getLoggerFor } from '../../logging/LogUtil';
import { toObjectTerm, toCachedNamedNode, isTerm } from '../../util/TermUtil'; import { toSubjectTerm, toObjectTerm, toCachedNamedNode, isTerm } from '../../util/TermUtil';
import { CONTENT_TYPE_TERM } from '../../util/Vocabularies'; import { CONTENT_TYPE_TERM } from '../../util/Vocabularies';
import type { ResourceIdentifier } from './ResourceIdentifier'; import type { ResourceIdentifier } from './ResourceIdentifier';
import { isResourceIdentifier } from './ResourceIdentifier'; import { isResourceIdentifier } from './ResourceIdentifier';
@ -133,6 +133,18 @@ export class RepresentationMetadata {
return this; return this;
} }
/**
* @param quads - Quad to add to the metadata.
*/
public addQuad(
subject: NamedNode | BlankNode | string,
predicate: NamedNode | string,
object: NamedNode | BlankNode | Literal | string,
): this {
this.store.addQuad(toSubjectTerm(subject), toCachedNamedNode(predicate), toObjectTerm(object, true));
return this;
}
/** /**
* @param quads - Quads to add to the metadata. * @param quads - Quads to add to the metadata.
*/ */
@ -141,6 +153,18 @@ export class RepresentationMetadata {
return this; return this;
} }
/**
* @param quads - Quad to remove from the metadata.
*/
public removeQuad(
subject: NamedNode | BlankNode | string,
predicate: NamedNode | string,
object: NamedNode | BlankNode | Literal | string,
): this {
this.store.removeQuad(toSubjectTerm(subject), toCachedNamedNode(predicate), toObjectTerm(object, true));
return this;
}
/** /**
* @param quads - Quads to remove from the metadata. * @param quads - Quads to remove from the metadata.
*/ */
@ -155,8 +179,7 @@ export class RepresentationMetadata {
* @param object - Value to add. * @param object - Value to add.
*/ */
public add(predicate: NamedNode | string, object: NamedNode | Literal | string): this { public add(predicate: NamedNode | string, object: NamedNode | Literal | string): this {
this.store.addQuad(this.id, toCachedNamedNode(predicate), toObjectTerm(object, true)); return this.addQuad(this.id, predicate, object);
return this;
} }
/** /**
@ -165,8 +188,7 @@ export class RepresentationMetadata {
* @param object - Value to remove. * @param object - Value to remove.
*/ */
public remove(predicate: NamedNode | string, object: NamedNode | Literal | string): this { public remove(predicate: NamedNode | string, object: NamedNode | Literal | string): this {
this.store.removeQuad(this.id, toCachedNamedNode(predicate), toObjectTerm(object, true)); return this.removeQuad(this.id, predicate, object);
return this;
} }
/** /**

View File

@ -1,4 +1,5 @@
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import { StreamWriter } from 'n3';
import rdfSerializer from 'rdf-serialize'; import rdfSerializer from 'rdf-serialize';
import type { Representation } from '../../ldp/representation/Representation'; import type { Representation } from '../../ldp/representation/Representation';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
@ -8,7 +9,8 @@ import type {
} from '../../ldp/representation/RepresentationPreferences'; } from '../../ldp/representation/RepresentationPreferences';
import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { guardStream } from '../../util/GuardedStream'; import { guardStream } from '../../util/GuardedStream';
import { CONTENT_TYPE } from '../../util/Vocabularies'; import { pipeSafely } from '../../util/StreamUtil';
import { CONTENT_TYPE, PREFERRED_PREFIX_TERM } from '../../util/Vocabularies';
import { matchingMediaTypes } from './ConversionUtil'; import { matchingMediaTypes } from './ConversionUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter'; import { TypedRepresentationConverter } from './TypedRepresentationConverter';
@ -41,9 +43,20 @@ export class QuadToRdfConverter extends TypedRepresentationConverter {
private async quadsToRdf(quads: Representation, { type }: RepresentationPreferences): Promise<Representation> { private async quadsToRdf(quads: Representation, { type }: RepresentationPreferences): Promise<Representation> {
const contentType = matchingMediaTypes(type, await this.getOutputTypes())[0]; const contentType = matchingMediaTypes(type, await this.getOutputTypes())[0];
const metadata = new RepresentationMetadata(quads.metadata, { [CONTENT_TYPE]: contentType }); const metadata = new RepresentationMetadata(quads.metadata, { [CONTENT_TYPE]: contentType });
let data: Readable;
// Use prefixes if possible (see https://github.com/rubensworks/rdf-serialize.js/issues/1)
if (/(?:turtle|trig)$/u.test(contentType)) {
const prefixes = Object.fromEntries(metadata.quads(null, PREFERRED_PREFIX_TERM, null)
.map(({ subject, object }): [string, string] => [ object.value, subject.value ]));
data = pipeSafely(quads.data, new StreamWriter({ format: contentType, prefixes }));
// Otherwise, write without prefixes
} else {
data = rdfSerializer.serialize(quads.data, { contentType }) as Readable;
}
return { return {
binary: true, binary: true,
data: guardStream(rdfSerializer.serialize(quads.data, { contentType }) as Readable), data: guardStream(data),
metadata, metadata,
}; };
} }

View File

@ -1,5 +1,5 @@
import { DataFactory } from 'n3'; import { DataFactory } from 'n3';
import type { Literal, NamedNode, Term } from 'rdf-js'; import type { NamedNode, Literal, Term } from 'rdf-js';
import { CONTENT_TYPE_TERM } from './Vocabularies'; import { CONTENT_TYPE_TERM } from './Vocabularies';
const { namedNode, literal } = DataFactory; const { namedNode, literal } = DataFactory;
@ -41,7 +41,10 @@ export function isTerm(input?: any): input is Term {
* Converts a subject to a named node when needed. * Converts a subject to a named node when needed.
* @param subject - Subject to potentially transform. * @param subject - Subject to potentially transform.
*/ */
export function toSubjectTerm(subject: NamedNode | string): NamedNode { export function toSubjectTerm(subject: string): NamedNode;
export function toSubjectTerm<T extends Term>(subject: T): T;
export function toSubjectTerm<T extends Term>(subject: T | string): T | NamedNode;
export function toSubjectTerm(subject: Term | string): Term {
return typeof subject === 'string' ? namedNode(subject) : subject; return typeof subject === 'string' ? namedNode(subject) : subject;
} }
@ -52,7 +55,10 @@ export const toPredicateTerm = toSubjectTerm;
* @param object - Object to potentially transform. * @param object - Object to potentially transform.
* @param preferLiteral - Whether strings are converted to literals or named nodes. * @param preferLiteral - Whether strings are converted to literals or named nodes.
*/ */
export function toObjectTerm<T extends Term>(object: T | string, preferLiteral = false): T { export function toObjectTerm(object: string, preferLiteral?: boolean): NamedNode;
export function toObjectTerm<T extends Term>(object: T, preferLiteral?: boolean): T;
export function toObjectTerm<T extends Term>(object: T | string, preferLiteral?: boolean): T | NamedNode;
export function toObjectTerm(object: Term | string, preferLiteral = false): Term {
if (typeof object === 'string') { if (typeof object === 'string') {
return (preferLiteral ? literal(object) : namedNode(object)) as any; return (preferLiteral ? literal(object) : namedNode(object)) as any;
} }

View File

@ -2,7 +2,7 @@
import { namedNode } from '@rdfjs/data-model'; import { namedNode } from '@rdfjs/data-model';
import type { NamedNode } from 'rdf-js'; import type { NamedNode } from 'rdf-js';
type PrefixResolver<T> = (localName: string) => T; type PrefixResolver<T> = (localName?: string) => T;
type RecordOf<TKey extends any[], TValue> = Record<TKey[number], TValue>; type RecordOf<TKey extends any[], TValue> = Record<TKey[number], TValue>;
export type Namespace<TKey extends any[], TValue> = export type Namespace<TKey extends any[], TValue> =
@ -19,7 +19,7 @@ export function createNamespace<TKey extends string, TValue>(
Namespace<typeof localNames, TValue> { Namespace<typeof localNames, TValue> {
// Create a function that expands local names // Create a function that expands local names
const expanded = {} as Record<string, TValue>; const expanded = {} as Record<string, TValue>;
const namespace = ((localName: string): TValue => { const namespace = ((localName = ''): TValue => {
if (!(localName in expanded)) { if (!(localName in expanded)) {
expanded[localName] = toValue(`${baseUri}${localName}`); expanded[localName] = toValue(`${baseUri}${localName}`);
} }
@ -114,11 +114,17 @@ export const RDF = createUriAndTermNamespace('http://www.w3.org/1999/02/22-rdf-s
'type', 'type',
); );
export const VANN = createUriAndTermNamespace('http://purl.org/vocab/vann/',
'preferredNamespacePrefix',
);
export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#', export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#',
'dateTime', 'dateTime',
'integer', 'integer',
); );
// Alias for most commonly used URI // Alias for commonly used types
export const CONTENT_TYPE = MA.format; export const CONTENT_TYPE = MA.format;
export const CONTENT_TYPE_TERM = MA.terms.format; export const CONTENT_TYPE_TERM = MA.terms.format;
export const PREFERRED_PREFIX = VANN.preferredNamespacePrefix;
export const PREFERRED_PREFIX_TERM = VANN.terms.preferredNamespacePrefix;

View File

@ -107,12 +107,12 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
[], [],
); );
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response._getBuffer().toString()).toContain( expect(response._getData()).toContain(
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2>.', '<http://test.com/s2> <http://test.com/p2> <http://test.com/o2>.',
); );
expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`); expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
const parser = new Parser(); const parser = new Parser();
const triples = parser.parse(response._getBuffer().toString()); const triples = parser.parse(response._getData());
expect(triples).toBeRdfIsomorphic([ expect(triples).toBeRdfIsomorphic([
quad( quad(
namedNode('http://test.com/s2'), namedNode('http://test.com/s2'),

View File

@ -8,12 +8,16 @@ import type { RepresentationPreferences } from '../../../../src/ldp/representati
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { QuadToRdfConverter } from '../../../../src/storage/conversion/QuadToRdfConverter'; import { QuadToRdfConverter } from '../../../../src/storage/conversion/QuadToRdfConverter';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; import { CONTENT_TYPE, DC, PREFERRED_PREFIX_TERM } from '../../../../src/util/Vocabularies';
describe('A QuadToRdfConverter', (): void => { describe('A QuadToRdfConverter', (): void => {
const converter = new QuadToRdfConverter(); const converter = new QuadToRdfConverter();
const identifier: ResourceIdentifier = { path: 'path' }; const identifier: ResourceIdentifier = { path: 'path' };
const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: INTERNAL_QUADS }); let metadata: RepresentationMetadata;
beforeEach((): void => {
metadata = new RepresentationMetadata({ [CONTENT_TYPE]: INTERNAL_QUADS });
});
it('supports parsing quads.', async(): Promise<void> => { it('supports parsing quads.', async(): Promise<void> => {
await expect(new QuadToRdfConverter().getInputTypes()) await expect(new QuadToRdfConverter().getInputTypes())
@ -71,6 +75,33 @@ describe('A QuadToRdfConverter', (): void => {
); );
}); });
it('converts quads with prefixes to turtle.', async(): Promise<void> => {
metadata.addQuad(DC.terms(), PREFERRED_PREFIX_TERM, 'dc');
metadata.addQuad('http://test.com/', PREFERRED_PREFIX_TERM, 'test');
const representation = {
data: streamifyArray([ triple(
namedNode('http://test.com/s'),
DC.terms.modified,
namedNode('http://test.com/o'),
) ]),
metadata,
} as Representation;
const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
const result = await converter.handle({ identifier, representation, preferences });
expect(result).toMatchObject({
binary: true,
metadata: expect.any(RepresentationMetadata),
});
expect(result.metadata.contentType).toEqual('text/turtle');
await expect(stringifyStream(result.data)).resolves.toEqual(
`@prefix dc: <http://purl.org/dc/terms/>.
@prefix test: <http://test.com/>.
test:s dc:modified test:o.
`,
);
});
it('converts quads to JSON-LD.', async(): Promise<void> => { it('converts quads to JSON-LD.', async(): Promise<void> => {
metadata.contentType = INTERNAL_QUADS; metadata.contentType = INTERNAL_QUADS;
const representation = { const representation = {

View File

@ -3,6 +3,10 @@ import { LDP } from '../../../src/util/Vocabularies';
describe('Vocabularies', (): void => { describe('Vocabularies', (): void => {
describe('LDP', (): void => { describe('LDP', (): void => {
it('can return its own URI.', (): void => {
expect(LDP()).toBe('http://www.w3.org/ns/ldp#');
});
it('can create new properties.', (): void => { it('can create new properties.', (): void => {
expect(LDP('new')).toBe('http://www.w3.org/ns/ldp#new'); expect(LDP('new')).toBe('http://www.w3.org/ns/ldp#new');
}); });