feat: Expose a storage description resource for storage containers

This commit is contained in:
Joachim Van Herwegen 2022-09-30 10:21:48 +02:00
parent 3db1921633
commit df2f69f532
15 changed files with 449 additions and 3 deletions

View File

@ -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" }

View 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"
}
]
}
]
}
]
}

View File

@ -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" }
]
}

View File

@ -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" }
]
}

View 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" }
}
]
}

View File

@ -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> {}

View 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));
}
}

View File

@ -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';

View 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);
}
}
}
}

View 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[]> {}

View 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);
}
}

View File

@ -218,6 +218,7 @@ export const SOLID = createVocabulary('http://www.w3.org/ns/solid/terms#',
'oidcIssuer',
'oidcIssuerRegistrationToken',
'oidcRegistration',
'storageDescription',
'where',
'InsertDeletePatch',

View File

@ -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);
});
});

View 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),
]);
});
});

View File

@ -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);
});
});