import arrayifyStream from 'arrayify-stream'; import { DataFactory } from 'n3'; import type { Quad, Term } from 'rdf-js'; import { v4 as uuid } from 'uuid'; import type { Representation } from '../ldp/representation/Representation'; import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import { INTERNAL_QUADS } from '../util/ContentTypes'; import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; import { ConflictHttpError } from '../util/errors/ConflictHttpError'; import { InternalServerError } from '../util/errors/InternalServerError'; import { MethodNotAllowedHttpError } from '../util/errors/MethodNotAllowedHttpError'; import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; import { ensureTrailingSlash, isContainerIdentifier, isContainerPath, trimTrailingSlashes, } from '../util/PathUtil'; import { parseQuads } from '../util/QuadUtil'; import { generateResourceQuads } from '../util/ResourceUtil'; import { guardedStreamFrom } from '../util/StreamUtil'; import { CONTENT_TYPE, HTTP, LDP, PIM, RDF } from '../util/UriConstants'; import type { DataAccessor } from './accessors/DataAccessor'; import type { ResourceStore } from './ResourceStore'; /** * ResourceStore which uses a DataAccessor for backend access. * * The DataAccessor interface provides elementary store operations such as read and write. * This DataAccessorBasedStore uses those elementary store operations * to implement the more high-level ResourceStore contact, abstracting all common functionality * such that new stores can be added by implementing the more simple DataAccessor contract. * DataAccessorBasedStore thereby provides behaviours for reuse across different stores, such as: * * Converting container metadata to data * * Converting slug to URI * * Checking if addResource target is a container * * Checking if no containment triples are written to a container * * etc. * * Currently "metadata" is seen as something that is not directly accessible. * That means that a consumer can't write directly to the metadata of a resource, only indirectly through headers. * (Except for containers where data and metadata overlap). * * The one thing this store does not take care of (yet?) are containment triples for containers * * Work has been done to minimize the number of required calls to the DataAccessor, * but the main disadvantage is that sometimes multiple calls are required where a specific store might only need one. */ export class DataAccessorBasedStore implements ResourceStore { private readonly accessor: DataAccessor; private readonly identifierStrategy: IdentifierStrategy; public constructor(accessor: DataAccessor, identifierStrategy: IdentifierStrategy) { this.accessor = accessor; this.identifierStrategy = identifierStrategy; } public async getRepresentation(identifier: ResourceIdentifier): Promise { this.validateIdentifier(identifier); // In the future we want to use getNormalizedMetadata and redirect in case the identifier differs const metadata = await this.accessor.getMetadata(identifier); let result: Representation; // Create the representation of a container if (this.isExistingContainer(metadata)) { // Generate the data stream before setting the content-type to prevent unnecessary triples const data = guardedStreamFrom(metadata.quads()); metadata.contentType = INTERNAL_QUADS; result = { binary: false, data, metadata, }; // Obtain a representation of a document } else { result = { binary: metadata.contentType !== INTERNAL_QUADS, data: await this.accessor.getData(identifier), metadata, }; } return result; } public async addResource(container: ResourceIdentifier, representation: Representation): Promise { this.validateIdentifier(container); // Ensure the representation is supported by the accessor await this.accessor.canHandle(representation); // Using the parent metadata as we can also use that later to check if the nested containers maybe need to be made const parentMetadata = await this.getSafeNormalizedMetadata(container); // When a POST method request targets a non-container resource without an existing representation, // the server MUST respond with the 404 status code. if (!parentMetadata && !isContainerIdentifier(container)) { throw new NotFoundHttpError(); } if (parentMetadata && !this.isExistingContainer(parentMetadata)) { throw new MethodNotAllowedHttpError('The given path is not a container.'); } const newID = this.createSafeUri(container, representation.metadata, parentMetadata); // Write the data. New containers will need to be created if there is no parent. await this.writeData(newID, representation, isContainerIdentifier(newID), !parentMetadata); return newID; } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise { this.validateIdentifier(identifier); // Ensure the representation is supported by the accessor await this.accessor.canHandle(representation); // Check if the resource already exists const oldMetadata = await this.getSafeNormalizedMetadata(identifier); // Might want to redirect in the future if (oldMetadata && oldMetadata.identifier.value !== identifier.path) { throw new ConflictHttpError(`${identifier.path} conflicts with existing path ${oldMetadata.identifier.value}`); } // If we already have a resource for the given identifier, make sure they match resource types const isContainer = this.isNewContainer(representation.metadata, identifier.path); if (oldMetadata && isContainer !== this.isExistingContainer(oldMetadata)) { throw new ConflictHttpError('Input resource type does not match existing resource type.'); } if (isContainer !== isContainerIdentifier(identifier)) { throw new BadRequestHttpError('Containers should have a `/` at the end of their path, resources should not.'); } // Potentially have to create containers if it didn't exist yet await this.writeData(identifier, representation, isContainer, !oldMetadata); } public async modifyResource(): Promise { throw new NotImplementedHttpError('Patches are not supported by the default store.'); } public async deleteResource(identifier: ResourceIdentifier): Promise { this.validateIdentifier(identifier); const metadata = await this.accessor.getMetadata(identifier); // "When a DELETE request targets storage’s root container or its associated ACL resource, // the server MUST respond with the 405 status code." // https://solid.github.io/specification/#deleting-resources if (this.isRootStorage(metadata)) { throw new MethodNotAllowedHttpError('Cannot delete a root storage container.'); } if (metadata.getAll(LDP.contains).length > 0) { throw new ConflictHttpError('Can only delete empty containers.'); } return this.accessor.deleteResource(identifier); } /** * Verify if the given identifier matches the stored base. */ protected validateIdentifier(identifier: ResourceIdentifier): void { if (!this.identifierStrategy.supportsIdentifier(identifier)) { throw new NotFoundHttpError(); } } /** * Returns the metadata matching the identifier, ignoring the presence of a trailing slash or not. * This is used to support the following part of the spec: * "If two URIs differ only in the trailing slash, and the server has associated a resource with one of them, * then the other URI MUST NOT correspond to another resource." * * First the identifier gets requested and if no result is found * the identifier with differing trailing slash is requested. * @param identifier - Identifier that needs to be checked. */ protected async getNormalizedMetadata(identifier: ResourceIdentifier): Promise { const hasSlash = isContainerIdentifier(identifier); try { return await this.accessor.getMetadata(identifier); } catch (error: unknown) { if (error instanceof NotFoundHttpError) { return this.accessor.getMetadata( { path: hasSlash ? trimTrailingSlashes(identifier.path) : ensureTrailingSlash(identifier.path) }, ); } throw error; } } /** * Returns the result of `getNormalizedMetadata` or undefined if a 404 error is thrown. */ protected async getSafeNormalizedMetadata(identifier: ResourceIdentifier): Promise { try { return await this.getNormalizedMetadata(identifier); } catch (error: unknown) { if (!(error instanceof NotFoundHttpError)) { throw error; } } } /** * Write the given resource to the DataAccessor. Metadata will be updated with necessary triples. * In case of containers `handleContainerData` will be used to verify the data. * @param identifier - Identifier of the resource. * @param representation - Corresponding Representation. * @param isContainer - Is the incoming resource a container? * @param createContainers - Should parent containers (potentially) be created? */ protected async writeData(identifier: ResourceIdentifier, representation: Representation, isContainer: boolean, createContainers?: boolean): Promise { if (isContainer) { await this.handleContainerData(representation); } // Root container should not have a parent container if (createContainers && !this.identifierStrategy.isRootContainer(identifier)) { await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(identifier)); } // Make sure the metadata has the correct identifier and correct type quads const { metadata } = representation; metadata.identifier = DataFactory.namedNode(identifier.path); metadata.addQuads(generateResourceQuads(metadata.identifier, isContainer)); await (isContainer ? this.accessor.writeContainer(identifier, representation.metadata) : this.accessor.writeDocument(identifier, representation.data, representation.metadata)); } /** * Verify if the incoming data for a container is valid (RDF and no containment triples). * Adds the container data to its metadata afterwards. * * @param representation - Container representation. */ protected async handleContainerData(representation: Representation): Promise { let quads: Quad[]; try { // No need to parse the data if it already contains internal/quads if (representation.metadata.contentType === INTERNAL_QUADS) { quads = await arrayifyStream(representation.data); } else { quads = await parseQuads(representation.data, representation.metadata.contentType); } } catch (error: unknown) { if (error instanceof Error) { throw new BadRequestHttpError(`Can only create containers with RDF data. ${error.message}`); } throw error; } // Make sure there are no containment triples in the body for (const quad of quads) { if (quad.predicate.value === LDP.contains) { throw new ConflictHttpError('Container bodies are not allowed to have containment triples.'); } } // Input content type doesn't matter anymore representation.metadata.removeAll(CONTENT_TYPE); // Container data is stored in the metadata representation.metadata.addQuads(quads); } /** * Generates a new URI for a resource in the given container, potentially using the given slug. * @param container - Parent container of the new URI. * @param isContainer - Does the new URI represent a container? * @param slug - Slug to use for the new URI. */ protected createURI(container: ResourceIdentifier, isContainer: boolean, slug?: string): ResourceIdentifier { return { path: `${ensureTrailingSlash(container.path)}${slug ? trimTrailingSlashes(slug) : uuid()}${isContainer ? '/' : ''}` }; } /** * Generate a valid URI to store a new Resource in the given container. * URI will be based on the slug header if there is one and is guaranteed to not exist yet. * * @param container - Identifier of the target container. * @param metadata - Metadata of the new resource. * @param parentMetadata - Optional metadata of the parent container. */ protected createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata, parentMetadata?: RepresentationMetadata): ResourceIdentifier { // Get all values needed for naming the resource const isContainer = this.isNewContainer(metadata); const slug = metadata.get(HTTP.slug)?.value; metadata.removeAll(HTTP.slug); let newID: ResourceIdentifier = this.createURI(container, isContainer, slug); // Make sure we don't already have a resource with this exact name (or with differing trailing slash) if (parentMetadata) { const withSlash = ensureTrailingSlash(newID.path); const withoutSlash = trimTrailingSlashes(newID.path); const exists = parentMetadata.getAll(LDP.contains).some((term): boolean => term.value === withSlash || term.value === withoutSlash); if (exists) { newID = this.createURI(container, isContainer); } } return newID; } /** * Checks if the given metadata represents a (potential) container, * both based on the metadata and the URI. * @param metadata - Metadata of the (new) resource. * @param suffix - Suffix of the URI. Can be the full URI, but only the last part is required. */ protected isNewContainer(metadata: RepresentationMetadata, suffix?: string): boolean { // Should not use `isExistingContainer` since the metadata might contain unrelated type triples // It's not because there is no container type triple that the new resource is not a container if (this.hasContainerType(metadata.getAll(RDF.type))) { return true; } const slug = suffix ?? metadata.get(HTTP.slug)?.value; return Boolean(slug && isContainerPath(slug)); } /** * Checks if the given metadata represents a container, purely based on metadata type triples. * Since type metadata always gets generated when writing resources this should never fail on stored resources. * @param metadata - Metadata to check. */ protected isExistingContainer(metadata: RepresentationMetadata): boolean { const types = metadata.getAll(RDF.type); if (!types.some((type): boolean => type.value === LDP.Resource)) { throw new InternalServerError('Unknown resource type.'); } return this.hasContainerType(types); } /** * Checks in a list of types if any of them match a Container type. */ protected hasContainerType(types: Term[]): boolean { return types.some((type): boolean => type.value === LDP.Container || type.value === LDP.BasicContainer); } /** * Verifies if this is the metadata of a root storage container. */ protected isRootStorage(metadata: RepresentationMetadata): boolean { return metadata.getAll(RDF.type).some((term): boolean => term.value === PIM.Storage); } /** * Create containers starting from the root until the given identifier corresponds to an existing container. * Will throw errors if the identifier of the last existing "container" corresponds to an existing document. * @param container - Identifier of the container which will need to exist. */ protected async createRecursiveContainers(container: ResourceIdentifier): Promise { try { const metadata = await this.getNormalizedMetadata(container); if (!this.isExistingContainer(metadata)) { throw new ConflictHttpError(`Creating container ${container.path} conflicts with an existing resource.`); } } catch (error: unknown) { if (error instanceof NotFoundHttpError) { // Make sure the parent exists first await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(container)); await this.writeData(container, this.getEmptyContainerRepresentation(container), true); } else { throw error; } } } /** * Generates the minimal representation for an empty container. * @param container - Identifier of this new container. */ protected getEmptyContainerRepresentation(container: ResourceIdentifier): Representation { return { binary: true, data: guardedStreamFrom([]), metadata: new RepresentationMetadata(container), }; } }