feat: Allow for custom CLI and variable options

* feat: (AppRunner) Mechanism to configure cli args and derive componentsjs vars from them implemented

* fix: (AppRunner) tidying

* fix: (AppRunner) tidying up

* fix: (AppRunner) runCli method made sync

* fix; (VarResolver) refactored to multiple files, and other stylistic fixes.

* chore: (AppRunner) Uses builder pattern for yargs base arguments setup to enable better typescript inference

* fix(AppRunner): refactoring AppRunner and VarResolver

* fix(AppRunner): refactoring AppRunner promise handling

* fix(AppRunner): verror dependency removal

* fix: Simplify CLI error handling

* feat: Use same config for both CLI and app instantiation

* fix: Update typings and imports

* feat: Split VariableResolver behaviour to 2 classes

* feat: Move default value behaviour from CLI to ValueComputers

* test: Add unit tests for new CLI classes

* feat: Integrate new CLI configuration with all default configurations

* feat: Add createApp function to AppRunner

* docs: Update comments in CLI-related classes

* fix: Various fixes and refactors

Co-authored-by: damooo <damodara@protonmail.com>
This commit is contained in:
Joachim Van Herwegen 2022-02-11 10:00:12 +01:00 committed by GitHub
parent d067165b68
commit c216efd62f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1026 additions and 373 deletions

View File

@ -3,16 +3,23 @@
## v3.0.0 ## v3.0.0
### New features ### New features
- The Identity Provider now uses the `webid` scope as required for Solid-OIDC. - The Identity Provider now uses the `webid` scope as required for Solid-OIDC.
- The `VoidLocker` can be used to disable locking for development/testing purposes. This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` - The `VoidLocker` can be used to disable locking for development/testing purposes.
This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json`
- Added support for setting a quota on the server. See the `config/quota-file.json` config for an example. - Added support for setting a quota on the server. See the `config/quota-file.json` config for an example.
- An official docker image is now built on each version tag and published at https://hub.docker.com/r/solidproject/community-server. - An official docker image is now built on each version tag and published at https://hub.docker.com/r/solidproject/community-server.
- Added support for N3 Patch. - Added support for N3 Patch.
- It is now possible to customize arguments to the `community-solid-server` command,
which enables passing custom variables to configurations and setting new default values.
- The AppRunner functions have changed to require Components.js variables.
This is important for anyone who starts the server from code.
### Configuration changes ### Configuration changes
You might need to make changes to your v2 configuration if you use a custom config. You might need to make changes to your v2 configuration if you use a custom config.
The following changes pertain to the imports in the default configs: The following changes pertain to the imports in the default configs:
- ... - A new configuration option needs to be imported:
- `/app/variables/default/json` contains everything related to parsing CLI arguments
and assigning values to variables.
The following changes are relevant for v2 custom configs that replaced certain features. The following changes are relevant for v2 custom configs that replaced certain features.
- Conversion has been simplified so most converters are part of the conversion chain: - Conversion has been simplified so most converters are part of the conversion chain:

View File

@ -1,4 +1,3 @@
#!/usr/bin/env node #!/usr/bin/env node
// eslint-disable-next-line @typescript-eslint/naming-convention
const { AppRunner } = require('..'); const { AppRunner } = require('..');
new AppRunner().runCli(process); new AppRunner().runCliSync(process);

View File

@ -20,3 +20,11 @@ Handles the setup page the first time the server is started.
* *optional*: Setup is available at `/setup` but the server can already be used. * *optional*: Setup is available at `/setup` but the server can already be used.
Everyone can access the setup page so make sure to complete that as soon as possible. Everyone can access the setup page so make sure to complete that as soon as possible.
* *required*: All requests will be redirected to the setup page until setup is completed. * *required*: All requests will be redirected to the setup page until setup is completed.
## Variables
Handles parsing CLI parameters and assigning values to Components.js variables.
Some parts of the configuration contains variables that can be set as arguments on the command-line.
That way, you don't have to edit the configuration files for small changes,
such as starting the server with a different hostname.
Here, you can customize the mapping from CLI arguments into values for those variables.
* *default*: Assigns CLI parameters for all variables defined in `/config/util/variables/default.json`

View File

@ -0,0 +1,67 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Extracts CLI arguments into a key/value object. Config and mainModulePath are only defined here so their description is returned.",
"@id": "urn:solid-server-app-setup:default:CliExtractor",
"@type": "YargsCliExtractor",
"parameters": {
"config": {
"alias": "c",
"requiresArg": true,
"type": "string",
"describe": "The configuration for the server. The default only stores data in memory; to persist to your filesystem, use @css:config/file.json."
},
"mainModulePath": {
"alias": "m",
"requiresArg": true,
"type": "string",
"describe": "Path from where Components.js will start its lookup when initializing configurations."
},
"loggingLevel": {
"alias": "l",
"requiresArg": true,
"type": "string",
"describe": "The detail level of logging; useful for debugging problems."
},
"baseUrl": {
"alias": "b",
"requiresArg": true,
"type": "string",
"describe": "The public URL of your server."
},
"port": {
"alias": "p",
"requiresArg": true,
"type": "number",
"describe": "The TCP port on which the server runs."
},
"rootFilePath": {
"alias": "f",
"requiresArg": true,
"type": "string",
"describe": "Root folder of the server, when using a file-based configuration."
},
"showStackTrace": {
"alias": "t",
"type": "boolean",
"describe": "Enables detailed logging on error pages."
},
"sparqlEndpoint": {
"alias": "s",
"requiresArg": true,
"type": "string",
"describe": "URL of the SPARQL endpoint, when using a quadstore-based configuration."
},
"podConfigJson": {
"requiresArg": true,
"type": "string",
"describe": "Path to the file that keeps track of dynamic Pod configurations."
}
},
"options": {
"usage": "node ./bin/server.js [args]"
}
}
]
}

View File

@ -0,0 +1,16 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"import": [
"files-scs:config/app/variables/cli/cli.json",
"files-scs:config/app/variables/resolver/resolver.json"
],
"@graph": [
{
"comment": "Combines a CliExtractor and SettingsResolver to be used by the AppRunner.",
"@id": "urn:solid-server-app-setup:default:CliResolver",
"@type": "CliResolver",
"cliExtractor": { "@id": "urn:solid-server-app-setup:default:CliExtractor" },
"settingsResolver": { "@id": "urn:solid-server-app-setup:default:SettingsResolver" }
}
]
}

View File

