diff --git a/index.ts b/index.ts index efcb58e7b..fff80ec1c 100644 --- a/index.ts +++ b/index.ts @@ -103,6 +103,7 @@ export * from './src/storage/Conditions'; export * from './src/storage/ContainerManager'; export * from './src/storage/DataAccessorBasedStore'; export * from './src/storage/ExtensionBasedMapper'; +export * from './src/storage/FixedConvertingStore'; export * from './src/storage/Lock'; export * from './src/storage/LockingResourceStore'; export * from './src/storage/PassthroughStore'; diff --git a/src/storage/FixedConvertingStore.ts b/src/storage/FixedConvertingStore.ts new file mode 100644 index 000000000..4a675dee2 --- /dev/null +++ b/src/storage/FixedConvertingStore.ts @@ -0,0 +1,45 @@ +import type { Representation } from '../ldp/representation/Representation'; +import type { RepresentationPreference } from '../ldp/representation/RepresentationPreference'; +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import type { Conditions } from './Conditions'; +import type { RepresentationConverter } from './conversion/RepresentationConverter'; +import { PassthroughStore } from './PassthroughStore'; +import type { ResourceStore } from './ResourceStore'; + +/** + * Store that converts incoming data when required. + * If the content-type of an incoming representation does not match one of the stored types it will be converted. + */ +export class FixedConvertingStore extends PassthroughStore { + private readonly types: string[]; + private readonly converter: RepresentationConverter; + + public constructor(source: ResourceStore, converter: RepresentationConverter, types: string[]) { + super(source); + this.converter = converter; + this.types = types; + } + + public async addResource(container: ResourceIdentifier, representation: Representation, + conditions?: Conditions): Promise { + // We can potentially run into problems here if we convert a turtle document where the base IRI is required, + // since we don't know the resource IRI yet at this point. + representation = await this.convertRepresentation(container, representation); + return this.source.addResource(container, representation, conditions); + } + + public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, + conditions?: Conditions): Promise { + representation = await this.convertRepresentation(identifier, representation); + return this.source.setRepresentation(identifier, representation, conditions); + } + + private async convertRepresentation(identifier: ResourceIdentifier, representation: Representation): + Promise { + if (this.types.includes(representation.metadata.contentType!)) { + return representation; + } + const preferences = this.types.map((type): RepresentationPreference => ({ value: type, weight: 1 })); + return this.converter.handleSafe({ identifier, representation, preferences: { type: preferences }}); + } +} diff --git a/src/storage/accessors/SparqlDataAccessor.ts b/src/storage/accessors/SparqlDataAccessor.ts index 441f451d8..e72454720 100644 --- a/src/storage/accessors/SparqlDataAccessor.ts +++ b/src/storage/accessors/SparqlDataAccessor.ts @@ -20,6 +20,7 @@ import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdenti import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { ConflictHttpError } from '../../util/errors/ConflictHttpError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; import type { MetadataController } from '../../util/MetadataController'; import { CONTENT_TYPE, LDP } from '../../util/UriConstants'; @@ -28,7 +29,7 @@ import { ensureTrailingSlash } from '../../util/Util'; import type { ContainerManager } from '../ContainerManager'; import type { DataAccessor } from './DataAccessor'; -const { quad, namedNode, variable } = DataFactory; +const { defaultGraph, namedNode, quad, variable } = DataFactory; /** * Stores all data and metadata of resources in a SPARQL backend. @@ -124,10 +125,16 @@ export class SparqlDataAccessor implements DataAccessor { } const { name, parent } = await this.getRelevantNames(identifier); + const triples = await arrayifyStream(data) as Quad[]; + const def = defaultGraph(); + if (triples.some((triple): boolean => !triple.graph.equals(def))) { + throw new UnsupportedHttpError('Only triples in the default graph are supported.'); + } + // Not relevant since all content is triples metadata.removeAll(CONTENT_TYPE); - return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata, await arrayifyStream(data))); + return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata, triples)); } /** diff --git a/test/unit/storage/FixedConvertingStore.test.ts b/test/unit/storage/FixedConvertingStore.test.ts new file mode 100644 index 000000000..f1afca71e --- /dev/null +++ b/test/unit/storage/FixedConvertingStore.test.ts @@ -0,0 +1,51 @@ +import type { Representation } from '../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; +import type { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter'; +import { FixedConvertingStore } from '../../../src/storage/FixedConvertingStore'; +import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { StaticAsyncHandler } from '../../util/StaticAsyncHandler'; + +describe('A FixedConvertingStore', (): void => { + let store: FixedConvertingStore; + let source: ResourceStore; + let converter: RepresentationConverter; + const types = [ 'text/turtle' ]; + let metadata: RepresentationMetadata; + let representation: Representation; + + beforeEach(async(): Promise => { + source = { + addResource: jest.fn(), + setRepresentation: jest.fn(), + } as any; + + converter = new StaticAsyncHandler(true, 'converted') as any; + + store = new FixedConvertingStore(source, converter, types); + + metadata = new RepresentationMetadata(); + representation = { binary: true, data: 'data', metadata } as any; + }); + + it('keeps the representation if the content-type is supported.', async(): Promise => { + metadata.contentType = types[0]; + const id = { path: 'identifier' }; + + await expect(store.addResource(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.addResource).toHaveBeenLastCalledWith(id, representation, 'conditions'); + + await expect(store.setRepresentation(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.setRepresentation).toHaveBeenLastCalledWith(id, representation, 'conditions'); + }); + + it('converts the data if the content-type is not supported.', async(): Promise => { + metadata.contentType = 'text/plain'; + const id = { path: 'identifier' }; + + await expect(store.addResource(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.addResource).toHaveBeenLastCalledWith(id, 'converted', 'conditions'); + + await expect(store.setRepresentation(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.setRepresentation).toHaveBeenLastCalledWith(id, 'converted', 'conditions'); + }); +}); diff --git a/test/unit/storage/accessors/SparqlDataAccessor.test.ts b/test/unit/storage/accessors/SparqlDataAccessor.test.ts index 6aa7807c1..8c471131b 100644 --- a/test/unit/storage/accessors/SparqlDataAccessor.test.ts +++ b/test/unit/storage/accessors/SparqlDataAccessor.test.ts @@ -10,6 +10,7 @@ import { UrlContainerManager } from '../../../../src/storage/UrlContainerManager import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; import { MetadataController } from '../../../../src/util/MetadataController'; import { CONTENT_TYPE, LDP, RDF } from '../../../../src/util/UriConstants'; @@ -193,9 +194,15 @@ describe('A SparqlDataAccessor', (): void => { it('errors when trying to write to a metadata document.', async(): Promise => { const data = streamifyArray([ quad(namedNode('http://name'), namedNode('http://pred'), literal('value')) ]); - metadata = new RepresentationMetadata('http://test.com/container/resource', - { [RDF.type]: [ toNamedNode(LDP.Resource) ]}); await expect(accessor.writeDocument({ path: 'http://test.com/container/resource.meta' }, data, metadata)) .rejects.toThrow(new ConflictHttpError('Not allowed to create NamedNodes with the metadata extension.')); }); + + it('errors when writing triples in a non-default graph.', async(): Promise => { + const data = streamifyArray( + [ quad(namedNode('http://name'), namedNode('http://pred'), literal('value'), namedNode('badGraph!')) ], + ); + await expect(accessor.writeDocument({ path: 'http://test.com/container/resource' }, data, metadata)) + .rejects.toThrow(new UnsupportedHttpError('Only triples in the default graph are supported.')); + }); });