mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Expose a storage description resource for storage containers
This commit is contained in:
parent
3db1921633
commit
df2f69f532
@ -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" }
|
||||
|
48
config/http/handler/handlers/storage-description.json
Normal file
48
config/http/handler/handlers/storage-description.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
14
config/ldp/metadata-writer/writers/storage-description.json
Normal file
14
config/ldp/metadata-writer/writers/storage-description.json
Normal file
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
@ -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<MetadataWriterInput> {}
|
||||
|
61
src/http/output/metadata/StorageDescriptionAdvertiser.ts
Normal file
61
src/http/output/metadata/StorageDescriptionAdvertiser.ts
Normal file
@ -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<void> {
|
||||
// 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<ResourceIdentifier> {
|
||||
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));
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
43
src/server/description/StaticStorageDescriber.ts
Normal file
43
src/server/description/StaticStorageDescriber.ts
Normal file
@ -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<NamedNode, Quad_Object[]>;
|
||||
|
||||
public constructor(terms: Record<string, string | string[]>) {
|
||||
super();
|
||||
const termMap = new Map<NamedNode, Quad_Object[]>();
|
||||
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<Quad[]> {
|
||||
const subject = namedNode(target.path);
|
||||
return [ ...this.generateTriples(subject) ];
|
||||
}
|
||||
|
||||
private* generateTriples(subject: NamedNode): Iterable<Quad> {
|
||||
for (const [ predicate, objects ] of this.terms.entries()) {
|
||||
for (const object of objects) {
|
||||
yield quad(subject, predicate, object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
src/server/description/StorageDescriber.ts
Normal file
8
src/server/description/StorageDescriber.ts
Normal file
@ -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<ResourceIdentifier, Quad[]> {}
|
58
src/server/description/StorageDescriptionHandler.ts
Normal file
58
src/server/description/StorageDescriptionHandler.ts
Normal file
@ -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<void> {
|
||||
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<ResponseDescription> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -218,6 +218,7 @@ export const SOLID = createVocabulary('http://www.w3.org/ns/solid/terms#',
|
||||
'oidcIssuer',
|
||||
'oidcIssuerRegistrationToken',
|
||||
'oidcRegistration',
|
||||
'storageDescription',
|
||||
'where',
|
||||
|
||||
'InsertDeletePatch',
|
||||
|
@ -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<HttpResponse>;
|
||||
let input: MetadataWriterInput;
|
||||
const baseUrl = 'http://example.com/';
|
||||
const suffix = '.well-known/solid';
|
||||
let targetExtractor: jest.Mocked<TargetExtractor>;
|
||||
const identifierStrategy = new SingleRootIdentifierStrategy(baseUrl);
|
||||
let store: jest.Mocked<ResourceStore>;
|
||||
let advertiser: StorageDescriptionAdvertiser;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata({ path: 'http://example.com/foo/' }, { [RDF.type]: LDP.terms.Resource });
|
||||
|
||||
const headerMap = new Map<string, string[]>();
|
||||
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<void> => {
|
||||
await expect(advertiser.handle(input)).resolves.toBeUndefined();
|
||||
expect(response.setHeader).toHaveBeenCalledTimes(1);
|
||||
expect(response.setHeader).toHaveBeenLastCalledWith('Link',
|
||||
'<http://example.com/foo/.well-known/solid>; rel="http://www.w3.org/ns/solid/terms#storageDescription"');
|
||||
});
|
||||
|
||||
it('only handles results with resource metadata.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
// No storage container will be found
|
||||
store.getRepresentation.mockResolvedValue(new BasicRepresentation());
|
||||
await expect(advertiser.handle(input)).resolves.toBeUndefined();
|
||||
expect(response.setHeader).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
31
test/unit/server/description/StaticStorageDescriber.test.ts
Normal file
31
test/unit/server/description/StaticStorageDescriber.test.ts
Normal file
@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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),
|
||||
]);
|
||||
});
|
||||
});
|
@ -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<ResourceStore>;
|
||||
let converter: jest.Mocked<RepresentationConverter>;
|
||||
let describer: jest.Mocked<StorageDescriber>;
|
||||
let handler: StorageDescriptionHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
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<Representation> => rep),
|
||||
} as any;
|
||||
|
||||
describer = {
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn(async(target): Promise<Quad[]> =>
|
||||
[ 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user