@ -0,0 +1,65 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Converts an input key/value object into an object mapping values to Components.js variables",
"@id": "urn:solid-server-app-setup:default:SettingsResolver",
"@type": "CombinedSettingsResolver",
"computers": [
{
"CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:baseUrl",
"CombinedSettingsResolver:_computers_value": {
"@type": "BaseUrlExtractor"
}
},
{
"CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:loggingLevel",
"CombinedSettingsResolver:_computers_value": {
"@type": "KeyExtractor",
"key": "loggingLevel",
"defaultValue": "info"
}
},
{
"CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:port",
"CombinedSettingsResolver:_computers_value": {
"@type": "KeyExtractor",
"key": "port",
"defaultValue": 3000
}
},
{
"CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:rootFilePath",
"CombinedSettingsResolver:_computers_value": {
"@type": "AssetPathExtractor",
"key": "rootFilePath",
"defaultPath": "./"
}
},
{
"CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:sparqlEndpoint",
"CombinedSettingsResolver:_computers_value": {
"@type": "KeyExtractor",
"key": "sparqlEndpoint"
}
},
{
"CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:showStackTrace",
"CombinedSettingsResolver:_computers_value": {
"@type": "KeyExtractor",
"key": "showStackTrace",
"defaultValue": false
}
},
{
"CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:AssetPathResolver",
"CombinedSettingsResolver:_computers_value": {
"@type": "AssetPathExtractor",
"key": "podConfigJson",
"defaultPath": "./pod-config.json"
}
}
]
}
]
}

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/initialize-prefilled-root.json", "files-scs:config/app/init/initialize-prefilled-root.json",
"files-scs:config/app/setup/optional.json", "files-scs:config/app/setup/optional.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/default.json", "files-scs:config/app/init/default.json",
"files-scs:config/app/setup/required.json", "files-scs:config/app/setup/required.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/default.json", "files-scs:config/app/init/default.json",
"files-scs:config/app/setup/required.json", "files-scs:config/app/setup/required.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/initialize-root.json", "files-scs:config/app/init/initialize-root.json",
"files-scs:config/app/setup/disabled.json", "files-scs:config/app/setup/disabled.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/default.json", "files-scs:config/app/init/default.json",
"files-scs:config/app/setup/required.json", "files-scs:config/app/setup/required.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/initialize-root.json", "files-scs:config/app/init/initialize-root.json",
"files-scs:config/app/setup/optional.json", "files-scs:config/app/setup/optional.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/initialize-root.json", "files-scs:config/app/init/initialize-root.json",
"files-scs:config/app/setup/disabled.json", "files-scs:config/app/setup/disabled.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/default.json", "files-scs:config/app/init/default.json",
"files-scs:config/app/setup/disabled.json", "files-scs:config/app/setup/disabled.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/initialize-root.json", "files-scs:config/app/init/initialize-root.json",
"files-scs:config/app/setup/disabled.json", "files-scs:config/app/setup/disabled.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -4,6 +4,7 @@
"files-scs:config/app/main/default.json", "files-scs:config/app/main/default.json",
"files-scs:config/app/init/default.json", "files-scs:config/app/init/default.json",
"files-scs:config/app/setup/required.json", "files-scs:config/app/setup/required.json",
"files-scs:config/app/variables/default.json",
"files-scs:config/http/handler/default.json", "files-scs:config/http/handler/default.json",
"files-scs:config/http/middleware/websockets.json", "files-scs:config/http/middleware/websockets.json",
"files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/server-factory/websockets.json",

View File

@ -8,7 +8,7 @@
"@type": "Variable" "@type": "Variable"
}, },
{ {
"comment": "Needs to be set to the base URL of the server for authnetication and authorization to function.", "comment": "Needs to be set to the base URL of the server for authentication and authorization to function.",
"@id": "urn:solid-server:default:variable:baseUrl", "@id": "urn:solid-server:default:variable:baseUrl",
"@type": "Variable" "@type": "Variable"
}, },

14
package-lock.json generated
View File

