diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7e91caf8c..9a75fc02d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,16 +3,23 @@ ## v3.0.0 ### New features - 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. - 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. +- 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 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: -- ... +- 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. - Conversion has been simplified so most converters are part of the conversion chain: diff --git a/bin/server.js b/bin/server.js index 32a79b07b..0eda47fe6 100755 --- a/bin/server.js +++ b/bin/server.js @@ -1,4 +1,3 @@ #!/usr/bin/env node -// eslint-disable-next-line @typescript-eslint/naming-convention const { AppRunner } = require('..'); -new AppRunner().runCli(process); +new AppRunner().runCliSync(process); diff --git a/config/app/README.md b/config/app/README.md index 1e7b2965a..5366a38a1 100644 --- a/config/app/README.md +++ b/config/app/README.md @@ -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. 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. + +## 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` diff --git a/config/app/variables/cli/cli.json b/config/app/variables/cli/cli.json new file mode 100644 index 000000000..cc44651c2 --- /dev/null +++ b/config/app/variables/cli/cli.json @@ -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]" + } + } + ] +} diff --git a/config/app/variables/default.json b/config/app/variables/default.json new file mode 100644 index 000000000..103467e2a --- /dev/null +++ b/config/app/variables/default.json @@ -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" } + } + ] +} diff --git a/config/app/variables/resolver/resolver.json b/config/app/variables/resolver/resolver.json new file mode 100644 index 000000000..46cf92016 --- /dev/null +++ b/config/app/variables/resolver/resolver.json @@ -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" + } + } + ] + } + ] +} diff --git a/config/default.json b/config/default.json index aa4346295..709856448 100644 --- a/config/default.json +++ b/config/default.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-prefilled-root.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/dynamic.json b/config/dynamic.json index d6552ca80..d731158e9 100644 --- a/config/dynamic.json +++ b/config/dynamic.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/example-https-file.json b/config/example-https-file.json index 77b2163a3..8ec5248bc 100644 --- a/config/example-https-file.json +++ b/config/example-https-file.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.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/middleware/websockets.json", diff --git a/config/file-no-setup.json b/config/file-no-setup.json index 26ff5f0ba..b4c5096a2 100644 --- a/config/file-no-setup.json +++ b/config/file-no-setup.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/file.json b/config/file.json index 4e220273a..40948c7ed 100644 --- a/config/file.json +++ b/config/file.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/memory-subdomains.json b/config/memory-subdomains.json index fef343dd2..bb1d61362 100644 --- a/config/memory-subdomains.json +++ b/config/memory-subdomains.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/path-routing.json b/config/path-routing.json index f05e98833..50296f710 100644 --- a/config/path-routing.json +++ b/config/path-routing.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/restrict-idp.json b/config/restrict-idp.json index 4ad5edfd2..f531031d9 100644 --- a/config/restrict-idp.json +++ b/config/restrict-idp.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/sparql-endpoint-no-setup.json b/config/sparql-endpoint-no-setup.json index 4f4f9731d..04ea8ebbe 100644 --- a/config/sparql-endpoint-no-setup.json +++ b/config/sparql-endpoint-no-setup.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/sparql-endpoint.json b/config/sparql-endpoint.json index af64b6186..bf63d0453 100644 --- a/config/sparql-endpoint.json +++ b/config/sparql-endpoint.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.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/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/util/variables/default.json b/config/util/variables/default.json index d259bcfb6..beb5aabb3 100644 --- a/config/util/variables/default.json +++ b/config/util/variables/default.json @@ -8,7 +8,7 @@ "@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", "@type": "Variable" }, diff --git a/package-lock.json b/package-lock.json index cb0b422a4..fb0f745d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@types/url-join": "^4.0.0", "@types/uuid": "^8.3.0", "@types/ws": "^8.2.0", - "@types/yargs": "^17.0.0", + "@types/yargs": "^17.0.8", "arrayify-stream": "^1.0.0", "async-lock": "^1.3.0", "bcrypt": "^5.0.1", @@ -4982,9 +4982,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.0.tgz", - "integrity": "sha512-RS7u2X7vdXjVQs160PWY1pjLBw6GJj04utojn0KU8p2rRZR37FSzzK6XOT+KLzT/DVbDYRyezroc0LHIvM5Z2A==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.8.tgz", + "integrity": "sha512-wDeUwiUmem9FzsyysEwRukaEdDNcwbROvQ9QGRKaLI6t+IltNzbn4/i4asmB10auvZGQCzSQ6t0GSczEThlUXw==", "dependencies": { "@types/yargs-parser": "*" } @@ -19663,9 +19663,9 @@ } }, "@types/yargs": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.0.tgz", - "integrity": "sha512-RS7u2X7vdXjVQs160PWY1pjLBw6GJj04utojn0KU8p2rRZR37FSzzK6XOT+KLzT/DVbDYRyezroc0LHIvM5Z2A==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.8.tgz", + "integrity": "sha512-wDeUwiUmem9FzsyysEwRukaEdDNcwbROvQ9QGRKaLI6t+IltNzbn4/i4asmB10auvZGQCzSQ6t0GSczEThlUXw==", "requires": { "@types/yargs-parser": "*" } diff --git a/package.json b/package.json index 1baf8d757..9bf645625 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@types/url-join": "^4.0.0", "@types/uuid": "^8.3.0", "@types/ws": "^8.2.0", - "@types/yargs": "^17.0.0", + "@types/yargs": "^17.0.8", "arrayify-stream": "^1.0.0", "async-lock": "^1.3.0", "bcrypt": "^5.0.1", diff --git a/src/index.ts b/src/index.ts index 498bd2212..f3ed16de2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -180,9 +180,24 @@ export * from './init/final/ParallelFinalizer'; // Init/Setup 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 export * from './init/App'; export * from './init/AppRunner'; +export * from './init/CliResolver'; export * from './init/ConfigPodInitializer'; export * from './init/ContainerInitializer'; export * from './init/Initializer'; diff --git a/src/init/AppRunner.ts b/src/init/AppRunner.ts index 87bda754e..39b4913a7 100644 --- a/src/init/AppRunner.ts +++ b/src/init/AppRunner.ts @@ -1,152 +1,199 @@ /* eslint-disable unicorn/no-process-exit */ - -import type { ReadStream, WriteStream } from 'tty'; -import type { IComponentsManagerBuilderOptions, LogLevel } from 'componentsjs'; +import type { WriteStream } from 'tty'; +import type { IComponentsManagerBuilderOptions } from 'componentsjs'; import { ComponentsManager } from 'componentsjs'; import yargs from 'yargs'; +import { LOG_LEVELS } from '../logging/LogLevel'; 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 { 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 { - loggingLevel: string; - port: number; - baseUrl?: string; - rootFilePath?: string; - sparqlEndpoint?: string; - showStackTrace?: boolean; - podConfigJson?: string; -} +const DEFAULT_CLI_RESOLVER = 'urn:solid-server-app-setup:default:CliResolver'; +const DEFAULT_APP = 'urn:solid-server:default:App'; +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 { private readonly logger = getLoggerFor(this); /** * Starts the server with a given config. * 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 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( loaderProperties: IComponentsManagerBuilderOptions, configFile: string, - variableParams: CliParams, + variableBindings: VariableBindings, ): Promise { - const app = await this.createApp(loaderProperties, configFile, variableParams); + const app = await this.create(loaderProperties, configFile, variableBindings); await app.start(); } /** - * Starts the server as a command-line application. - * Made non-async to lower the risk of unhandled promise rejections. - * @param args - Command line arguments. - * @param stderr - Standard error stream. - */ - public runCli({ - argv = process.argv, - stderr = process.stderr, - }: { - argv?: string[]; - stdin?: ReadStream; - stdout?: WriteStream; - 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 - const loaderProperties: IComponentsManagerBuilderOptions = { - 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) - .then( - async(app): Promise => app.start(), - (error: Error): void => { - // Instantiation of components has failed, so there is no logger to use - stderr.write(`Error: could not instantiate server from ${configFile}\n`); - stderr.write(`${error.stack}\n`); - process.exit(1); - }, - ).catch((error): void => { - this.logger.error(`Could not start server: ${error}`, { error }); - process.exit(1); - }); - } - - /** - * Creates the main app object to start the server from a given config. + * Returns an App object, created with the given config, that can start and stop the Solid server. + * 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 configFile - Path to a Components.js config file. - * @param variables - Variables to pass into the config file. + * @param configFile - Path to the server config file. + * @param variableBindings - Bindings of Components.js variables. */ - public async createApp( + public async create( loaderProperties: IComponentsManagerBuilderOptions, configFile: string, - variables: CliParams | Record, + variableBindings: VariableBindings, ): Promise { - // Translate command-line parameters if needed - if (typeof variables.loggingLevel === 'string') { - variables = this.createVariables(variables as CliParams); - } + // Create a resolver to translate (non-core) CLI parameters into values for variables + const componentsManager = await this.createComponentsManager(loaderProperties, configFile); - // 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 }); + // Create the application using the translated variable values + return componentsManager.instantiate(DEFAULT_APP, { variables: variableBindings }); } /** - * Translates command-line parameters into Components.js variables. + * Starts the server as a command-line application. + * Will exit the process on failure. + * + * Made non-async to lower the risk of unhandled promise rejections. + * This is only relevant when this is used to start as a Node.js application on its own, + * if you use this as part of your code you probably want to use the async version. + * + * @param argv - Command line arguments. + * @param stderr - Stream that should be used to output errors before the logger is enabled. */ - protected createVariables(params: CliParams): Record { - return { - 'urn:solid-server:default:variable:baseUrl': - params.baseUrl ? ensureTrailingSlash(params.baseUrl) : `http://localhost:${params.port}/`, - 'urn:solid-server:default:variable:loggingLevel': params.loggingLevel, - 'urn:solid-server:default:variable:port': params.port, - 'urn:solid-server:default:variable:rootFilePath': resolveAssetPath(params.rootFilePath), - 'urn:solid-server:default:variable:sparqlEndpoint': params.sparqlEndpoint, - 'urn:solid-server:default:variable:showStackTrace': params.showStackTrace, - 'urn:solid-server:default:variable:podConfigJson': resolveAssetPath(params.podConfigJson), + public runCliSync({ argv, stderr = process.stderr }: { argv?: CliArgv; stderr?: WriteStream }): void { + this.runCli(argv).catch((error): never => { + stderr.write(createErrorMessage(error)); + process.exit(1); + }); + } + + /** + * Starts the server as a command-line application. + * @param argv - Command line arguments. + */ + public async runCli(argv?: CliArgv): Promise { + const app = await this.createCli(argv); + try { + await app.start(); + } catch (error: unknown) { + this.logger.error(`Could not start the server: ${createErrorMessage(error)}`); + this.resolveError('Could not start the server', error); + } + } + + /** + * 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. + */ + public async createCli(argv: CliArgv = process.argv): Promise { + // Parse only the core CLI arguments needed to load the configuration + const yargv = yargs(argv.slice(2)) + .usage('node ./bin/server.js [args]') + .options(CORE_CLI_PARAMETERS) + // We disable help here as it would only show the core parameters + .help(false); + + const params = await yargv.parse(); + + 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; + 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( + loaderProperties: IComponentsManagerBuilderOptions, + configFile: string, + ): Promise> { + 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, argv: string[]): + Promise { + 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, variables: Record): Promise { + 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); } } diff --git a/src/init/CliResolver.ts b/src/init/CliResolver.ts new file mode 100644 index 000000000..3ecf1880e --- /dev/null +++ b/src/init/CliResolver.ts @@ -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; + } +} diff --git a/src/init/cli/CliExtractor.ts b/src/init/cli/CliExtractor.ts new file mode 100644 index 000000000..b11a6b96d --- /dev/null +++ b/src/init/cli/CliExtractor.ts @@ -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 {} diff --git a/src/init/cli/YargsCliExtractor.ts b/src/init/cli/YargsCliExtractor.ts new file mode 100644 index 000000000..2330174e2 --- /dev/null +++ b/src/init/cli/YargsCliExtractor.ts @@ -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; + +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 { + 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); + } +} diff --git a/src/init/variables/CombinedSettingsResolver.ts b/src/init/variables/CombinedSettingsResolver.ts new file mode 100644 index 000000000..9fe2a9df7 --- /dev/null +++ b/src/init/variables/CombinedSettingsResolver.ts @@ -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; + + public constructor(computers: Record) { + super(); + this.computers = computers; + } + + public async handle(input: Record): Promise> { + const vars: Record = {}; + 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; + } +} diff --git a/src/init/variables/SettingsResolver.ts b/src/init/variables/SettingsResolver.ts new file mode 100644 index 000000000..c2f2fa985 --- /dev/null +++ b/src/init/variables/SettingsResolver.ts @@ -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 {} diff --git a/src/init/variables/Types.ts b/src/init/variables/Types.ts new file mode 100644 index 000000000..4cb155d27 --- /dev/null +++ b/src/init/variables/Types.ts @@ -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; + +/** + * A key/value mapping of Components.js variables. + */ +export type VariableBindings = Record; diff --git a/src/init/variables/extractors/AssetPathExtractor.ts b/src/init/variables/extractors/AssetPathExtractor.ts new file mode 100644 index 000000000..7c14e5760 --- /dev/null +++ b/src/init/variables/extractors/AssetPathExtractor.ts @@ -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 { + const path = args[this.key] ?? this.defaultPath; + if (typeof path !== 'string') { + throw new Error(`Invalid ${this.key} argument`); + } + return resolveAssetPath(path); + } +} diff --git a/src/init/variables/extractors/BaseUrlExtractor.ts b/src/init/variables/extractors/BaseUrlExtractor.ts new file mode 100644 index 000000000..3081e1c79 --- /dev/null +++ b/src/init/variables/extractors/BaseUrlExtractor.ts @@ -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 { + if (typeof args.baseUrl === 'string') { + return ensureTrailingSlash(args.baseUrl); + } + const port = args.port ?? this.defaultPort; + return `http://localhost:${port}/`; + } +} diff --git a/src/init/variables/extractors/KeyExtractor.ts b/src/init/variables/extractors/KeyExtractor.ts new file mode 100644 index 000000000..e97129db8 --- /dev/null +++ b/src/init/variables/extractors/KeyExtractor.ts @@ -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 { + return typeof args[this.key] === 'undefined' ? this.defaultValue : args[this.key]; + } +} diff --git a/src/init/variables/extractors/SettingsExtractor.ts b/src/init/variables/extractors/SettingsExtractor.ts new file mode 100644 index 000000000..c1a7a3c95 --- /dev/null +++ b/src/init/variables/extractors/SettingsExtractor.ts @@ -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 {} diff --git a/src/logging/LogLevel.ts b/src/logging/LogLevel.ts index e67f9755e..136944833 100644 --- a/src/logging/LogLevel.ts +++ b/src/logging/LogLevel.ts @@ -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. */ -export type LogLevel = 'error' | 'warn' | 'info' | 'verbose' | 'debug' | 'silly'; +export type LogLevel = typeof LOG_LEVELS[number]; diff --git a/test/unit/init/AppRunner.test.ts b/test/unit/init/AppRunner.test.ts index ec206e4df..4ed64b7be 100644 --- a/test/unit/init/AppRunner.test.ts +++ b/test/unit/init/AppRunner.test.ts @@ -1,14 +1,38 @@ import { ComponentsManager } from 'componentsjs'; import type { App } from '../../../src/init/App'; 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'; const app: jest.Mocked = { start: jest.fn(), } as any; +const defaultParameters = { + port: 3000, + logLevel: 'info', +}; +const cliExtractor: jest.Mocked = { + 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 = { + handleSafe: jest.fn().mockResolvedValue(defaultVariables), +} as any; + const manager: jest.Mocked> = { - instantiate: jest.fn(async(): Promise => app), + instantiate: jest.fn(async(iri: string): Promise => { + 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: { register: jest.fn(), }, @@ -22,7 +46,6 @@ jest.mock('componentsjs', (): any => ({ })); 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 exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); @@ -31,8 +54,52 @@ describe('AppRunner', (): void => { jest.clearAllMocks(); }); + describe('create', (): void => { + it('creates an App with the provided settings.', async(): Promise => { + 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 => { - it('starts the server with default settings.', async(): Promise => { + it('starts the server with provided settings.', async(): Promise => { + 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( { mainModulePath: joinFilePath(__dirname, '../../../'), @@ -40,13 +107,7 @@ describe('AppRunner', (): void => { logLevel: 'info', }, joinFilePath(__dirname, '../../../config/default.json'), - { - port: 3000, - loggingLevel: 'info', - rootFilePath: '/var/cwd/', - showStackTrace: false, - podConfigJson: '/var/cwd/pod-config.json', - }, + variables, ); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); @@ -59,35 +120,17 @@ describe('AppRunner', (): void => { expect(manager.configRegistry.register) .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); expect(manager.instantiate).toHaveBeenCalledTimes(1); - expect(manager.instantiate).toHaveBeenCalledWith( - 'urn:solid-server:default:App', - { - 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(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server:default:App', { variables }); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0); expect(app.start).toHaveBeenCalledTimes(1); expect(app.start).toHaveBeenCalledWith(); }); }); - describe('runCli', (): void => { - it('starts the server with default settings.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + describe('createCli', (): void => { + it('creates the server with default settings.', async(): Promise => { + await expect(new AppRunner().createCli([ 'node', 'script' ])).resolves.toBe(app); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledWith({ @@ -98,133 +141,21 @@ describe('AppRunner', (): void => { expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); expect(manager.configRegistry.register) .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); - expect(manager.instantiate).toHaveBeenCalledTimes(1); - 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([ 'node', 'script' ]); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, 'urn:solid-server:default:App', - { - 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).toHaveBeenCalledWith(); - }); - - it('accepts abbreviated flags.', async(): Promise => { - 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 => { - 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 => { - 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'), - ); + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(0); }); it('uses the default process.argv in case none are provided.', async(): Promise => { const { argv } = process; - process.argv = [ + const argvParameters = [ 'node', 'script', '-b', 'http://pod.example/', '-c', 'myconfig.json', @@ -236,13 +167,9 @@ describe('AppRunner', (): void => { '-t', '--podConfigJson', '/different-path.json', ]; + process.argv = argvParameters; - new AppRunner().runCli(); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + await expect(new AppRunner().createCli()).resolves.toBe(app); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledWith({ @@ -252,119 +179,181 @@ describe('AppRunner', (): void => { }); expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); 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', - { - 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', - }, - }, - ); + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(0); process.argv = argv; }); - it('exits with output to stderr when instantiation fails.', async(): Promise => { - manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); + it('throws an error if creating a ComponentsManager fails.', async(): Promise => { + (manager.configRegistry.register as jest.Mock).mockRejectedValueOnce(new Error('Fatal')); - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + 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(2); - expect(write).toHaveBeenNthCalledWith(1, - expect.stringMatching(/^Error: could not instantiate server from .*default\.json/u)); - expect(write).toHaveBeenNthCalledWith(2, - expect.stringMatching(/^Error: Fatal/u)); - - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); }); - it('exits without output to stderr when initialization fails.', async(): Promise => { - app.start.mockRejectedValueOnce(new Error('Fatal')); - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); + it('throws an error if instantiating the CliResolver fails.', async(): Promise => { + manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); + 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); + }); + + it('throws an error if instantiating the server fails.', async(): Promise => { + // We want the second call to fail + 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); + }); + + it('throws an error if non-error objects get thrown.', async(): Promise => { + (manager.configRegistry.register as jest.Mock).mockRejectedValueOnce('NotAnError'); + + 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(/^Cause: Unknown error: NotAnError$/mu); + + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); + }); + }); + + describe('runCli', (): void => { + it('runs the server.', async(): Promise => { + 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 => { + app.start.mockRejectedValueOnce(new Error('Fatal')); + + let caughtError: Error = new Error('should disappear'); + try { + 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(exit).toHaveBeenCalledWith(1); + expect(exit).toHaveBeenCalledTimes(0); }); + }); - it('exits when unknown options are passed to the main executable.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '--foo' ], - }); + describe('runCliSync', (): void => { + it('starts the server.', async(): Promise => { + // 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. await new Promise((resolve): void => { setImmediate(resolve); }); - expect(error).toHaveBeenCalledWith('Unknown argument: foo'); - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); + 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('exits when no value is passed to the main executable for an argument.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '-s' ], - }); + it('exits the process and writes to stderr if there was an error.', async(): Promise => { + manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - // 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 => { 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).toHaveBeenCalledWith(1); - }); - - it('exits when unknown parameters are passed to the main executable.', async(): Promise => { - 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 => { - 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); + expect(exit).toHaveBeenLastCalledWith(1); }); }); }); diff --git a/test/unit/init/CliResolver.test.ts b/test/unit/init/CliResolver.test.ts new file mode 100644 index 000000000..65a45f036 --- /dev/null +++ b/test/unit/init/CliResolver.test.ts @@ -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 => { + 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); + }); +}); diff --git a/test/unit/init/cli/YargsCliExtractor.test.ts b/test/unit/init/cli/YargsCliExtractor.test.ts new file mode 100644 index 000000000..312aa0c82 --- /dev/null +++ b/test/unit/init/cli/YargsCliExtractor.test.ts @@ -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 => { + extractor = new YargsCliExtractor(parameters); + }); + + afterEach(async(): Promise => { + jest.clearAllMocks(); + }); + + it('returns parsed results.', async(): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + // 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); + }); +}); diff --git a/test/unit/init/variables/CombinedSettingsResolver.test.ts b/test/unit/init/variables/CombinedSettingsResolver.test.ts new file mode 100644 index 000000000..8f262e14a --- /dev/null +++ b/test/unit/init/variables/CombinedSettingsResolver.test.ts @@ -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; + let computerLog: jest.Mocked; + let resolver: CombinedSettingsResolver; + + beforeEach(async(): Promise => { + 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 => { + await expect(resolver.handle(values)).resolves.toEqual({ + [varPort]: 3000, + [varLog]: 'info', + }); + }); + + it('rethrows the error if something goes wrong.', async(): Promise => { + computerPort.handleSafe.mockRejectedValueOnce(new Error('bad data')); + await expect(resolver.handle(values)).rejects.toThrow(`Error in computing value for variable ${varPort}: bad data`); + }); +}); diff --git a/test/unit/init/variables/extractors/AssetPathExtractor.test.ts b/test/unit/init/variables/extractors/AssetPathExtractor.test.ts new file mode 100644 index 000000000..352d5d5d8 --- /dev/null +++ b/test/unit/init/variables/extractors/AssetPathExtractor.test.ts @@ -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 => { + resolver = new AssetPathExtractor('path'); + }); + + it('resolves the asset path.', async(): Promise => { + await expect(resolver.handle({ path: '/var/data' })).resolves.toBe('/var/data'); + }); + + it('errors if the path is not a string.', async(): Promise => { + await expect(resolver.handle({ path: 1234 })).rejects.toThrow('Invalid path argument'); + }); + + it('converts paths containing the module path placeholder.', async(): Promise => { + 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 => { + resolver = new AssetPathExtractor('path', '/root'); + await expect(resolver.handle({ otherPath: '/var/data' })).resolves.toBe('/root'); + }); +}); diff --git a/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts new file mode 100644 index 000000000..21b46e36a --- /dev/null +++ b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts @@ -0,0 +1,22 @@ +import { BaseUrlExtractor } from '../../../../../src/init/variables/extractors/BaseUrlExtractor'; + +describe('A BaseUrlExtractor', (): void => { + let computer: BaseUrlExtractor; + + beforeEach(async(): Promise => { + computer = new BaseUrlExtractor(); + }); + + it('extracts the baseUrl parameter.', async(): Promise => { + 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 => { + await expect(computer.handle({ port: 3333 })).resolves.toBe('http://localhost:3333/'); + }); + + it('defaults to port 3000.', async(): Promise => { + await expect(computer.handle({})).resolves.toBe('http://localhost:3000/'); + }); +}); diff --git a/test/unit/init/variables/extractors/KeyExtractor.test.ts b/test/unit/init/variables/extractors/KeyExtractor.test.ts new file mode 100644 index 000000000..21a25ce09 --- /dev/null +++ b/test/unit/init/variables/extractors/KeyExtractor.test.ts @@ -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 => { + extractor = new KeyExtractor(key); + }); + + it('extracts the value.', async(): Promise => { + await expect(extractor.handle({ test: 'data', notTest: 'notData' })).resolves.toBe('data'); + }); + + it('defaults to a given value if none is defined.', async(): Promise => { + extractor = new KeyExtractor(key, 'defaultData'); + await expect(extractor.handle({ notTest: 'notData' })).resolves.toBe('defaultData'); + }); +});