feat: Add implementation for dynamically instantiating pods

This commit is contained in:
Joachim Van Herwegen 2021-02-25 17:04:52 +01:00
parent 88d008e36f
commit b160121176
14 changed files with 477 additions and 0 deletions

View File

@ -108,7 +108,16 @@ export * from './logging/LogUtil';
export * from './logging/VoidLoggerFactory';
export * from './logging/WinstonLoggerFactory';
// Pods/Generate/Variables
export * from './pods/generate/variables/BaseUrlHandler';
export * from './pods/generate/variables/RootFilePathHandler';
export * from './pods/generate/variables/VariableHandler';
export * from './pods/generate/variables/Variables';
export * from './pods/generate/variables/VariableSetter';
// Pods/Generate
export * from './pods/generate/BaseComponentsJsFactory';
export * from './pods/generate/ComponentsJsFactory';
export * from './pods/generate/GenerateUtil';
export * from './pods/generate/HandlebarsTemplateEngine';
export * from './pods/generate/IdentifierGenerator';
@ -116,6 +125,7 @@ export * from './pods/generate/PodGenerator';
export * from './pods/generate/ResourcesGenerator';
export * from './pods/generate/SubdomainIdentifierGenerator';
export * from './pods/generate/SuffixIdentifierGenerator';
export * from './pods/generate/TemplatedPodGenerator';
export * from './pods/generate/TemplateEngine';
export * from './pods/generate/TemplatedResourcesGenerator';

View File

@ -0,0 +1,43 @@
import type { IComponentsManagerBuilderOptions, LogLevel } from 'componentsjs';
import { ComponentsManager } from 'componentsjs';
import { joinFilePath } from '../../util/PathUtil';
import type { ComponentsJsFactory } from './ComponentsJsFactory';
/**
* Can be used to instantiate objects using Components.js.
* Default main module path is the root folder of the project.
* For every generate call a new manager will be made,
* but moduleState will be stored in between calls.
*/
export class BaseComponentsJsFactory implements ComponentsJsFactory {
private readonly options: IComponentsManagerBuilderOptions<any>;
public constructor(relativeModulePath = '../../../', logLevel = 'error') {
this.options = {
mainModulePath: joinFilePath(__dirname, relativeModulePath),
logLevel: logLevel as LogLevel,
dumpErrorState: false,
};
}
private async buildManager(): Promise<ComponentsManager<any>> {
const manager = await ComponentsManager.build(this.options);
this.options.moduleState = manager.moduleState;
return manager;
}
/**
* Calls Components.js to instantiate a new object.
* @param configPath - Location of the config to instantiate.
* @param componentIri - Iri of the object in the config that will be the result.
* @param variables - Variables to send to Components.js
*
* @returns The resulting object, corresponding to the given component IRI.
*/
public async generate<T>(configPath: string, componentIri: string, variables: Record<string, any>):
Promise<T> {
const manager = await this.buildManager();
await manager.configRegistry.register(configPath);
return await manager.instantiate(componentIri, { variables });
}
}

View File

@ -0,0 +1,14 @@
/**
* Used for instantiating new object using Components.js configurations.
*/
export interface ComponentsJsFactory {
/**
* Instantiates a new object using Components.js.
* @param configPath - Location of the config to instantiate.
* @param componentIri - Iri of the object in the config that will be the result.
* @param variables - Variables to send to Components.js
*
* @returns The resulting object, corresponding to the given component IRI.
*/
generate: <T>(configPath: string, componentIri: string, variables: Record<string, any>) => Promise<T>;
}

View File

