mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: add implementations of pod-related interfaces
This commit is contained in:
parent
39745ccf22
commit
9653deec7f
@ -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': [
|
||||
|
56
src/pods/GeneratedPodManager.ts
Normal file
56
src/pods/GeneratedPodManager.ts
Normal file
@ -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<ResourceIdentifier> {
|
||||
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;
|
||||
}
|
||||
}
|
48
src/pods/agent/AgentJsonParser.ts
Normal file
48
src/pods/agent/AgentJsonParser.ts
Normal file
@ -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<keyof Agent> = 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<void> {
|
||||
if (!input.metadata.contentType || !this.isJSON(input.metadata.contentType)) {
|
||||
throw new NotImplementedHttpError('Only JSON data is supported');
|
||||
}
|
||||
}
|
||||
|
||||
public async handle(input: Representation): Promise<Agent> {
|
||||
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<string>): 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
12
src/pods/generate/IdentifierGenerator.ts
Normal file
12
src/pods/generate/IdentifierGenerator.ts
Normal file
@ -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;
|
||||
}
|
23
src/pods/generate/ResourcesGenerator.ts
Normal file
23
src/pods/generate/ResourcesGenerator.ts
Normal file
@ -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<string>) => AsyncIterable<Resource>;
|
||||
}
|
20
src/pods/generate/SuffixIdentifierGenerator.ts
Normal file
20
src/pods/generate/SuffixIdentifierGenerator.ts
Normal file
@ -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) };
|
||||
}
|
||||
}
|
67
test/unit/pods/GeneratedPodManager.test.ts
Normal file
67
test/unit/pods/GeneratedPodManager.test.ts
Normal file
@ -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<void> => {
|
||||
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<void> => {
|
||||
(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<void> => {
|
||||
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');
|
||||
});
|
||||
});
|
66
test/unit/pods/agent/AgentJsonParser.test.ts
Normal file
66
test/unit/pods/agent/AgentJsonParser.test.ts
Normal file
@ -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<void> => {
|
||||
metadata = new RepresentationMetadata();
|
||||
metadata.contentType = 'application/json';
|
||||
representation = {
|
||||
binary: true,
|
||||
data: guardedStreamFrom([]),
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
it('only supports JSON data.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
representation.data = guardedStreamFrom([ JSON.stringify({
|
||||
login: 'login',
|
||||
webId: 'webId',
|
||||
name: 'name',
|
||||
}) ]);
|
||||
await expect(parser.handle(representation)).resolves
|
||||
.toEqual({
|
||||
login: 'login',
|
||||
webId: 'webId',
|
||||
name: 'name',
|
||||
});
|
||||
});
|
||||
});
|
14
test/unit/pods/generate/SuffixIdentifierGenerator.test.ts
Normal file
14
test/unit/pods/generate/SuffixIdentifierGenerator.test.ts
Normal file
@ -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<void> => {
|
||||
expect(generator.generate('slug')).toEqual({ path: `${base}slug/` });
|
||||
});
|
||||
|
||||
it('converts non-alphanumerics to dashes.', async(): Promise<void> => {
|
||||
expect(generator.generate('sàl/u㋡g')).toEqual({ path: `${base}s-l-u-g/` });
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user