From df2f69f5327f097662ffead20b0c49f755e85d56 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 30 Sep 2022 10:21:48 +0200 Subject: [PATCH] feat: Expose a storage description resource for storage containers --- config/http/handler/default.json | 4 +- .../handler/handlers/storage-description.json | 48 +++++++++ config/http/handler/simple.json | 5 + config/ldp/metadata-writer/default.json | 2 + .../writers/storage-description.json | 14 +++ src/http/output/metadata/MetadataWriter.ts | 8 +- .../metadata/StorageDescriptionAdvertiser.ts | 61 ++++++++++++ src/index.ts | 6 ++ .../description/StaticStorageDescriber.ts | 43 ++++++++ src/server/description/StorageDescriber.ts | 8 ++ .../description/StorageDescriptionHandler.ts | 58 +++++++++++ src/util/Vocabularies.ts | 1 + .../StorageDescriptionAdvertiser.test.ts | 66 +++++++++++++ .../StaticStorageDescriber.test.ts | 31 ++++++ .../StorageDescriptionHandler.test.ts | 97 +++++++++++++++++++ 15 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 config/http/handler/handlers/storage-description.json create mode 100644 config/ldp/metadata-writer/writers/storage-description.json create mode 100644 src/http/output/metadata/StorageDescriptionAdvertiser.ts create mode 100644 src/server/description/StaticStorageDescriber.ts create mode 100644 src/server/description/StorageDescriber.ts create mode 100644 src/server/description/StorageDescriptionHandler.ts create mode 100644 test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts create mode 100644 test/unit/server/description/StaticStorageDescriber.test.ts create mode 100644 test/unit/server/description/StorageDescriptionHandler.test.ts diff --git a/config/http/handler/default.json b/config/http/handler/default.json index 9305f90fe..8de3b1aee 100644 --- a/config/http/handler/default.json +++ b/config/http/handler/default.json @@ -1,7 +1,8 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", "import": [ - "css:config/http/handler/handlers/oidc.json" + "css:config/http/handler/handlers/oidc.json", + "css:config/http/handler/handlers/storage-description.json" ], "@graph": [ { @@ -16,6 +17,7 @@ { "@id": "urn:solid-server:default:StaticAssetHandler" }, { "@id": "urn:solid-server:default:SetupHandler" }, { "@id": "urn:solid-server:default:OidcHandler" }, + { "@id": "urn:solid-server:default:StorageDescriptionHandler" }, { "@id": "urn:solid-server:default:AuthResourceHttpHandler" }, { "@id": "urn:solid-server:default:IdentityProviderHandler" }, { "@id": "urn:solid-server:default:LdpHandler" } diff --git a/config/http/handler/handlers/storage-description.json b/config/http/handler/handlers/storage-description.json new file mode 100644 index 000000000..0ae135f9e --- /dev/null +++ b/config/http/handler/handlers/storage-description.json @@ -0,0 +1,48 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "The suffix appended to a storage container to find its description resource.", + "@id": "urn:solid-server:default:variable:storageDescriptionSuffix", + "valueRaw": ".well-known/solid" + }, + { + "comment": "Generates the storage description.", + "@id": "urn:solid-server:default:StorageDescriptionHandler", + "@type": "RouterHandler", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "allowedPathNames": [ "/\\.well-known/solid" ], + "handler": { + "@type": "ParsingHttpHandler", + "requestParser": { "@id": "urn:solid-server:default:RequestParser" }, + "metadataCollector": { "@id": "urn:solid-server:default:OperationMetadataCollector" }, + "errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, + "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, + "operationHandler": { + "@type": "StorageDescriptionHandler", + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "suffix": { "@id": "urn:solid-server:default:variable:storageDescriptionSuffix" }, + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, + "describer": { "@id": "urn:solid-server:default:StorageDescriber" } + } + } + }, + { + "comment": "Combines the output of all storage describers.", + "@id": "urn:solid-server:default:StorageDescriber", + "@type": "ArrayUnionHandler", + "handlers": [ + { + "@type": "StaticStorageDescriber", + "terms": [ + { + "StaticStorageDescriber:_terms_key": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "StaticStorageDescriber:_terms_value": "http://www.w3.org/ns/pim/space#Storage" + } + ] + } + ] + } + ] +} diff --git a/config/http/handler/simple.json b/config/http/handler/simple.json index a577b5b81..5511ab7ca 100644 --- a/config/http/handler/simple.json +++ b/config/http/handler/simple.json @@ -1,5 +1,8 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "import": [ + "css:config/http/handler/handlers/storage-description.json" + ], "@graph": [ { "comment": "This version of the server has no IDP or pod provisioning.", @@ -11,6 +14,8 @@ "@type": "WaterfallHandler", "handlers": [ { "@id": "urn:solid-server:default:StaticAssetHandler" }, + { "@id": "urn:solid-server:default:SetupHandler" }, + { "@id": "urn:solid-server:default:StorageDescriptionHandler" }, { "@id": "urn:solid-server:default:LdpHandler" } ] } diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json index 3622725bf..2ffef4537 100644 --- a/config/ldp/metadata-writer/default.json +++ b/config/ldp/metadata-writer/default.json @@ -7,6 +7,7 @@ "css:config/ldp/metadata-writer/writers/link-rel-metadata.json", "css:config/ldp/metadata-writer/writers/mapped.json", "css:config/ldp/metadata-writer/writers/modified.json", + "css:config/ldp/metadata-writer/writers/storage-description.json", "css:config/ldp/metadata-writer/writers/www-auth.json" ], "@graph": [ @@ -21,6 +22,7 @@ { "@id": "urn:solid-server:default:MetadataWriter_LinkRelMetadata" }, { "@id": "urn:solid-server:default:MetadataWriter_Mapped" }, { "@id": "urn:solid-server:default:MetadataWriter_Modified" }, + { "@id": "urn:solid-server:default:MetadataWriter_StorageDescription" }, { "@id": "urn:solid-server:default:MetadataWriter_WwwAuth" } ] } diff --git a/config/ldp/metadata-writer/writers/storage-description.json b/config/ldp/metadata-writer/writers/storage-description.json new file mode 100644 index 000000000..0c463fd6d --- /dev/null +++ b/config/ldp/metadata-writer/writers/storage-description.json @@ -0,0 +1,14 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Adds a link header pointing to the storage description resource.", + "@id": "urn:solid-server:default:MetadataWriter_StorageDescription", + "@type": "StorageDescriptionAdvertiser", + "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "suffix": { "@id": "urn:solid-server:default:variable:storageDescriptionSuffix" } + } + ] +} diff --git a/src/http/output/metadata/MetadataWriter.ts b/src/http/output/metadata/MetadataWriter.ts index 8b91f7830..353ad670e 100644 --- a/src/http/output/metadata/MetadataWriter.ts +++ b/src/http/output/metadata/MetadataWriter.ts @@ -2,8 +2,12 @@ import type { HttpResponse } from '../../../server/HttpResponse'; import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +export interface MetadataWriterInput { + response: HttpResponse; + metadata: RepresentationMetadata; +} + /** * A serializer that converts metadata to headers for an HttpResponse. */ -export abstract class MetadataWriter - extends AsyncHandler<{ response: HttpResponse; metadata: RepresentationMetadata }> { } +export abstract class MetadataWriter extends AsyncHandler {} diff --git a/src/http/output/metadata/StorageDescriptionAdvertiser.ts b/src/http/output/metadata/StorageDescriptionAdvertiser.ts new file mode 100644 index 000000000..0a063e25b --- /dev/null +++ b/src/http/output/metadata/StorageDescriptionAdvertiser.ts @@ -0,0 +1,61 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { ResourceStore } from '../../../storage/ResourceStore'; +import { createErrorMessage } from '../../../util/errors/ErrorUtil'; +import { addHeader } from '../../../util/HeaderUtil'; +import type { IdentifierStrategy } from '../../../util/identifiers/IdentifierStrategy'; +import { joinUrl } from '../../../util/PathUtil'; +import { LDP, PIM, RDF, SOLID } from '../../../util/Vocabularies'; +import type { TargetExtractor } from '../../input/identifier/TargetExtractor'; +import type { ResourceIdentifier } from '../../representation/ResourceIdentifier'; +import type { MetadataWriterInput } from './MetadataWriter'; +import { MetadataWriter } from './MetadataWriter'; + +/** + * Adds a link header pointing to the relevant storage description resource. + * Recursively checks parent containers until a storage container is found, + * and then appends the provided suffix to determine the storage description resource. + */ +export class StorageDescriptionAdvertiser extends MetadataWriter { + protected readonly logger = getLoggerFor(this); + + private readonly targetExtractor: TargetExtractor; + private readonly identifierStrategy: IdentifierStrategy; + private readonly store: ResourceStore; + private readonly suffix: string; + + public constructor(targetExtractor: TargetExtractor, identifierStrategy: IdentifierStrategy, store: ResourceStore, + suffix: string) { + super(); + this.identifierStrategy = identifierStrategy; + this.targetExtractor = targetExtractor; + this.store = store; + this.suffix = suffix; + } + + public async handle({ response, metadata }: MetadataWriterInput): Promise { + // This indicates this is the response of a successful GET/HEAD request + if (!metadata.has(RDF.terms.type, LDP.terms.Resource)) { + return; + } + const identifier = { path: metadata.identifier.value }; + let storageRoot: ResourceIdentifier; + try { + storageRoot = await this.findStorageRoot(identifier); + } catch (error: unknown) { + this.logger.error(`Unable to find storage root: ${createErrorMessage(error)}`); + return; + } + const storageDescription = joinUrl(storageRoot.path, this.suffix); + addHeader(response, 'Link', `<${storageDescription}>; rel="${SOLID.storageDescription}"`); + } + + private async findStorageRoot(identifier: ResourceIdentifier): Promise { + const representation = await this.store.getRepresentation(identifier, {}); + // We only need the metadata + representation.data.destroy(); + if (representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) { + return identifier; + } + return this.findStorageRoot(this.identifierStrategy.getParentContainer(identifier)); + } +} diff --git a/src/index.ts b/src/index.ts index 926f36fd0..f2f6eed6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,6 +110,7 @@ export * from './http/output/metadata/LinkRelMetadataWriter'; export * from './http/output/metadata/MappedMetadataWriter'; export * from './http/output/metadata/MetadataWriter'; export * from './http/output/metadata/ModifiedMetadataWriter'; +export * from './http/output/metadata/StorageDescriptionAdvertiser'; export * from './http/output/metadata/WacAllowMetadataWriter'; export * from './http/output/metadata/WwwAuthMetadataWriter'; @@ -294,6 +295,11 @@ export * from './server/ParsingHttpHandler'; export * from './server/ServerConfigurator'; export * from './server/WebSocketServerConfigurator'; +// Server/Description +export * from './server/description/StaticStorageDescriber'; +export * from './server/description/StorageDescriber'; +export * from './server/description/StorageDescriptionHandler'; + // Server/Middleware export * from './server/middleware/AcpHeaderHandler'; export * from './server/middleware/CorsHandler'; diff --git a/src/server/description/StaticStorageDescriber.ts b/src/server/description/StaticStorageDescriber.ts new file mode 100644 index 000000000..638f46ad2 --- /dev/null +++ b/src/server/description/StaticStorageDescriber.ts @@ -0,0 +1,43 @@ +import type { NamedNode, Quad, Quad_Object, Term } from '@rdfjs/types'; +import { DataFactory } from 'n3'; +import { stringToTerm } from 'rdf-string'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { StorageDescriber } from './StorageDescriber'; +import quad = DataFactory.quad; +import namedNode = DataFactory.namedNode; + +/** + * Adds a fixed set of triples to the storage description resource, + * with the resource identifier as subject. + */ +export class StaticStorageDescriber extends StorageDescriber { + private readonly terms: ReadonlyMap; + + public constructor(terms: Record) { + super(); + const termMap = new Map(); + for (const [ predicate, objects ] of Object.entries(terms)) { + const predTerm = stringToTerm(predicate); + if (predTerm.termType !== 'NamedNode') { + throw new Error('Predicate needs to be a named node.'); + } + const objTerms = (Array.isArray(objects) ? objects : [ objects ]).map((obj): Term => stringToTerm(obj)); + // `stringToTerm` can only generate valid term types + termMap.set(predTerm, objTerms as Quad_Object[]); + } + this.terms = termMap; + } + + public async handle(target: ResourceIdentifier): Promise { + const subject = namedNode(target.path); + return [ ...this.generateTriples(subject) ]; + } + + private* generateTriples(subject: NamedNode): Iterable { + for (const [ predicate, objects ] of this.terms.entries()) { + for (const object of objects) { + yield quad(subject, predicate, object); + } + } + } +} diff --git a/src/server/description/StorageDescriber.ts b/src/server/description/StorageDescriber.ts new file mode 100644 index 000000000..842d36342 --- /dev/null +++ b/src/server/description/StorageDescriber.ts @@ -0,0 +1,8 @@ +import type { Quad } from '@rdfjs/types'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; + +/** + * Generates Quads that need to be added to the given storage description resource. + */ +export abstract class StorageDescriber extends AsyncHandler {} diff --git a/src/server/description/StorageDescriptionHandler.ts b/src/server/description/StorageDescriptionHandler.ts new file mode 100644 index 000000000..d7cad65a4 --- /dev/null +++ b/src/server/description/StorageDescriptionHandler.ts @@ -0,0 +1,58 @@ +import { OkResponseDescription } from '../../http/output/response/OkResponseDescription'; +import type { ResponseDescription } from '../../http/output/response/ResponseDescription'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; +import type { ResourceStore } from '../../storage/ResourceStore'; +import { INTERNAL_QUADS } from '../../util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { ensureTrailingSlash } from '../../util/PathUtil'; +import { PIM, RDF } from '../../util/Vocabularies'; +import type { OperationHttpHandlerInput } from '../OperationHttpHandler'; +import { OperationHttpHandler } from '../OperationHttpHandler'; +import type { StorageDescriber } from './StorageDescriber'; + +/** + * Generates the response for GET requests targeting a storage description resource. + * The suffix needs to match the suffix used to generate storage description resources + * and will be used to verify the container it is linked to is an actual storage. + */ +export class StorageDescriptionHandler extends OperationHttpHandler { + private readonly store: ResourceStore; + private readonly suffix: string; + private readonly converter: RepresentationConverter; + private readonly describer: StorageDescriber; + + public constructor(store: ResourceStore, suffix: string, converter: RepresentationConverter, + describer: StorageDescriber) { + super(); + this.store = store; + this.suffix = suffix; + this.converter = converter; + this.describer = describer; + } + + public async canHandle({ operation: { target, method }}: OperationHttpHandlerInput): Promise { + if (method !== 'GET') { + throw new MethodNotAllowedHttpError([ method ], `Only GET requests can target the storage description.`); + } + const container = { path: ensureTrailingSlash(target.path.slice(0, -this.suffix.length)) }; + const representation = await this.store.getRepresentation(container, {}); + representation.data.destroy(); + if (!representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) { + throw new NotImplementedHttpError(`Only supports descriptions of storage containers.`); + } + + await this.describer.canHandle(target); + } + + public async handle({ operation: { target, preferences }}: OperationHttpHandlerInput): Promise { + const quads = await this.describer.handle(target); + + const representation = new BasicRepresentation(quads, INTERNAL_QUADS); + + const converted = await this.converter.handleSafe({ identifier: target, representation, preferences }); + + return new OkResponseDescription(converted.metadata, converted.data); + } +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 2220a62ea..0a041b704 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -218,6 +218,7 @@ export const SOLID = createVocabulary('http://www.w3.org/ns/solid/terms#', 'oidcIssuer', 'oidcIssuerRegistrationToken', 'oidcRegistration', + 'storageDescription', 'where', 'InsertDeletePatch', diff --git a/test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts b/test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts new file mode 100644 index 000000000..189f5d8b6 --- /dev/null +++ b/test/unit/http/output/metadata/StorageDescriptionAdvertiser.test.ts @@ -0,0 +1,66 @@ +import type { TargetExtractor } from '../../../../../src/http/input/identifier/TargetExtractor'; +import type { MetadataWriterInput } from '../../../../../src/http/output/metadata/MetadataWriter'; +import { StorageDescriptionAdvertiser } from '../../../../../src/http/output/metadata/StorageDescriptionAdvertiser'; +import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; +import type { ResourceStore } from '../../../../../src/storage/ResourceStore'; +import { SingleRootIdentifierStrategy } from '../../../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { joinUrl } from '../../../../../src/util/PathUtil'; +import { LDP, PIM, RDF } from '../../../../../src/util/Vocabularies'; + +describe('A StorageDescriptionAdvertiser', (): void => { + let metadata: RepresentationMetadata; + let response: jest.Mocked; + let input: MetadataWriterInput; + const baseUrl = 'http://example.com/'; + const suffix = '.well-known/solid'; + let targetExtractor: jest.Mocked; + const identifierStrategy = new SingleRootIdentifierStrategy(baseUrl); + let store: jest.Mocked; + let advertiser: StorageDescriptionAdvertiser; + + beforeEach(async(): Promise => { + metadata = new RepresentationMetadata({ path: 'http://example.com/foo/' }, { [RDF.type]: LDP.terms.Resource }); + + const headerMap = new Map(); + response = { + hasHeader: jest.fn((header): boolean => headerMap.has(header)), + getHeader: jest.fn((header): string[] | undefined => headerMap.get(header)), + setHeader: jest.fn((header, values: string[]): any => headerMap.set(header, values)), + } as any; + + input = { metadata, response }; + + targetExtractor = { + handleSafe: jest.fn(({ request: req }): ResourceIdentifier => ({ path: joinUrl(baseUrl, req.url!) })), + } as any; + + store = { + getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', { [RDF.type]: PIM.terms.Storage })), + } as any; + + advertiser = new StorageDescriptionAdvertiser(targetExtractor, identifierStrategy, store, suffix); + }); + + it('adds a storage description link header.', async(): Promise => { + await expect(advertiser.handle(input)).resolves.toBeUndefined(); + expect(response.setHeader).toHaveBeenCalledTimes(1); + expect(response.setHeader).toHaveBeenLastCalledWith('Link', + '; rel="http://www.w3.org/ns/solid/terms#storageDescription"'); + }); + + it('only handles results with resource metadata.', async(): Promise => { + metadata.removeAll(RDF.terms.type); + await expect(advertiser.handle(input)).resolves.toBeUndefined(); + expect(response.setHeader).toHaveBeenCalledTimes(0); + }); + + it('does nothing if it cannot find a storage root.', async(): Promise => { + // No storage container will be found + store.getRepresentation.mockResolvedValue(new BasicRepresentation()); + await expect(advertiser.handle(input)).resolves.toBeUndefined(); + expect(response.setHeader).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/server/description/StaticStorageDescriber.test.ts b/test/unit/server/description/StaticStorageDescriber.test.ts new file mode 100644 index 000000000..a7ec3095a --- /dev/null +++ b/test/unit/server/description/StaticStorageDescriber.test.ts @@ -0,0 +1,31 @@ +import 'jest-rdf'; +import { DataFactory } from 'n3'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import { StaticStorageDescriber } from '../../../../src/server/description/StaticStorageDescriber'; +import { LDP, PIM, RDF } from '../../../../src/util/Vocabularies'; +import quad = DataFactory.quad; +import namedNode = DataFactory.namedNode; + +describe('A StaticStorageDescriber', (): void => { + const target: ResourceIdentifier = { path: 'http://example.com/foo' }; + + it('returns the stored triples.', async(): Promise => { + const describer = new StaticStorageDescriber({ [RDF.type]: PIM.Storage }); + await expect(describer.handle(target)).resolves.toEqualRdfQuadArray([ + quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage), + ]); + }); + + it('errors if an input predicate does not represent a named node.', async(): Promise => { + expect((): any => new StaticStorageDescriber({ '"appelflap"': PIM.Storage })) + .toThrow('Predicate needs to be a named node.'); + }); + + it('accepts an array in the object position.', async(): Promise => { + const describer = new StaticStorageDescriber({ [RDF.type]: [ PIM.Storage, LDP.Resource ]}); + await expect(describer.handle(target)).resolves.toEqualRdfQuadArray([ + quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage), + quad(namedNode(target.path), RDF.terms.type, LDP.terms.Resource), + ]); + }); +}); diff --git a/test/unit/server/description/StorageDescriptionHandler.test.ts b/test/unit/server/description/StorageDescriptionHandler.test.ts new file mode 100644 index 000000000..6c807d8e6 --- /dev/null +++ b/test/unit/server/description/StorageDescriptionHandler.test.ts @@ -0,0 +1,97 @@ +import type { Quad } from '@rdfjs/types'; +import { DataFactory } from 'n3'; +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import type { StorageDescriber } from '../../../../src/server/description/StorageDescriber'; +import { StorageDescriptionHandler } from '../../../../src/server/description/StorageDescriptionHandler'; +import type { HttpRequest } from '../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../src/server/HttpResponse'; +import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; +import type { ResourceStore } from '../../../../src/storage/ResourceStore'; +import { readableToQuads } from '../../../../src/util/StreamUtil'; +import { PIM, RDF } from '../../../../src/util/Vocabularies'; +import quad = DataFactory.quad; +import namedNode = DataFactory.namedNode; + +describe('A StorageDescriptionHandler', (): void => { + const suffix = '.well-known/solid'; + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + let operation: Operation; + let representation: Representation; + let store: jest.Mocked; + let converter: jest.Mocked; + let describer: jest.Mocked; + let handler: StorageDescriptionHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'GET', + target: { path: `http://example.com/${suffix}` }, + body: new BasicRepresentation(), + preferences: {}, + }; + + representation = new BasicRepresentation(); + representation.metadata.add(RDF.terms.type, PIM.terms.Storage); + + store = { + getRepresentation: jest.fn().mockResolvedValue(representation), + } as any; + + converter = { + handleSafe: jest.fn(async({ representation: rep }): Promise => rep), + } as any; + + describer = { + canHandle: jest.fn(), + handle: jest.fn(async(target): Promise => + [ quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage) ]), + } as any; + + handler = new StorageDescriptionHandler(store, suffix, converter, describer); + }); + + it('only handles GET requests.', async(): Promise => { + operation.method = 'POST'; + await expect(handler.canHandle({ request, response, operation })) + .rejects.toThrow('Only GET requests can target the storage description.'); + expect(store.getRepresentation).toHaveBeenCalledTimes(0); + }); + + it('requires the corresponding container to be a pim:Storage.', async(): Promise => { + representation.metadata.removeAll(RDF.terms.type); + await expect(handler.canHandle({ request, response, operation })) + .rejects.toThrow('Only supports descriptions of storage containers.'); + expect(store.getRepresentation).toHaveBeenCalledTimes(1); + expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'http://example.com/' }, {}); + }); + + it('makes sure the describer can handle the input.', async(): Promise => { + describer.canHandle.mockRejectedValue(new Error('bad input')); + await expect(handler.canHandle({ request, response, operation })) + .rejects.toThrow('bad input'); + }); + + it('can handle valid input.', async(): Promise => { + await expect(handler.canHandle({ request, response, operation })) + .resolves.toBeUndefined(); + expect(store.getRepresentation).toHaveBeenCalledTimes(1); + expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'http://example.com/' }, {}); + expect(describer.canHandle).toHaveBeenCalledTimes(1); + expect(describer.canHandle).toHaveBeenLastCalledWith(operation.target); + }); + + it('converts the quads from its describer into a response.', async(): Promise => { + const result = await handler.handle({ request, response, operation }); + expect(result.statusCode).toBe(200); + expect(result.metadata?.contentType).toBe('internal/quads'); + expect(result.data).toBeDefined(); + const quads = await readableToQuads(result.data!); + expect(quads.countQuads(operation.target.path, RDF.terms.type, PIM.terms.Storage, null)).toBe(1); + expect(describer.handle).toHaveBeenCalledTimes(1); + expect(describer.handle).toHaveBeenLastCalledWith(operation.target); + expect(converter.handleSafe).toHaveBeenCalledTimes(1); + }); +});