@ -31,7 +31,7 @@
"@types/url-join": "^4.0.0", "@types/url-join": "^4.0.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/ws": "^8.2.0", "@types/ws": "^8.2.0",
"@types/yargs": "^17.0.0", "@types/yargs": "^17.0.8",
"arrayify-stream": "^1.0.0", "arrayify-stream": "^1.0.0",
"async-lock": "^1.3.0", "async-lock": "^1.3.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
@ -4982,9 +4982,9 @@
} }
}, },
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.0", "version": "17.0.8",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.8.tgz",
"integrity": "sha512-RS7u2X7vdXjVQs160PWY1pjLBw6GJj04utojn0KU8p2rRZR37FSzzK6XOT+KLzT/DVbDYRyezroc0LHIvM5Z2A==", "integrity": "sha512-wDeUwiUmem9FzsyysEwRukaEdDNcwbROvQ9QGRKaLI6t+IltNzbn4/i4asmB10auvZGQCzSQ6t0GSczEThlUXw==",
"dependencies": { "dependencies": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }
@ -19663,9 +19663,9 @@
} }
}, },
"@types/yargs": { "@types/yargs": {
"version": "17.0.0", "version": "17.0.8",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.8.tgz",
"integrity": "sha512-RS7u2X7vdXjVQs160PWY1pjLBw6GJj04utojn0KU8p2rRZR37FSzzK6XOT+KLzT/DVbDYRyezroc0LHIvM5Z2A==", "integrity": "sha512-wDeUwiUmem9FzsyysEwRukaEdDNcwbROvQ9QGRKaLI6t+IltNzbn4/i4asmB10auvZGQCzSQ6t0GSczEThlUXw==",
"requires": { "requires": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }

View File

@ -97,7 +97,7 @@
"@types/url-join": "^4.0.0", "@types/url-join": "^4.0.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/ws": "^8.2.0", "@types/ws": "^8.2.0",
"@types/yargs": "^17.0.0", "@types/yargs": "^17.0.8",
"arrayify-stream": "^1.0.0", "arrayify-stream": "^1.0.0",
"async-lock": "^1.3.0", "async-lock": "^1.3.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",

View File

@ -180,9 +180,24 @@ export * from './init/final/ParallelFinalizer';
// Init/Setup // Init/Setup
export * from './init/setup/SetupHttpHandler'; export * from './init/setup/SetupHttpHandler';
// Init/Cli
export * from './init/cli/CliExtractor';
export * from './init/cli/YargsCliExtractor';
// Init/Variables/Extractors
export * from './init/variables/extractors/KeyExtractor';
export * from './init/variables/extractors/AssetPathExtractor';
export * from './init/variables/extractors/BaseUrlExtractor';
export * from './init/variables/extractors/SettingsExtractor';
// Init/Variables
export * from './init/variables/CombinedSettingsResolver';
export * from './init/variables/SettingsResolver';
// Init // Init
export * from './init/App'; export * from './init/App';
export * from './init/AppRunner'; export * from './init/AppRunner';
export * from './init/CliResolver';
export * from './init/ConfigPodInitializer'; export * from './init/ConfigPodInitializer';
export * from './init/ContainerInitializer'; export * from './init/ContainerInitializer';
export * from './init/Initializer'; export * from './init/Initializer';

View File

@ -1,152 +1,199 @@
/* eslint-disable unicorn/no-process-exit */ /* eslint-disable unicorn/no-process-exit */
import type { WriteStream } from 'tty';
import type { ReadStream, WriteStream } from 'tty'; import type { IComponentsManagerBuilderOptions } from 'componentsjs';
import type { IComponentsManagerBuilderOptions, LogLevel } from 'componentsjs';
import { ComponentsManager } from 'componentsjs'; import { ComponentsManager } from 'componentsjs';
import yargs from 'yargs'; import yargs from 'yargs';
import { LOG_LEVELS } from '../logging/LogLevel';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import { ensureTrailingSlash, resolveAssetPath, modulePathPlaceholder } from '../util/PathUtil'; import { createErrorMessage, isError } from '../util/errors/ErrorUtil';
import { modulePathPlaceholder, resolveAssetPath } from '../util/PathUtil';
import type { App } from './App'; import type { App } from './App';
import type { CliResolver } from './CliResolver';
import type { CliArgv, VariableBindings } from './variables/Types';
const defaultConfig = `${modulePathPlaceholder}config/default.json`; const DEFAULT_CONFIG = `${modulePathPlaceholder}config/default.json`;
export interface CliParams { const DEFAULT_CLI_RESOLVER = 'urn:solid-server-app-setup:default:CliResolver';
loggingLevel: string; const DEFAULT_APP = 'urn:solid-server:default:App';
port: number;
baseUrl?: string;
rootFilePath?: string;
sparqlEndpoint?: string;
showStackTrace?: boolean;
podConfigJson?: string;
}
const CORE_CLI_PARAMETERS = {
config: { type: 'string', alias: 'c', default: DEFAULT_CONFIG, requiresArg: true },
loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true, choices: LOG_LEVELS },
mainModulePath: { type: 'string', alias: 'm', requiresArg: true },
} as const;
/**
* A class that can be used to instantiate and start a server based on a Component.js configuration.
*/
export class AppRunner { export class AppRunner {
private readonly logger = getLoggerFor(this); private readonly logger = getLoggerFor(this);
/** /**
* Starts the server with a given config. * Starts the server with a given config.
* This method can be used to start the server from within another JavaScript application. * This method can be used to start the server from within another JavaScript application.
* Keys of the `variableBindings` object should be Components.js variables.
* E.g.: `{ 'urn:solid-server:default:variable:rootFilePath': '.data' }`.
*
* @param loaderProperties - Components.js loader properties. * @param loaderProperties - Components.js loader properties.
* @param configFile - Path to the server config file. * @param configFile - Path to the server config file.
* @param variableParams - Variables to pass into the config file. * @param variableBindings - Parameters to pass into the VariableResolver.
*/ */
public async run( public async run(
loaderProperties: IComponentsManagerBuilderOptions<App>, loaderProperties: IComponentsManagerBuilderOptions<App>,
configFile: string, configFile: string,
variableParams: CliParams, variableBindings: VariableBindings,
): Promise<void> { ): Promise<void> {
const app = await this.createApp(loaderProperties, configFile, variableParams); const app = await this.create(loaderProperties, configFile, variableBindings);
await app.start(); await app.start();
} }
/** /**
* Starts the server as a command-line application. * Returns an App object, created with the given config, that can start and stop the Solid server.
* Made non-async to lower the risk of unhandled promise rejections. * Keys of the `variableBindings` object should be Components.js variables.
* @param args - Command line arguments. * E.g.: `{ 'urn:solid-server:default:variable:rootFilePath': '.data' }`.
* @param stderr - Standard error stream. *
* @param loaderProperties - Components.js loader properties.
* @param configFile - Path to the server config file.
* @param variableBindings - Bindings of Components.js variables.
*/ */
public runCli({ public async create(
argv = process.argv, loaderProperties: IComponentsManagerBuilderOptions<App>,
stderr = process.stderr, configFile: string,
}: { variableBindings: VariableBindings,
argv?: string[]; ): Promise<App> {
stdin?: ReadStream; // Create a resolver to translate (non-core) CLI parameters into values for variables
stdout?: WriteStream; const componentsManager = await this.createComponentsManager<App>(loaderProperties, configFile);
stderr?: WriteStream;
} = {}): void {
// Parse the command-line arguments
// eslint-disable-next-line no-sync
const params = yargs(argv.slice(2))
.strict()
.usage('node ./bin/server.js [args]')
.check((args): boolean => {
if (args._.length > 0) {
throw new Error(`Unsupported positional arguments: "${args._.join('", "')}"`);
}
for (const key of Object.keys(args)) {
// We have no options that allow for arrays
const val = args[key];
if (key !== '_' && Array.isArray(val)) {
throw new Error(`Multiple values were provided for: "${key}": "${val.join('", "')}"`);
}
}
return true;
})
.options({
baseUrl: { type: 'string', alias: 'b', requiresArg: true },
config: { type: 'string', alias: 'c', default: defaultConfig, requiresArg: true },
loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true },
mainModulePath: { type: 'string', alias: 'm', requiresArg: true },
port: { type: 'number', alias: 'p', default: 3000, requiresArg: true },
rootFilePath: { type: 'string', alias: 'f', default: './', requiresArg: true },
showStackTrace: { type: 'boolean', alias: 't', default: false },
sparqlEndpoint: { type: 'string', alias: 's', requiresArg: true },
podConfigJson: { type: 'string', default: './pod-config.json', requiresArg: true },
})
.parseSync();
// Gather settings for instantiating the server // Create the application using the translated variable values
const loaderProperties: IComponentsManagerBuilderOptions<App> = { return componentsManager.instantiate(DEFAULT_APP, { variables: variableBindings });
mainModulePath: resolveAssetPath(params.mainModulePath), }
dumpErrorState: true,
logLevel: params.loggingLevel as LogLevel,
};
const configFile = resolveAssetPath(params.config);
// Create and execute the app /**
this.createApp(loaderProperties, configFile, params) * Starts the server as a command-line application.
.then( * Will exit the process on failure.
async(app): Promise<void> => app.start(), *
(error: Error): void => { * Made non-async to lower the risk of unhandled promise rejections.
// Instantiation of components has failed, so there is no logger to use * This is only relevant when this is used to start as a Node.js application on its own,
stderr.write(`Error: could not instantiate server from ${configFile}\n`); * if you use this as part of your code you probably want to use the async version.
stderr.write(`${error.stack}\n`); *
process.exit(1); * @param argv - Command line arguments.
}, * @param stderr - Stream that should be used to output errors before the logger is enabled.
).catch((error): void => { */
this.logger.error(`Could not start server: ${error}`, { error }); public runCliSync({ argv, stderr = process.stderr }: { argv?: CliArgv; stderr?: WriteStream }): void {
this.runCli(argv).catch((error): never => {
stderr.write(createErrorMessage(error));
process.exit(1); process.exit(1);
}); });
} }
/** /**
* Creates the main app object to start the server from a given config. * Starts the server as a command-line application.
* @param loaderProperties - Components.js loader properties. * @param argv - Command line arguments.
* @param configFile - Path to a Components.js config file.
* @param variables - Variables to pass into the config file.
*/ */
public async createApp( public async runCli(argv?: CliArgv): Promise<void> {
loaderProperties: IComponentsManagerBuilderOptions<App>, const app = await this.createCli(argv);
configFile: string, try {
variables: CliParams | Record<string, any>, await app.start();
): Promise<App> { } catch (error: unknown) {
// Translate command-line parameters if needed this.logger.error(`Could not start the server: ${createErrorMessage(error)}`);
if (typeof variables.loggingLevel === 'string') { this.resolveError('Could not start the server', error);
variables = this.createVariables(variables as CliParams);
} }
// Set up Components.js
const componentsManager = await ComponentsManager.build(loaderProperties);
await componentsManager.configRegistry.register(configFile);
// Create the app
const app = 'urn:solid-server:default:App';
return await componentsManager.instantiate(app, { variables });
} }
/** /**
* Translates command-line parameters into Components.js variables. * Returns an App object, created by parsing the Command line arguments, that can start and stop the Solid server.
* Will exit the process on failure.
*
* @param argv - Command line arguments.
*/ */
protected createVariables(params: CliParams): Record<string, any> { public async createCli(argv: CliArgv = process.argv): Promise<App> {
return { // Parse only the core CLI arguments needed to load the configuration
'urn:solid-server:default:variable:baseUrl': const yargv = yargs(argv.slice(2))
params.baseUrl ? ensureTrailingSlash(params.baseUrl) : `http://localhost:${params.port}/`, .usage('node ./bin/server.js [args]')
'urn:solid-server:default:variable:loggingLevel': params.loggingLevel, .options(CORE_CLI_PARAMETERS)
'urn:solid-server:default:variable:port': params.port, // We disable help here as it would only show the core parameters
'urn:solid-server:default:variable:rootFilePath': resolveAssetPath(params.rootFilePath), .help(false);
'urn:solid-server:default:variable:sparqlEndpoint': params.sparqlEndpoint,
'urn:solid-server:default:variable:showStackTrace': params.showStackTrace, const params = await yargv.parse();
'urn:solid-server:default:variable:podConfigJson': resolveAssetPath(params.podConfigJson),
const loaderProperties = {
mainModulePath: resolveAssetPath(params.mainModulePath),
dumpErrorState: true,
logLevel: params.loggingLevel,
}; };
const config = resolveAssetPath(params.config);
// Create the Components.js manager used to build components from the provided config
let componentsManager: ComponentsManager<any>;
try {
componentsManager = await this.createComponentsManager(loaderProperties, config);
} catch (error: unknown) {
// Print help of the expected core CLI parameters
const help = await yargv.getHelp();
this.resolveError(`${help}\n\nCould not build the config files from ${config}`, error);
}
// Build the CLI components and use them to generate values for the Components.js variables
const variables = await this.resolveVariables(componentsManager, argv);
// Build and start the actual server application using the generated variable values
return await this.createApp(componentsManager, variables);
}
/**
* Creates the Components Manager that will be used for instantiating.
*/
public async createComponentsManager<T>(
loaderProperties: IComponentsManagerBuilderOptions<T>,
configFile: string,
): Promise<ComponentsManager<T>> {
const componentsManager = await ComponentsManager.build(loaderProperties);
await componentsManager.configRegistry.register(configFile);
return componentsManager;
}
/**
* Handles the first Components.js instantiation,
* where CLI settings and variable mappings are created.
*/
private async resolveVariables(componentsManager: ComponentsManager<CliResolver>, argv: string[]):
Promise<VariableBindings> {
try {
// Create a CliResolver, which combines a CliExtractor and a VariableResolver
const resolver = await componentsManager.instantiate(DEFAULT_CLI_RESOLVER, {});
// Convert CLI args to CLI bindings
const cliValues = await resolver.cliExtractor.handleSafe(argv);
// Convert CLI bindings into variable bindings
return await resolver.settingsResolver.handleSafe(cliValues);
} catch (error: unknown) {
this.resolveError(`Could not load the config variables`, error);
}
}
/**
* The second Components.js instantiation,
* where the App is created and started using the variable mappings.
*/
private async createApp(componentsManager: ComponentsManager<App>, variables: Record<string, unknown>): Promise<App> {
try {
// Create the app
return await componentsManager.instantiate(DEFAULT_APP, { variables });
} catch (error: unknown) {
this.resolveError(`Could not create the server`, error);
}
}
/**
* Throws a new error that provides additional information through the extra message.
* Also appends the stack trace to the message.
* This is needed for errors that are thrown before the logger is created as we can't log those the standard way.
*/
private resolveError(message: string, error: unknown): never {
let errorMessage = `${message}\nCause: ${createErrorMessage(error)}\n`;
if (isError(error)) {
errorMessage += `${error.stack}\n`;
}
throw new Error(errorMessage);
} }
} }