@ -0,0 +1,86 @@
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
import type { ResourceStore } from '../../storage/ResourceStore';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { ConflictHttpError } from '../../util/errors/ConflictHttpError';
import { joinFilePath } from '../../util/PathUtil';
import type { PodSettings } from '../settings/PodSettings';
import type { ComponentsJsFactory } from './ComponentsJsFactory';
import type { PodGenerator } from './PodGenerator';
import type { VariableHandler } from './variables/VariableHandler';
import { isValidVariable, TEMPLATE, TEMPLATE_VARIABLE } from './variables/Variables';
const DEFAULT_CONFIG_PATH = joinFilePath(__dirname, '../../../config/templates');
/**
* Creates a new ResourceStore when creating a pod based on a Components.js configuration.
*
* Part of the dynamic pod creation.
* 1. It calls a VariableHandler to add necessary variable values.
* E.g. setting the base url variable for components.js to the pod identifier.
* 2. It filters/cleans the input agent values using {@link VariableHandler}s
* 3. It calls a ComponentsJsFactory with the variables and template location to instantiate a new ResourceStore.
* 4. It stores these values in the configuration storage, which is used as a permanent storage for pod configurations.
*
* @see {@link ConfigPodManager}, {@link ConfigPodInitializer}, {@link BaseUrlRouterRule}
*/
export class TemplatedPodGenerator implements PodGenerator {
protected readonly logger = getLoggerFor(this);
private readonly storeFactory: ComponentsJsFactory;
private readonly variableHandler: VariableHandler;
private readonly configStorage: KeyValueStorage<string, unknown>;
private readonly configTemplatePath: string;
/**
* @param storeFactory - Factory used for Components.js instantiation.
* @param variableHandler - Handler used for setting variable values.
* @param configStorage - Where to store the configuration values to instantiate the store for this pod.
* @param configTemplatePath - Where to find the configuration templates.
*/
public constructor(storeFactory: ComponentsJsFactory, variableHandler: VariableHandler,
configStorage: KeyValueStorage<string, unknown>, configTemplatePath?: string) {
this.storeFactory = storeFactory;
this.variableHandler = variableHandler;
this.configStorage = configStorage;
this.configTemplatePath = configTemplatePath ?? DEFAULT_CONFIG_PATH;
}
public async generate(identifier: ResourceIdentifier, settings: PodSettings): Promise<ResourceStore> {
if (!settings.template) {
throw new BadRequestHttpError('Settings require template field.');
}
if (await this.configStorage.has(identifier.path)) {
this.logger.warn(`There already is a pod at ${identifier.path}`);
throw new ConflictHttpError(`There already is a pod at ${identifier.path}`);
}
await this.variableHandler.handleSafe({ identifier, settings });
// Filter out irrelevant data in the agent
const variables: NodeJS.Dict<string> = {};
for (const key of Object.keys(settings)) {
if (isValidVariable(key)) {
variables[key] = settings[key];
}
}
// Prevent unsafe template names
if (!/^[a-zA-Z0-9.-]+$/u.test(settings.template)) {
this.logger.warn(`Invalid template name ${settings.template}`);
throw new BadRequestHttpError(`Invalid template name ${settings.template}`);
}
// Storing the template in the variables so it also gets stored in the config for later re-use
variables[TEMPLATE_VARIABLE.templateConfig] = joinFilePath(this.configTemplatePath, settings.template);
const store: ResourceStore =
await this.storeFactory.generate(variables[TEMPLATE_VARIABLE.templateConfig]!, TEMPLATE.ResourceStore, variables);
this.logger.debug(`Generating store ${identifier.path} with variables ${JSON.stringify(variables)}`);
// Store the variables permanently
await this.configStorage.set(identifier.path, variables);
return store;
}
}

View File

@ -0,0 +1,16 @@
import type { ResourceIdentifier } from '../../../ldp/representation/ResourceIdentifier';
import type { PodSettings } from '../../settings/PodSettings';
import { VariableHandler } from './VariableHandler';
import { TEMPLATE_VARIABLE } from './Variables';
/**
* Adds the pod identifier as base url variable to the agent.
* This allows for config templates that require a value for TEMPLATE_BASE_URL_URN,
* which should equal the pod identifier.
*/
export class BaseUrlHandler extends VariableHandler {
public async handle({ identifier, settings }: { identifier: ResourceIdentifier; settings: PodSettings }):
Promise<void> {
settings[TEMPLATE_VARIABLE.baseUrl] = identifier.path;
}
}

View File

