diff --git a/.eslintrc.js b/.eslintrc.js index a9552b7e7..ec1427012 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,6 +59,8 @@ module.exports = { 'new-cap': 'off', // Necessary in constructor overloading 'no-param-reassign': 'off', + // Checked by @typescript-eslint/no-redeclare + 'no-redeclare': 'off', // Conflicts with external libraries 'no-underscore-dangle': 'off', // Already checked by @typescript-eslint/no-unused-vars diff --git a/package-lock.json b/package-lock.json index 07a134196..219f06291 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6803,9 +6803,9 @@ "dev": true }, "n3": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.6.4.tgz", - "integrity": "sha512-qiiBhW2nJ59cfQzi0nvZs5tSXkXgDXedIy3zNNuKjTwE8Bcvv95DTFJpOY9geg6of5T7z6cg+ZWcaHIij3svrA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.7.0.tgz", + "integrity": "sha512-8R0Qj545WnVLQxOfxxyFKzOpO13hF3jhSMJfO0FNqvbsPZDiR9ZDmGGjXAlcoZDf/88OsCYd7rHML284vm1h6A==", "requires": { "queue-microtask": "^1.1.2", "readable-stream": "^3.6.0" @@ -7629,9 +7629,9 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, "queue-microtask": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.0.tgz", - "integrity": "sha512-J95OVUiS4b8qqmpqhCodN8yPpHG2mpZUPQ8tDGyIY0VhM+kBHszOuvsMJVGNQ1OH2BnTFbqz45i+2jGpDw9H0w==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.2.tgz", + "integrity": "sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg==" }, "quick-lru": { "version": "4.0.1", diff --git a/package.json b/package.json index f3e36ad26..d8545701f 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "fetch-sparql-endpoint": "^1.8.0", "handlebars": "^4.7.6", "mime-types": "^2.1.27", - "n3": "^1.6.4", + "n3": "^1.7.0", "rdf-parse": "^1.5.0", "rdf-serialize": "^1.0.0", "rdf-terms": "^1.5.1", diff --git a/src/ldp/representation/RepresentationMetadata.ts b/src/ldp/representation/RepresentationMetadata.ts index 249aad9a6..4f9a5818e 100644 --- a/src/ldp/representation/RepresentationMetadata.ts +++ b/src/ldp/representation/RepresentationMetadata.ts @@ -1,7 +1,7 @@ import { DataFactory, Store } from 'n3'; import type { BlankNode, Literal, NamedNode, Quad, Term } from 'rdf-js'; 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 type { ResourceIdentifier } from './ResourceIdentifier'; import { isResourceIdentifier } from './ResourceIdentifier'; @@ -133,6 +133,18 @@ export class RepresentationMetadata { 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. */ @@ -141,6 +153,18 @@ export class RepresentationMetadata { 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. */ @@ -155,8 +179,7 @@ export class RepresentationMetadata { * @param object - Value to add. */ public add(predicate: NamedNode | string, object: NamedNode | Literal | string): this { - this.store.addQuad(this.id, toCachedNamedNode(predicate), toObjectTerm(object, true)); - return this; + return this.addQuad(this.id, predicate, object); } /** @@ -165,8 +188,7 @@ export class RepresentationMetadata { * @param object - Value to remove. */ public remove(predicate: NamedNode | string, object: NamedNode | Literal | string): this { - this.store.removeQuad(this.id, toCachedNamedNode(predicate), toObjectTerm(object, true)); - return this; + return this.removeQuad(this.id, predicate, object); } /** diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts index 356581885..595e33f7d 100644 --- a/src/storage/conversion/QuadToRdfConverter.ts +++ b/src/storage/conversion/QuadToRdfConverter.ts @@ -1,4 +1,5 @@ import type { Readable } from 'stream'; +import { StreamWriter } from 'n3'; import rdfSerializer from 'rdf-serialize'; import type { Representation } from '../../ldp/representation/Representation'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; @@ -8,7 +9,8 @@ import type { } from '../../ldp/representation/RepresentationPreferences'; import { INTERNAL_QUADS } from '../../util/ContentTypes'; 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 type { RepresentationConverterArgs } from './RepresentationConverter'; import { TypedRepresentationConverter } from './TypedRepresentationConverter'; @@ -41,9 +43,20 @@ export class QuadToRdfConverter extends TypedRepresentationConverter { private async quadsToRdf(quads: Representation, { type }: RepresentationPreferences): Promise { const contentType = matchingMediaTypes(type, await this.getOutputTypes())[0]; 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 { binary: true, - data: guardStream(rdfSerializer.serialize(quads.data, { contentType }) as Readable), + data: guardStream(data), metadata, }; } diff --git a/src/util/TermUtil.ts b/src/util/TermUtil.ts index 51d5f6c1a..6308e6efc 100644 --- a/src/util/TermUtil.ts +++ b/src/util/TermUtil.ts @@ -1,5 +1,5 @@ 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'; 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. * @param subject - Subject to potentially transform. */ -export function toSubjectTerm(subject: NamedNode | string): NamedNode { +export function toSubjectTerm(subject: string): NamedNode; +export function toSubjectTerm(subject: T): T; +export function toSubjectTerm(subject: T | string): T | NamedNode; +export function toSubjectTerm(subject: Term | string): Term { return typeof subject === 'string' ? namedNode(subject) : subject; } @@ -52,7 +55,10 @@ export const toPredicateTerm = toSubjectTerm; * @param object - Object to potentially transform. * @param preferLiteral - Whether strings are converted to literals or named nodes. */ -export function toObjectTerm(object: T | string, preferLiteral = false): T { +export function toObjectTerm(object: string, preferLiteral?: boolean): NamedNode; +export function toObjectTerm(object: T, preferLiteral?: boolean): T; +export function toObjectTerm(object: T | string, preferLiteral?: boolean): T | NamedNode; +export function toObjectTerm(object: Term | string, preferLiteral = false): Term { if (typeof object === 'string') { return (preferLiteral ? literal(object) : namedNode(object)) as any; } diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 752b68c84..32669d97c 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -2,7 +2,7 @@ import { namedNode } from '@rdfjs/data-model'; import type { NamedNode } from 'rdf-js'; -type PrefixResolver = (localName: string) => T; +type PrefixResolver = (localName?: string) => T; type RecordOf = Record; export type Namespace = @@ -19,7 +19,7 @@ export function createNamespace( Namespace { // Create a function that expands local names const expanded = {} as Record; - const namespace = ((localName: string): TValue => { + const namespace = ((localName = ''): TValue => { if (!(localName in expanded)) { expanded[localName] = toValue(`${baseUri}${localName}`); } @@ -114,11 +114,17 @@ export const RDF = createUriAndTermNamespace('http://www.w3.org/1999/02/22-rdf-s 'type', ); +export const VANN = createUriAndTermNamespace('http://purl.org/vocab/vann/', + 'preferredNamespacePrefix', +); + export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#', 'dateTime', 'integer', ); -// Alias for most commonly used URI +// Alias for commonly used types export const CONTENT_TYPE = MA.format; export const CONTENT_TYPE_TERM = MA.terms.format; +export const PREFERRED_PREFIX = VANN.preferredNamespacePrefix; +export const PREFERRED_PREFIX_TERM = VANN.terms.preferredNamespacePrefix; diff --git a/test/integration/LpdHandlerOperations.test.ts b/test/integration/LpdHandlerOperations.test.ts index bf274e8d6..c086e44bc 100644 --- a/test/integration/LpdHandlerOperations.test.ts +++ b/test/integration/LpdHandlerOperations.test.ts @@ -107,12 +107,12 @@ describe('An integrated AuthenticatedLdpHandler', (): void => { [], ); expect(response.statusCode).toBe(200); - expect(response._getBuffer().toString()).toContain( + expect(response._getData()).toContain( ' .', ); expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`); const parser = new Parser(); - const triples = parser.parse(response._getBuffer().toString()); + const triples = parser.parse(response._getData()); expect(triples).toBeRdfIsomorphic([ quad( namedNode('http://test.com/s2'), diff --git a/test/unit/storage/conversion/QuadToRdfConverter.test.ts b/test/unit/storage/conversion/QuadToRdfConverter.test.ts index 5b8c6e429..38305bb1c 100644 --- a/test/unit/storage/conversion/QuadToRdfConverter.test.ts +++ b/test/unit/storage/conversion/QuadToRdfConverter.test.ts @@ -8,12 +8,16 @@ import type { RepresentationPreferences } from '../../../../src/ldp/representati import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import { QuadToRdfConverter } from '../../../../src/storage/conversion/QuadToRdfConverter'; 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 => { const converter = new QuadToRdfConverter(); 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 => { await expect(new QuadToRdfConverter().getInputTypes()) @@ -71,6 +75,33 @@ describe('A QuadToRdfConverter', (): void => { ); }); + it('converts quads with prefixes to turtle.', async(): Promise => { + 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: . +@prefix test: . + +test:s dc:modified test:o. +`, + ); + }); + it('converts quads to JSON-LD.', async(): Promise => { metadata.contentType = INTERNAL_QUADS; const representation = { diff --git a/test/unit/util/Vocabularies.test.ts b/test/unit/util/Vocabularies.test.ts index 0885bea6d..718e43df7 100644 --- a/test/unit/util/Vocabularies.test.ts +++ b/test/unit/util/Vocabularies.test.ts @@ -3,6 +3,10 @@ import { LDP } from '../../../src/util/Vocabularies'; describe('Vocabularies', (): 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 => { expect(LDP('new')).toBe('http://www.w3.org/ns/ldp#new'); });