16
src/init/CliResolver.ts Normal file
View File

@ -0,0 +1,16 @@
import type { CliExtractor } from './cli/CliExtractor';
import type { SettingsResolver } from './variables/SettingsResolver';
/**
* A class that combines a {@link CliExtractor} and a {@link SettingsResolver}.
* Mainly exists so both such classes can be generated in a single Components.js instance.
*/
export class CliResolver {
public readonly cliExtractor: CliExtractor;
public readonly settingsResolver: SettingsResolver;
public constructor(cliExtractor: CliExtractor, settingsResolver: SettingsResolver) {
this.cliExtractor = cliExtractor;
this.settingsResolver = settingsResolver;
}
}

View File

@ -0,0 +1,18 @@
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { CliArgv, Settings } from '../variables/Types';
/**
* Converts the input CLI arguments into an easily parseable key/value object.
*
* Due to how the application is built, there are certain CLI parameters
* that need to be parsed before this class can be instantiated.
* These can be ignored by this class as they will have been handled before it is called,
* but that does mean that this class should not error if they are present,
* e.g., by being strict throwing an error on these unexpected parameters.
*
* The following core CLI parameters are mandatory:
* - -c / \--config
* - -m / \--mainModulePath
* - -l / \--loggingLevel
*/
export abstract class CliExtractor extends AsyncHandler<CliArgv, Settings> {}

View File

@ -0,0 +1,61 @@
/* eslint-disable tsdoc/syntax */
import type { Arguments, Argv, Options } from 'yargs';
import yargs from 'yargs';
import { CliExtractor } from './CliExtractor';
export type YargsArgOptions = Record<string, Options>;
export interface CliOptions {
// Usage string printed in case of CLI errors
usage?: string;
// Errors on unknown CLI parameters when enabled.
// @see https://yargs.js.org/docs/#api-reference-strictenabledtrue
strictMode?: boolean;
// Loads CLI args from environment variables when enabled.
// @see http://yargs.js.org/docs/#api-reference-envprefix
loadFromEnv?: boolean;
// Prefix to be used when `loadFromEnv` is enabled.
// @see http://yargs.js.org/docs/#api-reference-envprefix
envVarPrefix?: string;
}
/**
* Parses CLI args using the yargs library.
* Specific settings can be enabled through the provided options.
*/
export class YargsCliExtractor extends CliExtractor {
protected readonly yargsArgOptions: YargsArgOptions;
protected readonly yargvOptions: CliOptions;
/**
* @param parameters - Parameters that should be parsed from the CLI. @range {json}
* Format details can be found at https://yargs.js.org/docs/#api-reference-optionskey-opt
* @param options - Additional options to configure yargs. @range {json}
*/
public constructor(parameters: YargsArgOptions = {}, options: CliOptions = {}) {
super();
this.yargsArgOptions = parameters;
this.yargvOptions = options;
}
public async handle(argv: readonly string[]): Promise<Arguments> {
return this.createYArgv(argv).parse();
}
/**
* Creates the yargs Argv object based on the input CLI argv.
*/
private createYArgv(argv: readonly string[]): Argv {
let yArgv = yargs(argv.slice(2));
if (this.yargvOptions.usage !== undefined) {
yArgv = yArgv.usage(this.yargvOptions.usage);
}
if (this.yargvOptions.strictMode) {
yArgv = yArgv.strict();
}
if (this.yargvOptions.loadFromEnv) {
yArgv = yArgv.env(this.yargvOptions.envVarPrefix ?? '');
}
return yArgv.options(this.yargsArgOptions);
}
}

View File

@ -0,0 +1,27 @@
import { createErrorMessage } from '../../util/errors/ErrorUtil';
import type { SettingsExtractor } from './extractors/SettingsExtractor';
import { SettingsResolver } from './SettingsResolver';
/**
* Generates variable values by running a set of {@link SettingsExtractor}s on the input.
*/
export class CombinedSettingsResolver extends SettingsResolver {
public readonly computers: Record<string, SettingsExtractor>;
public constructor(computers: Record<string, SettingsExtractor>) {
super();
this.computers = computers;
}
public async handle(input: Record<string, unknown>): Promise<Record<string, unknown>> {
const vars: Record<string, any> = {};
for (const [ name, computer ] of Object.entries(this.computers)) {
try {
vars[name] = await computer.handleSafe(input);
} catch (err: unknown) {
throw new Error(`Error in computing value for variable ${name}: ${createErrorMessage(err)}`);
}
}
return vars;
}
}

View File

@ -0,0 +1,9 @@
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { Settings, VariableBindings } from './Types';
/**
* Converts a key/value object, extracted from the CLI or passed as a parameter,
* into a new key/value object where the keys are variables defined in the Components.js configuration.
* The resulting values are the values that should be assigned to those variables.
*/
export abstract class SettingsResolver extends AsyncHandler<Settings, VariableBindings> {}

View File

@ -0,0 +1,16 @@
// These types are used to clarify what is expected for the CLI-related handlers
/**
* A list of command line arguments provided to the process.
*/
export type CliArgv = string[];
/**
* A key/value mapping of parsed command line arguments.
*/
export type Settings = Record<string, unknown>;
/**
* A key/value mapping of Components.js variables.
*/
export type VariableBindings = Record<string, unknown>;

View File