@ -0,0 +1,37 @@
import { promises as fsPromises } from 'fs';
import type { ResourceIdentifier } from '../../../ldp/representation/ResourceIdentifier';
import type { FileIdentifierMapper } from '../../../storage/mapping/FileIdentifierMapper';
import { ConflictHttpError } from '../../../util/errors/ConflictHttpError';
import { isSystemError } from '../../../util/errors/SystemError';
import type { PodSettings } from '../../settings/PodSettings';
import { VariableHandler } from './VariableHandler';
import { TEMPLATE_VARIABLE } from './Variables';
/**
* Uses a FileIdentifierMapper to generate a root file path variable based on the identifier.
* Will throw an error if the resulting file path already exists.
*/
export class RootFilePathHandler extends VariableHandler {
private readonly fileMapper: FileIdentifierMapper;
public constructor(fileMapper: FileIdentifierMapper) {
super();
this.fileMapper = fileMapper;
}
public async handle({ identifier, settings }: { identifier: ResourceIdentifier; settings: PodSettings }):
Promise<void> {
const path = (await this.fileMapper.mapUrlToFilePath(identifier)).filePath;
try {
// Even though we check if it already exists, there is still a potential race condition
// in between this check and the store being created.
await fsPromises.access(path);
throw new ConflictHttpError(`There already is a folder that corresponds to ${identifier.path}`);
} catch (error: unknown) {
if (!(isSystemError(error) && error.code === 'ENOENT')) {
throw error;
}
settings[TEMPLATE_VARIABLE.rootFilePath] = path;
}
}
}

View File

@ -0,0 +1,11 @@
import type { ResourceIdentifier } from '../../../ldp/representation/ResourceIdentifier';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { PodSettings } from '../../settings/PodSettings';
/**
* Updates the variables stored in the given agent.
* Can be used to set variables that are required for the Components.js instantiation
* but which should not be provided by the request.
* E.g.: The exact file path (when required) should be determined by the server to prevent abuse.
*/
export abstract class VariableHandler extends AsyncHandler<{ identifier: ResourceIdentifier; settings: PodSettings }> {}

View File

@ -0,0 +1,25 @@
import type { PodSettings } from '../../settings/PodSettings';
import { VariableHandler } from './VariableHandler';
/**
* A VariableHandler that will set the given variable to the given value,
* unless there already is a value for the variable and override is false.
*/
export class VariableSetter extends VariableHandler {
private readonly variable: string;
private readonly value: string;
private readonly override: boolean;
public constructor(variable: string, value: string, override = false) {
super();
this.variable = variable;
this.value = value;
this.override = override;
}
public async handle({ settings }: { settings: PodSettings }): Promise<void> {
if (this.override || !settings[this.variable]) {
settings[this.variable] = this.value;
}
}
}

View File

@ -0,0 +1,20 @@
import { createUriAndTermNamespace } from '../../../util/Vocabularies';
export const TEMPLATE = createUriAndTermNamespace('urn:solid-server:template:',
'ResourceStore');
// Variables used for configuration templates
// This is not an exclusive list
export const TEMPLATE_VARIABLE = createUriAndTermNamespace(`${TEMPLATE.namespace}variable:`,
'baseUrl',
'rootFilePath',
'sparqlEndpoint',
'templateConfig');
/**
* Checks if the given variable is one that is supported.
* This can be used to weed out irrelevant parameters in an object.
*/
export function isValidVariable(variable: string): boolean {
return variable.startsWith(TEMPLATE_VARIABLE.namespace);
}

View File

@ -0,0 +1,39 @@
import type { ComponentsManager } from 'componentsjs';
import { BaseComponentsJsFactory } from '../../../../src/pods/generate/BaseComponentsJsFactory';
const manager: jest.Mocked<ComponentsManager<any>> = {
instantiate: jest.fn(async(): Promise<any> => 'store!'),
configRegistry: {
register: jest.fn(),
},
} as any;
jest.mock('componentsjs', (): any => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
ComponentsManager: {
build: jest.fn(async(): Promise<ComponentsManager<any>> => manager),
},
}));
describe('A BaseComponentsJsFactory', (): void => {
let factory: BaseComponentsJsFactory;
const configPath = 'config!';
const componentIri = 'componentIri!';
const variables = {
aa: 'b',
cc: 'd',
};
beforeEach(async(): Promise<void> => {
jest.clearAllMocks();
factory = new BaseComponentsJsFactory();
});
it('calls Components.js with the given values.', async(): Promise<void> => {
await expect(factory.generate(configPath, componentIri, variables)).resolves.toBe('store!');
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register).toHaveBeenLastCalledWith(configPath);
expect(manager.instantiate).toHaveBeenCalledTimes(1);
expect(manager.instantiate).toHaveBeenLastCalledWith(componentIri, { variables });
});
});

