diff --git a/.eslintrc.js b/.eslintrc.js index f9752ad7d..14ee9c097 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,8 @@ module.exports = { project: ['./tsconfig.json'], }, globals: { - NodeJS: 'readonly' + AsyncIterable: 'readonly', + NodeJS: 'readonly', }, plugins: [ 'tsdoc', @@ -38,6 +39,7 @@ module.exports = { 'class-methods-use-this': 'off', // conflicts with functions from interfaces that sometimes don't require `this` 'comma-dangle': ['error', 'always-multiline'], 'dot-location': ['error', 'property'], + 'generator-star-spacing': ['error', 'after'], 'lines-around-comment': 'off', // conflicts with padded-blocks 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 'max-len': ['error', { code: 120, ignoreUrls: true }], @@ -54,6 +56,7 @@ module.exports = { 'unicorn/no-object-as-default-parameter': 'off', 'unicorn/numeric-separators-style': 'off', 'unicorn/prefer-ternary': 'off', // can get ugly with large single statements + 'yield-star-spacing': ['error', 'after'], // Naming conventions '@typescript-eslint/naming-convention': [ diff --git a/src/pods/GeneratedPodManager.ts b/src/pods/GeneratedPodManager.ts new file mode 100644 index 000000000..72e188762 --- /dev/null +++ b/src/pods/GeneratedPodManager.ts @@ -0,0 +1,56 @@ +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { getLoggerFor } from '../logging/LogUtil'; +import type { ResourceStore } from '../storage/ResourceStore'; +import { ConflictHttpError } from '../util/errors/ConflictHttpError'; +import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; +import type { Agent } from './agent/Agent'; +import type { IdentifierGenerator } from './generate/IdentifierGenerator'; +import type { ResourcesGenerator } from './generate/ResourcesGenerator'; +import type { PodManager } from './PodManager'; + +/** + * Pod manager that uses an {@link IdentifierGenerator} and {@link ResourcesGenerator} + * to create the default resources and identifier for a new pod. + */ +export class GeneratedPodManager implements PodManager { + protected readonly logger = getLoggerFor(this); + + private readonly store: ResourceStore; + private readonly idGenerator: IdentifierGenerator; + private readonly resourcesGenerator: ResourcesGenerator; + + public constructor(store: ResourceStore, idGenerator: IdentifierGenerator, resourcesGenerator: ResourcesGenerator) { + this.store = store; + this.idGenerator = idGenerator; + this.resourcesGenerator = resourcesGenerator; + } + + /** + * Creates a new pod, pre-populating it with the resources created by the data generator. + * Pod identifiers are created based on the identifier generator. + * Will throw an error if the given identifier already has a resource. + */ + public async createPod(agent: Agent): Promise { + const podIdentifier = this.idGenerator.generate(agent.login); + this.logger.info(`Creating pod ${podIdentifier.path}`); + try { + const result = await this.store.getRepresentation(podIdentifier, {}); + result.data.destroy(); + throw new ConflictHttpError(`There already is a resource at ${podIdentifier.path}`); + } catch (error: unknown) { + // We want the identifier to not exist + if (!(error instanceof NotFoundHttpError)) { + throw error; + } + } + + const resources = this.resourcesGenerator.generate(podIdentifier, agent); + let count = 0; + for await (const { identifier, representation } of resources) { + await this.store.setRepresentation(identifier, representation); + count += 1; + } + this.logger.info(`Added ${count} resources to ${podIdentifier.path}`); + return podIdentifier; + } +} diff --git a/src/pods/agent/AgentJsonParser.ts b/src/pods/agent/AgentJsonParser.ts new file mode 100644 index 000000000..aa305e40d --- /dev/null +++ b/src/pods/agent/AgentJsonParser.ts @@ -0,0 +1,48 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readableToString } from '../../util/StreamUtil'; +import type { Agent } from './Agent'; +import { AgentParser } from './AgentParser'; +import Dict = NodeJS.Dict; + +const requiredKeys: (keyof Agent)[] = [ 'login', 'webId' ]; +const optionalKeys: (keyof Agent)[] = [ 'name', 'email' ]; +const agentKeys: Set = new Set(requiredKeys.concat(optionalKeys)); + +/** + * A parser that extracts Agent data from a JSON body. + */ +export class AgentJsonParser extends AgentParser { + public async canHandle(input: Representation): Promise { + if (!input.metadata.contentType || !this.isJSON(input.metadata.contentType)) { + throw new NotImplementedHttpError('Only JSON data is supported'); + } + } + + public async handle(input: Representation): Promise { + const result = JSON.parse(await readableToString(input.data)); + this.isValidAgent(result); + return result; + } + + private isJSON(mediaType: string): boolean { + return mediaType === 'application/json' || mediaType.endsWith('+json'); + } + + /** + * Checks if all keys in the object are valid Agent keys and if all required keys are there. + */ + private isValidAgent(data: Dict): asserts data is Agent { + for (const key of Object.keys(data)) { + if (!agentKeys.has(key as keyof Agent)) { + throw new BadRequestHttpError(`${key} is not a valid Agent key`); + } + } + for (const key of requiredKeys) { + if (!data[key]) { + throw new BadRequestHttpError(`Input data is missing Agent key ${key}`); + } + } + } +} diff --git a/src/pods/generate/IdentifierGenerator.ts b/src/pods/generate/IdentifierGenerator.ts new file mode 100644 index 000000000..ea0a860e6 --- /dev/null +++ b/src/pods/generate/IdentifierGenerator.ts @@ -0,0 +1,12 @@ +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; + +/** + * Utility class for generating container identifiers. + */ +export interface IdentifierGenerator { + /** + * Generates container identifiers based on an input slug. + * This is simply string generation, no resource-related checks are run. + */ + generate: (slug: string) => ResourceIdentifier; +} diff --git a/src/pods/generate/ResourcesGenerator.ts b/src/pods/generate/ResourcesGenerator.ts new file mode 100644 index 000000000..842e60569 --- /dev/null +++ b/src/pods/generate/ResourcesGenerator.ts @@ -0,0 +1,23 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import Dict = NodeJS.Dict; + +export interface Resource { + identifier: ResourceIdentifier; + representation: Representation; +} + +/** + * Generator used to create resources relative to a given base identifier. + */ +export interface ResourcesGenerator { + /** + * Generates resources with the given options. + * The output Map should be sorted so that containers always appear before their contents. + * @param location - Base identifier. + * @param options - Options that can be used when generating resources. + * + * @returns A map where the keys are the identifiers and the values the corresponding representations to store. + */ + generate: (location: ResourceIdentifier, options: Dict) => AsyncIterable; +} diff --git a/src/pods/generate/SuffixIdentifierGenerator.ts b/src/pods/generate/SuffixIdentifierGenerator.ts new file mode 100644 index 000000000..0af56d978 --- /dev/null +++ b/src/pods/generate/SuffixIdentifierGenerator.ts @@ -0,0 +1,20 @@ +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { ensureTrailingSlash } from '../../util/PathUtil'; +import type { IdentifierGenerator } from './IdentifierGenerator'; + +/** + * Generates identifiers by appending the slug to a stored base identifier. + * Non-alphanumeric characters will be replaced with `-`. + */ +export class SuffixIdentifierGenerator implements IdentifierGenerator { + private readonly base: string; + + public constructor(base: string) { + this.base = base; + } + + public generate(slug: string): ResourceIdentifier { + const cleanSlug = slug.replace(/\W/gu, '-'); + return { path: ensureTrailingSlash(new URL(cleanSlug, this.base).href) }; + } +} diff --git a/test/unit/pods/GeneratedPodManager.test.ts b/test/unit/pods/GeneratedPodManager.test.ts new file mode 100644 index 000000000..f76969dcc --- /dev/null +++ b/test/unit/pods/GeneratedPodManager.test.ts @@ -0,0 +1,67 @@ +import { Readable } from 'stream'; +import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; +import type { Agent } from '../../../src/pods/agent/Agent'; +import type { IdentifierGenerator } from '../../../src/pods/generate/IdentifierGenerator'; +import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator'; +import { GeneratedPodManager } from '../../../src/pods/GeneratedPodManager'; +import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; + +describe('A GeneratedPodManager', (): void => { + const base = 'http://test.com/'; + let agent: Agent; + let store: ResourceStore; + let generatorData: Resource[]; + const idGenerator: IdentifierGenerator = { + generate: (slug: string): ResourceIdentifier => ({ path: `${base}${slug}/` }), + }; + let resGenerator: ResourcesGenerator; + let manager: GeneratedPodManager; + + beforeEach(async(): Promise => { + agent = { + login: 'user', + name: 'first last', + webId: 'http://secure/webId', + }; + store = { + getRepresentation: jest.fn((): any => { + throw new NotFoundHttpError(); + }), + setRepresentation: jest.fn(), + } as any; + generatorData = [ + { identifier: { path: '/path/' }, representation: '/' as any }, + { identifier: { path: '/path/a/' }, representation: '/a/' as any }, + { identifier: { path: '/path/a/b' }, representation: '/a/b' as any }, + ]; + resGenerator = { + generate: jest.fn(async function* (): any { + yield* generatorData; + }), + }; + manager = new GeneratedPodManager(store, idGenerator, resGenerator); + }); + + it('throws an error if the generate identifier is not available.', async(): Promise => { + (store.getRepresentation as jest.Mock).mockImplementationOnce((): any => ({ + data: Readable.from([]), + metadata: new RepresentationMetadata(), + binary: true, + })); + const result = manager.createPod(agent); + await expect(result).rejects.toThrow(`There already is a resource at ${base}user/`); + await expect(result).rejects.toThrow(ConflictHttpError); + }); + + it('generates an identifier and writes containers before writing the resources in them.', async(): Promise => { + await expect(manager.createPod(agent)).resolves.toEqual({ path: `${base}${agent.login}/` }); + + expect(store.setRepresentation).toHaveBeenCalledTimes(3); + expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/'); + expect(store.setRepresentation).toHaveBeenNthCalledWith(2, { path: '/path/a/' }, '/a/'); + expect(store.setRepresentation).toHaveBeenNthCalledWith(3, { path: '/path/a/b' }, '/a/b'); + }); +}); diff --git a/test/unit/pods/agent/AgentJsonParser.test.ts b/test/unit/pods/agent/AgentJsonParser.test.ts new file mode 100644 index 000000000..642ec2ec4 --- /dev/null +++ b/test/unit/pods/agent/AgentJsonParser.test.ts @@ -0,0 +1,66 @@ +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import { AgentJsonParser } from '../../../../src/pods/agent/AgentJsonParser'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; + +describe('An AgentJsonParser', (): void => { + let metadata: RepresentationMetadata; + let representation: Representation; + const parser = new AgentJsonParser(); + + beforeEach(async(): Promise => { + metadata = new RepresentationMetadata(); + metadata.contentType = 'application/json'; + representation = { + binary: true, + data: guardedStreamFrom([]), + metadata, + }; + }); + + it('only supports JSON data.', async(): Promise => { + metadata.contentType = undefined; + const result = parser.canHandle(representation); + await expect(result).rejects.toThrow(NotImplementedHttpError); + await expect(result).rejects.toThrow('Only JSON data is supported'); + metadata.contentType = 'application/json'; + await expect(parser.canHandle(representation)).resolves.toBeUndefined(); + metadata.contentType = 'application/ld+json'; + await expect(parser.canHandle(representation)).resolves.toBeUndefined(); + }); + + it('errors if required keys are missing.', async(): Promise => { + representation.data = guardedStreamFrom([ JSON.stringify({ login: 'login' }) ]); + const result = parser.handle(representation); + await expect(result).rejects.toThrow(BadRequestHttpError); + await expect(result).rejects.toThrow('Input data is missing Agent key webId'); + }); + + it('errors if unknown keys are present.', async(): Promise => { + representation.data = guardedStreamFrom([ JSON.stringify({ + login: 'login', + webId: 'webId', + name: 'name', + unknown: 'unknown', + }) ]); + const result = parser.handle(representation); + await expect(result).rejects.toThrow(BadRequestHttpError); + await expect(result).rejects.toThrow('unknown is not a valid Agent key'); + }); + + it('generates a User object.', async(): Promise => { + representation.data = guardedStreamFrom([ JSON.stringify({ + login: 'login', + webId: 'webId', + name: 'name', + }) ]); + await expect(parser.handle(representation)).resolves + .toEqual({ + login: 'login', + webId: 'webId', + name: 'name', + }); + }); +}); diff --git a/test/unit/pods/generate/SuffixIdentifierGenerator.test.ts b/test/unit/pods/generate/SuffixIdentifierGenerator.test.ts new file mode 100644 index 000000000..724e14cda --- /dev/null +++ b/test/unit/pods/generate/SuffixIdentifierGenerator.test.ts @@ -0,0 +1,14 @@ +import { SuffixIdentifierGenerator } from '../../../../src/pods/generate/SuffixIdentifierGenerator'; + +describe('A SuffixIdentifierGenerator', (): void => { + const base = 'http://test.com/'; + const generator = new SuffixIdentifierGenerator(base); + + it('generates identifiers by appending the slug.', async(): Promise => { + expect(generator.generate('slug')).toEqual({ path: `${base}slug/` }); + }); + + it('converts non-alphanumerics to dashes.', async(): Promise => { + expect(generator.generate('sàl/u㋡g')).toEqual({ path: `${base}s-l-u-g/` }); + }); +});