@ -0,0 +1,26 @@
import { resolveAssetPath } from '../../../util/PathUtil';
import type { Settings } from '../Types';
import { SettingsExtractor } from './SettingsExtractor';
/**
* A {@link SettingsExtractor} that converts a path value to an absolute asset path by making use of `resolveAssetPath`.
* Returns the default path in case it is defined and no path was found in the map.
*/
export class AssetPathExtractor extends SettingsExtractor {
private readonly key: string;
private readonly defaultPath?: string;
public constructor(key: string, defaultPath?: string) {
super();
this.key = key;
this.defaultPath = defaultPath;
}
public async handle(args: Settings): Promise<unknown> {
const path = args[this.key] ?? this.defaultPath;
if (typeof path !== 'string') {
throw new Error(`Invalid ${this.key} argument`);
}
return resolveAssetPath(path);
}
}

View File

@ -0,0 +1,24 @@
import { ensureTrailingSlash } from '../../../util/PathUtil';
import type { Settings } from '../Types';
import { SettingsExtractor } from './SettingsExtractor';
/**
* A {@link SettingsExtractor} that that generates the base URL based on the input `baseUrl` value,
* or by using the port if the first isn't provided.
*/
export class BaseUrlExtractor extends SettingsExtractor {
private readonly defaultPort: number;
public constructor(defaultPort = 3000) {
super();
this.defaultPort = defaultPort;
}
public async handle(args: Settings): Promise<unknown> {
if (typeof args.baseUrl === 'string') {
return ensureTrailingSlash(args.baseUrl);
}
const port = args.port ?? this.defaultPort;
return `http://localhost:${port}/`;
}
}

View File

@ -0,0 +1,21 @@
import type { Settings } from '../Types';
import { SettingsExtractor } from './SettingsExtractor';
/**
* A simple {@link SettingsExtractor} that extracts a single value from the input map.
* Returns the default value if it was defined in case no value was found in the map.
*/
export class KeyExtractor extends SettingsExtractor {
private readonly key: string;
private readonly defaultValue: unknown;
public constructor(key: string, defaultValue?: unknown) {
super();
this.key = key;
this.defaultValue = defaultValue;
}
public async handle(args: Settings): Promise<unknown> {
return typeof args[this.key] === 'undefined' ? this.defaultValue : args[this.key];
}
}

View File

@ -0,0 +1,7 @@
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { Settings } from '../Types';
/**
* A handler that computes a specific value from a given map of values.
*/
export abstract class SettingsExtractor extends AsyncHandler<Settings, unknown> {}

View File

@ -1,4 +1,6 @@
export const LOG_LEVELS = [ 'error', 'warn', 'info', 'verbose', 'debug', 'silly' ] as const;
/** /**
* Different log levels, from most important to least important. * Different log levels, from most important to least important.
*/ */
export type LogLevel = 'error' | 'warn' | 'info' | 'verbose' | 'debug' | 'silly'; export type LogLevel = typeof LOG_LEVELS[number];

View File