View File

@ -0,0 +1,84 @@
import type { ComponentsJsFactory } from '../../../../src/pods/generate/ComponentsJsFactory';
import { TemplatedPodGenerator } from '../../../../src/pods/generate/TemplatedPodGenerator';
import type { VariableHandler } from '../../../../src/pods/generate/variables/VariableHandler';
import { TEMPLATE, TEMPLATE_VARIABLE } from '../../../../src/pods/generate/variables/Variables';
import type { PodSettings } from '../../../../src/pods/settings/PodSettings';
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError';
import { joinFilePath } from '../../../../src/util/PathUtil';
describe('A TemplatedPodGenerator', (): void => {
const configTemplatePath = 'templates/config/';
const template = 'config-template.json';
const templatePath = `${configTemplatePath}${template}`;
const identifier = { path: 'http://test.com/alice/' };
let settings: PodSettings;
let storeFactory: ComponentsJsFactory;
let variableHandler: VariableHandler;
let configStorage: KeyValueStorage<string, unknown>;
let generator: TemplatedPodGenerator;
beforeEach(async(): Promise<void> => {
settings = { template } as any;
storeFactory = {
generate: jest.fn().mockResolvedValue('store'),
} as any;
variableHandler = {
handleSafe: jest.fn(),
} as any;
configStorage = new Map<string, unknown>() as any;
generator = new TemplatedPodGenerator(storeFactory, variableHandler, configStorage, configTemplatePath);
});
it('only supports settings with a template.', async(): Promise<void> => {
(settings as any).template = undefined;
await expect(generator.generate(identifier, settings)).rejects.toThrow(BadRequestHttpError);
});
it('generates a store and stores relevant variables.', async(): Promise<void> => {
await expect(generator.generate(identifier, settings)).resolves.toBe('store');
expect(variableHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(variableHandler.handleSafe).toHaveBeenLastCalledWith({ identifier, settings });
expect(storeFactory.generate).toHaveBeenCalledTimes(1);
expect(storeFactory.generate).toHaveBeenLastCalledWith(
templatePath, TEMPLATE.ResourceStore, { [TEMPLATE_VARIABLE.templateConfig]: templatePath },
);
expect(configStorage.get(identifier.path)).toEqual({ [TEMPLATE_VARIABLE.templateConfig]: templatePath });
});
it('rejects identifiers that already have a config.', async(): Promise<void> => {
await configStorage.set(identifier.path, {});
await expect(generator.generate(identifier, settings)).rejects.toThrow(ConflictHttpError);
});
it('rejects invalid config template names.', async(): Promise<void> => {
settings.template = '../../secret-file.json';
await expect(generator.generate(identifier, settings)).rejects.toThrow(BadRequestHttpError);
});
it('only stores relevant variables from an agent object.', async(): Promise<void> => {
settings[TEMPLATE_VARIABLE.rootFilePath] = 'correctFilePath';
settings.login = 'should not be stored';
await expect(generator.generate(identifier, settings)).resolves.toBe('store');
expect(configStorage.get(identifier.path)).toEqual({
[TEMPLATE_VARIABLE.templateConfig]: templatePath,
[TEMPLATE_VARIABLE.rootFilePath]: 'correctFilePath',
});
});
it('uses a default template folder if none is provided.', async(): Promise<void> => {
generator = new TemplatedPodGenerator(storeFactory, variableHandler, configStorage);
const defaultPath = joinFilePath(__dirname, '../../../../config/templates/', template);
await expect(generator.generate(identifier, settings)).resolves.toBe('store');
expect(storeFactory.generate)
.toHaveBeenLastCalledWith(defaultPath, TEMPLATE.ResourceStore, {
[TEMPLATE_VARIABLE.templateConfig]: defaultPath,
});
});
});

View File

@ -0,0 +1,14 @@
import { BaseUrlHandler } from '../../../../../src/pods/generate/variables/BaseUrlHandler';
import { TEMPLATE_VARIABLE } from '../../../../../src/pods/generate/variables/Variables';
import type { PodSettings } from '../../../../../src/pods/settings/PodSettings';
describe('A BaseUrlHandler', (): void => {
const handler = new BaseUrlHandler();
it('adds the identifier as base URL variable.', async(): Promise<void> => {
const identifier = { path: 'http://test.com/foo' };
const settings = {} as PodSettings;
await expect(handler.handle({ identifier, settings })).resolves.toBeUndefined();
expect(settings[TEMPLATE_VARIABLE.baseUrl]).toBe(identifier.path);
});
});

View File

@ -0,0 +1,45 @@
import fs from 'fs';
import { RootFilePathHandler } from '../../../../../src/pods/generate/variables/RootFilePathHandler';
import { TEMPLATE_VARIABLE } from '../../../../../src/pods/generate/variables/Variables';
import type { PodSettings } from '../../../../../src/pods/settings/PodSettings';
import type { ResourceLink } from '../../../../../src/storage/mapping/FileIdentifierMapper';
import { ConflictHttpError } from '../../../../../src/util/errors/ConflictHttpError';
import { joinFilePath } from '../../../../../src/util/PathUtil';
jest.mock('fs');
describe('A RootFilePathHandler', (): void => {
const rootFilePath = 'files/';
const baseUrl = 'http://test.com/';
let handler: RootFilePathHandler;
const identifier = { path: 'http://test.com/alice/' };
let settings: PodSettings;
let fsPromises: Record<string, jest.Mock>;
beforeEach(async(): Promise<void> => {
settings = {} as any;
handler = new RootFilePathHandler({
mapUrlToFilePath: async(id): Promise<ResourceLink> => ({
identifier: id,
filePath: joinFilePath(rootFilePath, id.path.slice(baseUrl.length)),
}),
mapFilePathToUrl: jest.fn(),
});
fs.promises = {
access: jest.fn(),
} as any;
fsPromises = fs.promises as any;
});
it('errors if the target folder already exists.', async(): Promise<void> => {
await expect(handler.handle({ identifier, settings })).rejects.toThrow(ConflictHttpError);
});
it('adds the new file path as variable.', async(): Promise<void> => {
fsPromises.access.mockRejectedValue({ code: 'ENOENT', syscall: 'access' });
await expect(handler.handle({ identifier, settings })).resolves.toBeUndefined();
expect(settings[TEMPLATE_VARIABLE.rootFilePath]).toBe(joinFilePath(rootFilePath, 'alice/'));
});
});

View File

@ -0,0 +1,33 @@
import { VariableSetter } from '../../../../../src/pods/generate/variables/VariableSetter';
import type { PodSettings } from '../../../../../src/pods/settings/PodSettings';
describe('A VariableSetter', (): void => {
const variable = 'variable';
const value = 'http://test.com/sparql';
let settings: PodSettings;
let handler: VariableSetter;
beforeEach(async(): Promise<void> => {
settings = {} as any;
});
it('does nothing if there already is a sparql endpoint value and override is false.', async(): Promise<void> => {
handler = new VariableSetter(variable, value);
settings[variable] = 'sparql-endpoint';
await expect(handler.handle({ settings })).resolves.toBeUndefined();
expect(settings[variable]).toBe('sparql-endpoint');
});
it('adds adds the value to the variable if there is none.', async(): Promise<void> => {
handler = new VariableSetter(variable, value);
await expect(handler.handle({ settings })).resolves.toBeUndefined();
expect(settings[variable]).toBe(value);
});
it('always sets the value if override is true.', async(): Promise<void> => {
handler = new VariableSetter(variable, value, true);
settings[variable] = 'sparql-endpoint';
await expect(handler.handle({ settings })).resolves.toBeUndefined();
expect(settings[variable]).toBe(value);
});
});