@ -1,14 +1,38 @@
import { ComponentsManager } from 'componentsjs'; import { ComponentsManager } from 'componentsjs';
import type { App } from '../../../src/init/App'; import type { App } from '../../../src/init/App';
import { AppRunner } from '../../../src/init/AppRunner'; import { AppRunner } from '../../../src/init/AppRunner';
import type { CliExtractor } from '../../../src/init/cli/CliExtractor';
import type { SettingsResolver } from '../../../src/init/variables/SettingsResolver';
import { joinFilePath } from '../../../src/util/PathUtil'; import { joinFilePath } from '../../../src/util/PathUtil';
const app: jest.Mocked<App> = { const app: jest.Mocked<App> = {
start: jest.fn(), start: jest.fn(),
} as any; } as any;
const defaultParameters = {
port: 3000,
logLevel: 'info',
};
const cliExtractor: jest.Mocked<CliExtractor> = {
handleSafe: jest.fn().mockResolvedValue(defaultParameters),
} as any;
const defaultVariables = {
'urn:solid-server:default:variable:port': 3000,
'urn:solid-server:default:variable:loggingLevel': 'info',
};
const settingsResolver: jest.Mocked<SettingsResolver> = {
handleSafe: jest.fn().mockResolvedValue(defaultVariables),
} as any;
const manager: jest.Mocked<ComponentsManager<App>> = { const manager: jest.Mocked<ComponentsManager<App>> = {
instantiate: jest.fn(async(): Promise<App> => app), instantiate: jest.fn(async(iri: string): Promise<any> => {
switch (iri) {
case 'urn:solid-server-app-setup:default:CliResolver': return { cliExtractor, settingsResolver };
case 'urn:solid-server:default:App': return app;
default: throw new Error('unknown iri');
}
}),
configRegistry: { configRegistry: {
register: jest.fn(), register: jest.fn(),
}, },
@ -22,7 +46,6 @@ jest.mock('componentsjs', (): any => ({
})); }));
jest.spyOn(process, 'cwd').mockReturnValue('/var/cwd'); jest.spyOn(process, 'cwd').mockReturnValue('/var/cwd');
const error = jest.spyOn(console, 'error').mockImplementation(jest.fn());
const write = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn()); const write = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn());
const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any);
@ -31,8 +54,52 @@ describe('AppRunner', (): void => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('create', (): void => {
it('creates an App with the provided settings.', async(): Promise<void> => {
const variables = {
'urn:solid-server:default:variable:port': 3000,
'urn:solid-server:default:variable:loggingLevel': 'info',
'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
'urn:solid-server:default:variable:showStackTrace': false,
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
};
const createdApp = await new AppRunner().create(
{
mainModulePath: joinFilePath(__dirname, '../../../'),
dumpErrorState: true,
logLevel: 'info',
},
joinFilePath(__dirname, '../../../config/default.json'),
variables,
);
expect(createdApp).toBe(app);
expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
expect(ComponentsManager.build).toHaveBeenCalledWith({
dumpErrorState: true,
logLevel: 'info',
mainModulePath: joinFilePath(__dirname, '../../../'),
});
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register)
.toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json'));
expect(manager.instantiate).toHaveBeenCalledTimes(1);
expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server:default:App', { variables });
expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0);
expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0);
expect(app.start).toHaveBeenCalledTimes(0);
});
});
describe('run', (): void => { describe('run', (): void => {
it('starts the server with default settings.', async(): Promise<void> => { it('starts the server with provided settings.', async(): Promise<void> => {
const variables = {
'urn:solid-server:default:variable:port': 3000,
'urn:solid-server:default:variable:loggingLevel': 'info',
'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
'urn:solid-server:default:variable:showStackTrace': false,
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
};
await new AppRunner().run( await new AppRunner().run(
{ {
mainModulePath: joinFilePath(__dirname, '../../../'), mainModulePath: joinFilePath(__dirname, '../../../'),
@ -40,13 +107,7 @@ describe('AppRunner', (): void => {
logLevel: 'info', logLevel: 'info',
}, },
joinFilePath(__dirname, '../../../config/default.json'), joinFilePath(__dirname, '../../../config/default.json'),
{ variables,
port: 3000,
loggingLevel: 'info',
rootFilePath: '/var/cwd/',
showStackTrace: false,
podConfigJson: '/var/cwd/pod-config.json',
},
); );
expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
@ -59,35 +120,17 @@ describe('AppRunner', (): void => {
expect(manager.configRegistry.register) expect(manager.configRegistry.register)
.toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json'));
expect(manager.instantiate).toHaveBeenCalledTimes(1); expect(manager.instantiate).toHaveBeenCalledTimes(1);
expect(manager.instantiate).toHaveBeenCalledWith( expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server:default:App', { variables });
'urn:solid-server:default:App', expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0);
{ expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0);
variables: {
'urn:solid-server:default:variable:port': 3000,
'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/',
'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
'urn:solid-server:default:variable:sparqlEndpoint': undefined,
'urn:solid-server:default:variable:loggingLevel': 'info',
'urn:solid-server:default:variable:showStackTrace': false,
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
},
},
);
expect(app.start).toHaveBeenCalledTimes(1); expect(app.start).toHaveBeenCalledTimes(1);
expect(app.start).toHaveBeenCalledWith(); expect(app.start).toHaveBeenCalledWith();
}); });
}); });
describe('runCli', (): void => { describe('createCli', (): void => {
it('starts the server with default settings.', async(): Promise<void> => { it('creates the server with default settings.', async(): Promise<void> => {
new AppRunner().runCli({ await expect(new AppRunner().createCli([ 'node', 'script' ])).resolves.toBe(app);
argv: [ 'node', 'script' ],
});
// Wait until app.start has been called, because we can't await AppRunner.run.
await new Promise((resolve): void => {
setImmediate(resolve);
});
expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
expect(ComponentsManager.build).toHaveBeenCalledWith({ expect(ComponentsManager.build).toHaveBeenCalledWith({
@ -98,133 +141,21 @@ describe('AppRunner', (): void => {
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register) expect(manager.configRegistry.register)
.toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json'));
expect(manager.instantiate).toHaveBeenCalledTimes(1); expect(manager.instantiate).toHaveBeenCalledTimes(2);
expect(manager.instantiate).toHaveBeenCalledWith( expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {});
expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]);
expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1);
expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters);
expect(manager.instantiate).toHaveBeenNthCalledWith(2,
'urn:solid-server:default:App', 'urn:solid-server:default:App',
{ { variables: defaultVariables });
variables: { expect(app.start).toHaveBeenCalledTimes(0);
'urn:solid-server:default:variable:port': 3000,
'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/',
'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
'urn:solid-server:default:variable:sparqlEndpoint': undefined,
'urn:solid-server:default:variable:loggingLevel': 'info',
'urn:solid-server:default:variable:showStackTrace': false,
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
},
},
);
expect(app.start).toHaveBeenCalledTimes(1);
expect(app.start).toHaveBeenCalledWith();
});
it('accepts abbreviated flags.', async(): Promise<void> => {
new AppRunner().runCli({
argv: [
'node', 'script',
'-b', 'http://pod.example/',
'-c', 'myconfig.json',
'-f', '/root',
'-l', 'debug',
'-m', 'module/path',
'-p', '4000',
'-s', 'http://localhost:5000/sparql',
'-t',
'--podConfigJson', '/different-path.json',
],
});
// Wait until app.start has been called, because we can't await AppRunner.run.
await new Promise((resolve): void => {
setImmediate(resolve);
});
expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
expect(ComponentsManager.build).toHaveBeenCalledWith({
dumpErrorState: true,
logLevel: 'debug',
mainModulePath: '/var/cwd/module/path',
});
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
expect(manager.instantiate).toHaveBeenCalledWith(
'urn:solid-server:default:App',
{
variables: {
'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
'urn:solid-server:default:variable:loggingLevel': 'debug',
'urn:solid-server:default:variable:port': 4000,
'urn:solid-server:default:variable:rootFilePath': '/root',
'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
},
},
);
});
it('accepts full flags.', async(): Promise<void> => {
new AppRunner().runCli({
argv: [
'node', 'script',
'--baseUrl', 'http://pod.example/',
'--config', 'myconfig.json',
'--loggingLevel', 'debug',
'--mainModulePath', 'module/path',
'--port', '4000',
'--rootFilePath', 'root',
'--sparqlEndpoint', 'http://localhost:5000/sparql',
'--showStackTrace',
'--podConfigJson', '/different-path.json',
],
});
// Wait until app.start has been called, because we can't await AppRunner.run.
await new Promise((resolve): void => {
setImmediate(resolve);
});
expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
expect(ComponentsManager.build).toHaveBeenCalledWith({
dumpErrorState: true,
logLevel: 'debug',
mainModulePath: '/var/cwd/module/path',
});
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
expect(manager.instantiate).toHaveBeenCalledWith(
'urn:solid-server:default:App',
{
variables: {
'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
'urn:solid-server:default:variable:loggingLevel': 'debug',
'urn:solid-server:default:variable:port': 4000,
'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root',
'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
},
},
);
});
it('accepts asset paths for the config flag.', async(): Promise<void> => {
new AppRunner().runCli({
argv: [
'node', 'script',
'--config', '@css:config/file.json',
],
});
await new Promise(setImmediate);
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register).toHaveBeenCalledWith(
joinFilePath(__dirname, '../../../config/file.json'),
);
}); });
it('uses the default process.argv in case none are provided.', async(): Promise<void> => { it('uses the default process.argv in case none are provided.', async(): Promise<void> => {
const { argv } = process; const { argv } = process;
process.argv = [ const argvParameters = [
'node', 'script', 'node', 'script',
'-b', 'http://pod.example/', '-b', 'http://pod.example/',
'-c', 'myconfig.json', '-c', 'myconfig.json',
@ -236,13 +167,9 @@ describe('AppRunner', (): void => {
'-t', '-t',
'--podConfigJson', '/different-path.json', '--podConfigJson', '/different-path.json',
]; ];
process.argv = argvParameters;
new AppRunner().runCli(); await expect(new AppRunner().createCli()).resolves.toBe(app);
// Wait until app.start has been called, because we can't await AppRunner.run.
await new Promise((resolve): void => {
setImmediate(resolve);
});
expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
expect(ComponentsManager.build).toHaveBeenCalledWith({ expect(ComponentsManager.build).toHaveBeenCalledWith({
@ -252,119 +179,181 @@ describe('AppRunner', (): void => {
}); });
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json'); expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
expect(manager.instantiate).toHaveBeenCalledWith( expect(manager.instantiate).toHaveBeenCalledTimes(2);
expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {});
expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(cliExtractor.handleSafe).toHaveBeenCalledWith(argvParameters);
expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1);
expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters);
expect(manager.instantiate).toHaveBeenNthCalledWith(2,
'urn:solid-server:default:App', 'urn:solid-server:default:App',
{ { variables: defaultVariables });
variables: { expect(app.start).toHaveBeenCalledTimes(0);
'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
'urn:solid-server:default:variable:loggingLevel': 'debug',
'urn:solid-server:default:variable:port': 4000,
'urn:solid-server:default:variable:rootFilePath': '/root',
'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
},
},
);
process.argv = argv; process.argv = argv;
}); });
it('exits with output to stderr when instantiation fails.', async(): Promise<void> => { it('throws an error if creating a ComponentsManager fails.', async(): Promise<void> => {
(manager.configRegistry.register as jest.Mock).mockRejectedValueOnce(new Error('Fatal'));
let caughtError: Error = new Error('should disappear');
try {
await new AppRunner().createCli([ 'node', 'script' ]);
} catch (error: unknown) {
caughtError = error as Error;
}
expect(caughtError.message).toMatch(/^Could not build the config files from .*default\.json/mu);
expect(caughtError.message).toMatch(/^Cause: Fatal/mu);
expect(write).toHaveBeenCalledTimes(0);
expect(exit).toHaveBeenCalledTimes(0);
});
it('throws an error if instantiating the CliResolver fails.', async(): Promise<void> => {
manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); manager.instantiate.mockRejectedValueOnce(new Error('Fatal'));
new AppRunner().runCli({
argv: [ 'node', 'script' ], let caughtError: Error = new Error('should disappear');
try {
await new AppRunner().createCli([ 'node', 'script' ]);
} catch (error: unknown) {
caughtError = error as Error;
}
expect(caughtError.message).toMatch(/^Could not load the config variables/mu);
expect(caughtError.message).toMatch(/^Cause: Fatal/mu);
expect(write).toHaveBeenCalledTimes(0);
expect(exit).toHaveBeenCalledTimes(0);
}); });
// Wait until app.start has been called, because we can't await AppRunner.run. it('throws an error if instantiating the server fails.', async(): Promise<void> => {
await new Promise((resolve): void => { // We want the second call to fail
setImmediate(resolve); manager.instantiate
.mockResolvedValueOnce({ cliExtractor, settingsResolver })
.mockRejectedValueOnce(new Error('Fatal'));
let caughtError: Error = new Error('should disappear');
try {
await new AppRunner().createCli([ 'node', 'script' ]);
} catch (error: unknown) {
caughtError = error as Error;
}
expect(caughtError.message).toMatch(/^Could not create the server/mu);
expect(caughtError.message).toMatch(/^Cause: Fatal/mu);
expect(write).toHaveBeenCalledTimes(0);
expect(exit).toHaveBeenCalledTimes(0);
}); });
expect(write).toHaveBeenCalledTimes(2); it('throws an error if non-error objects get thrown.', async(): Promise<void> => {
expect(write).toHaveBeenNthCalledWith(1, (manager.configRegistry.register as jest.Mock).mockRejectedValueOnce('NotAnError');
expect.stringMatching(/^Error: could not instantiate server from .*default\.json/u));
expect(write).toHaveBeenNthCalledWith(2,
expect.stringMatching(/^Error: Fatal/u));
expect(exit).toHaveBeenCalledTimes(1); let caughtError: Error = new Error('should disappear');
expect(exit).toHaveBeenCalledWith(1); try {
await new AppRunner().createCli([ 'node', 'script' ]);
} catch (error: unknown) {
caughtError = error as Error;
}
expect(caughtError.message).toMatch(/^Cause: Unknown error: NotAnError$/mu);
expect(write).toHaveBeenCalledTimes(0);
expect(exit).toHaveBeenCalledTimes(0);
});
}); });
it('exits without output to stderr when initialization fails.', async(): Promise<void> => { describe('runCli', (): void => {
it('runs the server.', async(): Promise<void> => {
await expect(new AppRunner().runCli([ 'node', 'script' ])).resolves.toBeUndefined();
expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
expect(ComponentsManager.build).toHaveBeenCalledWith({
dumpErrorState: true,
logLevel: 'info',
mainModulePath: joinFilePath(__dirname, '../../../'),
});
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register)
.toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json'));
expect(manager.instantiate).toHaveBeenCalledTimes(2);
expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {});
expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]);
expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1);
expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters);
expect(manager.instantiate).toHaveBeenNthCalledWith(2,
'urn:solid-server:default:App',
{ variables: defaultVariables });
expect(app.start).toHaveBeenCalledTimes(1);
expect(app.start).toHaveBeenLastCalledWith();
});
it('throws an error if the server could not start.', async(): Promise<void> => {
app.start.mockRejectedValueOnce(new Error('Fatal')); app.start.mockRejectedValueOnce(new Error('Fatal'));
new AppRunner().runCli({
argv: [ 'node', 'script' ],
});
// Wait until app.start has been called, because we can't await AppRunner.run. let caughtError: Error = new Error('should disappear');
await new Promise((resolve): void => { try {
setImmediate(resolve); await new AppRunner().runCli([ 'node', 'script' ]);
}); } catch (error: unknown) {
caughtError = error as Error;
}
expect(caughtError.message).toMatch(/^Could not start the server/mu);
expect(caughtError.message).toMatch(/^Cause: Fatal/mu);
expect(app.start).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledTimes(0); expect(write).toHaveBeenCalledTimes(0);
expect(exit).toHaveBeenCalledWith(1); expect(exit).toHaveBeenCalledTimes(0);
});
}); });
it('exits when unknown options are passed to the main executable.', async(): Promise<void> => { describe('runCliSync', (): void => {
new AppRunner().runCli({ it('starts the server.', async(): Promise<void> => {
argv: [ 'node', 'script', '--foo' ], // eslint-disable-next-line no-sync
}); new AppRunner().runCliSync({ argv: [ 'node', 'script' ]});
// Wait until app.start has been called, because we can't await AppRunner.run. // Wait until app.start has been called, because we can't await AppRunner.run.
await new Promise((resolve): void => { await new Promise((resolve): void => {
setImmediate(resolve); setImmediate(resolve);
}); });
expect(error).toHaveBeenCalledWith('Unknown argument: foo'); expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledWith({
expect(exit).toHaveBeenCalledWith(1); dumpErrorState: true,
logLevel: 'info',
mainModulePath: joinFilePath(__dirname, '../../../'),
});
expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
expect(manager.configRegistry.register)
.toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json'));
expect(manager.instantiate).toHaveBeenCalledTimes(2);
expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {});
expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]);
expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1);
expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters);
expect(manager.instantiate).toHaveBeenNthCalledWith(2,
'urn:solid-server:default:App',
{ variables: defaultVariables });
expect(app.start).toHaveBeenCalledTimes(1);
expect(app.start).toHaveBeenLastCalledWith();
}); });
it('exits when no value is passed to the main executable for an argument.', async(): Promise<void> => { it('exits the process and writes to stderr if there was an error.', async(): Promise<void> => {
new AppRunner().runCli({ manager.instantiate.mockRejectedValueOnce(new Error('Fatal'));
argv: [ 'node', 'script', '-s' ],
});
// Wait until app.start has been called, because we can't await AppRunner.run. // eslint-disable-next-line no-sync
new AppRunner().runCliSync({ argv: [ 'node', 'script' ]});
// Wait until app.start has been called, because we can't await AppRunner.runCli.
await new Promise((resolve): void => { await new Promise((resolve): void => {
setImmediate(resolve); setImmediate(resolve);
}); });
expect(error).toHaveBeenCalledWith('Not enough arguments following: s'); expect(write).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenLastCalledWith(expect.stringMatching(/Cause: Fatal/mu));
expect(exit).toHaveBeenCalledTimes(1); expect(exit).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(1); expect(exit).toHaveBeenLastCalledWith(1);
});
it('exits when unknown parameters are passed to the main executable.', async(): Promise<void> => {
new AppRunner().runCli({
argv: [ 'node', 'script', 'foo', 'bar', 'foo.txt', 'bar.txt' ],
});
// Wait until app.start has been called, because we can't await AppRunner.run.
await new Promise((resolve): void => {
setImmediate(resolve);
});
expect(error).toHaveBeenCalledWith('Unsupported positional arguments: "foo", "bar", "foo.txt", "bar.txt"');
expect(exit).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(1);
});
it('exits when multiple values for a parameter are passed.', async(): Promise<void> => {
new AppRunner().runCli({
argv: [ 'node', 'script', '-l', 'info', '-l', 'debug' ],
});
// Wait until app.start has been called, because we can't await AppRunner.run.
await new Promise((resolve): void => {
setImmediate(resolve);
});
expect(error).toHaveBeenCalledWith('Multiple values were provided for: "l": "info", "debug"');
expect(exit).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(1);
}); });
}); });
}); });

View File

@ -0,0 +1,13 @@
import type { CliExtractor } from '../../../src/init/cli/CliExtractor';
import { CliResolver } from '../../../src/init/CliResolver';
import type { SettingsResolver } from '../../../src/init/variables/SettingsResolver';
describe('A CliResolver', (): void => {
it('stores a CliExtractor and SettingsResolver.', async(): Promise<void> => {
const cliExtractor: CliExtractor = { canHandle: jest.fn().mockResolvedValue('CLI!') } as any;
const settingsResolver: SettingsResolver = { canHandle: jest.fn().mockResolvedValue('Settings!') } as any;
const cliResolver = new CliResolver(cliExtractor, settingsResolver);
expect(cliResolver.cliExtractor).toBe(cliExtractor);
expect(cliResolver.settingsResolver).toBe(settingsResolver);
});
});

View File

@ -0,0 +1,83 @@
import type { YargsArgOptions } from '../../../../src/init/cli/YargsCliExtractor';
import { YargsCliExtractor } from '../../../../src/init/cli/YargsCliExtractor';
const error = jest.spyOn(console, 'error').mockImplementation(jest.fn());
const log = jest.spyOn(console, 'log').mockImplementation(jest.fn());
const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any);
describe('A YargsCliExtractor', (): void => {
const parameters: YargsArgOptions = {
baseUrl: { alias: 'b', requiresArg: true, type: 'string' },
port: { alias: 'p', requiresArg: true, type: 'number' },
};
let extractor: YargsCliExtractor;
beforeEach(async(): Promise<void> => {
extractor = new YargsCliExtractor(parameters);
});
afterEach(async(): Promise<void> => {
jest.clearAllMocks();
});
it('returns parsed results.', async(): Promise<void> => {
const argv = [ 'node', 'script', '-b', 'http://localhost:3000/', '-p', '3000' ];
await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({
baseUrl: 'http://localhost:3000/',
port: 3000,
}));
});
it('accepts full flags.', async(): Promise<void> => {
const argv = [ 'node', 'script', '--baseUrl', 'http://localhost:3000/', '--port', '3000' ];
await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({
baseUrl: 'http://localhost:3000/',
port: 3000,
}));
});
it('defaults to no parameters if none are provided.', async(): Promise<void> => {
extractor = new YargsCliExtractor();
const argv = [ 'node', 'script', '-b', 'http://localhost:3000/', '-p', '3000' ];
await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({}));
});
it('prints usage if defined.', async(): Promise<void> => {
extractor = new YargsCliExtractor(parameters, { usage: 'node ./bin/server.js [args]' });
const argv = [ 'node', 'script', '--help' ];
await extractor.handle(argv);
expect(exit).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenLastCalledWith(expect.stringMatching(/^node \.\/bin\/server\.js \[args\]/u));
});
it('can error on undefined parameters.', async(): Promise<void> => {
extractor = new YargsCliExtractor(parameters, { strictMode: true });
const argv = [ 'node', 'script', '--unsupported' ];
await extractor.handle(argv);
expect(exit).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith('Unknown argument: unsupported');
});
it('can parse environment variables.', async(): Promise<void> => {
// While the code below does go into the corresponding values,
// yargs does not see the new environment variable for some reason.
// It does see all the env variables that were already in there
// (which can be tested by setting envVarPrefix to '').
// This can probably be fixed by changing jest setup to already load the custom env before loading the tests,
// but does not seem worth it just for this test.
const { env } = process;
// eslint-disable-next-line @typescript-eslint/naming-convention
process.env = { ...env, TEST_ENV_PORT: '3333' };
extractor = new YargsCliExtractor(parameters, { loadFromEnv: true, envVarPrefix: 'TEST_ENV' });
const argv = [ 'node', 'script', '-b', 'http://localhost:3333/' ];
await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({
baseUrl: 'http://localhost:3333/',
}));
process.env = env;
// This part is here for the case of envVarPrefix being defined
// since it doesn't make much sense to test it if the above doesn't work
extractor = new YargsCliExtractor(parameters, { loadFromEnv: true });
await extractor.handle(argv);
});
});

View File

@ -0,0 +1,38 @@
import { CombinedSettingsResolver } from '../../../../src/init/variables/CombinedSettingsResolver';
import type { SettingsExtractor } from '../../../../src/init/variables/extractors/SettingsExtractor';
describe('A CombinedSettingsResolver', (): void => {
const values = { test: 'data' };
const varPort = 'urn:solid-server:default:variable:port';
const varLog = 'urn:solid-server:default:variable:loggingLevel';
let computerPort: jest.Mocked<SettingsExtractor>;
let computerLog: jest.Mocked<SettingsExtractor>;
let resolver: CombinedSettingsResolver;
beforeEach(async(): Promise<void> => {
computerPort = {
handleSafe: jest.fn().mockResolvedValue(3000),
} as any;
computerLog = {
handleSafe: jest.fn().mockResolvedValue('info'),
} as any;
resolver = new CombinedSettingsResolver({
[varPort]: computerPort,
[varLog]: computerLog,
});
});
it('assigns variable values based on the Computers output.', async(): Promise<void> => {
await expect(resolver.handle(values)).resolves.toEqual({
[varPort]: 3000,
[varLog]: 'info',
});
});
it('rethrows the error if something goes wrong.', async(): Promise<void> => {
computerPort.handleSafe.mockRejectedValueOnce(new Error('bad data'));
await expect(resolver.handle(values)).rejects.toThrow(`Error in computing value for variable ${varPort}: bad data`);
});
});

View File

@ -0,0 +1,28 @@
import { AssetPathExtractor } from '../../../../../src/init/variables/extractors/AssetPathExtractor';
import { joinFilePath } from '../../../../../src/util/PathUtil';
describe('An AssetPathExtractor', (): void => {
let resolver: AssetPathExtractor;
beforeEach(async(): Promise<void> => {
resolver = new AssetPathExtractor('path');
});
it('resolves the asset path.', async(): Promise<void> => {
await expect(resolver.handle({ path: '/var/data' })).resolves.toBe('/var/data');
});
it('errors if the path is not a string.', async(): Promise<void> => {
await expect(resolver.handle({ path: 1234 })).rejects.toThrow('Invalid path argument');
});
it('converts paths containing the module path placeholder.', async(): Promise<void> => {
await expect(resolver.handle({ path: '@css:config/file.json' }))
.resolves.toEqual(joinFilePath(__dirname, '../../../../../config/file.json'));
});
it('defaults to the given path if none is provided.', async(): Promise<void> => {
resolver = new AssetPathExtractor('path', '/root');
await expect(resolver.handle({ otherPath: '/var/data' })).resolves.toBe('/root');
});
});

View File

@ -0,0 +1,22 @@
import { BaseUrlExtractor } from '../../../../../src/init/variables/extractors/BaseUrlExtractor';
describe('A BaseUrlExtractor', (): void => {
let computer: BaseUrlExtractor;
beforeEach(async(): Promise<void> => {
computer = new BaseUrlExtractor();
});
it('extracts the baseUrl parameter.', async(): Promise<void> => {
await expect(computer.handle({ baseUrl: 'http://example.com/', port: 3333 }))
.resolves.toBe('http://example.com/');
});
it('uses the port parameter if baseUrl is not defined.', async(): Promise<void> => {
await expect(computer.handle({ port: 3333 })).resolves.toBe('http://localhost:3333/');
});
it('defaults to port 3000.', async(): Promise<void> => {
await expect(computer.handle({})).resolves.toBe('http://localhost:3000/');
});
});

View File

@ -0,0 +1,19 @@
import { KeyExtractor } from '../../../../../src/init/variables/extractors/KeyExtractor';
describe('An KeyExtractor', (): void => {
const key = 'test';
let extractor: KeyExtractor;
beforeEach(async(): Promise<void> => {
extractor = new KeyExtractor(key);
});
it('extracts the value.', async(): Promise<void> => {
await expect(extractor.handle({ test: 'data', notTest: 'notData' })).resolves.toBe('data');
});
it('defaults to a given value if none is defined.', async(): Promise<void> => {
extractor = new KeyExtractor(key, 'defaultData');
await expect(extractor.handle({ notTest: 'notData' })).resolves.toBe('defaultData');
});
});