From 701b47cf51446df00260bf50562fac0b06981449 Mon Sep 17 00:00:00 2001 From: Matthew Francis Brunetti Date: Fri, 18 Sep 2020 07:39:49 -0400 Subject: [PATCH] feat(core): Add serviceDefaults config --- composite-service.api.md | 3 +- src/core/CompositeServiceConfig.ts | 8 +- src/core/ServiceConfig.ts | 11 +- src/core/validateAndNormalizeConfig.ts | 111 +++++++++++------- test/integration/crashing.test.ts | 5 +- .../core/validateAndNormalizeConfig.test.ts | 27 ++++- 6 files changed, 108 insertions(+), 57 deletions(-) diff --git a/composite-service.api.md b/composite-service.api.md index 3811480..b3dc2ad 100644 --- a/composite-service.api.md +++ b/composite-service.api.md @@ -12,6 +12,7 @@ import stream from 'stream'; export interface CompositeServiceConfig { gracefulShutdown?: boolean; logLevel?: 'debug' | 'info' | 'error'; + serviceDefaults?: ServiceConfig; services: { [id: string]: ServiceConfig | false | null | undefined | 0 | ''; }; @@ -62,7 +63,7 @@ export interface ReadyContext { // @public export interface ServiceConfig { - command: string | string[]; + command?: string | string[]; cwd?: string; dependencies?: string[]; env?: { diff --git a/src/core/CompositeServiceConfig.ts b/src/core/CompositeServiceConfig.ts index f61c30c..837f179 100644 --- a/src/core/CompositeServiceConfig.ts +++ b/src/core/CompositeServiceConfig.ts @@ -48,7 +48,13 @@ export interface CompositeServiceConfig { windowsCtrlCShutdown?: boolean /** - * Configuration for each service to be composed. + * Configuration to use as defaults for every service. + * Defaults to `{}`. + */ + serviceDefaults?: ServiceConfig + + /** + * Configuration for each specific service. * * @remarks * diff --git a/src/core/ServiceConfig.ts b/src/core/ServiceConfig.ts index ccc5903..5765d3b 100644 --- a/src/core/ServiceConfig.ts +++ b/src/core/ServiceConfig.ts @@ -30,17 +30,18 @@ export interface ServiceConfig { cwd?: string /** - * Command used to run the service + * Command used to run the service. + * No default. * * @remarks * - * If it's a single string, it will be parsed into binary and arguments. - * Otherwise it must be an array of strings - * where the first element is the binary, and the remaining elements are the arguments. + * If it's an array of strings, the first element is the binary, and the remaining elements are the arguments. + * If it's a single string, it will be parsed into the format described above + * with a simple `command.split(/\s+/).filter(Boolean)`. * * The binary part can be the name (path & extension not required) of a Node.js CLI program. */ - command: string | string[] + command?: string | string[] /** * Environment variables to pass to the service. diff --git a/src/core/validateAndNormalizeConfig.ts b/src/core/validateAndNormalizeConfig.ts index 56a9cd3..b365698 100644 --- a/src/core/validateAndNormalizeConfig.ts +++ b/src/core/validateAndNormalizeConfig.ts @@ -1,5 +1,5 @@ -import { getCheckers } from '@zen_flow/ts-interface-builder/macro' -import { Checker, IErrorDetail } from 'ts-interface-checker' +import { getTypeSuite } from '@zen_flow/ts-interface-builder/macro' +import { createCheckers, IErrorDetail } from 'ts-interface-checker' import { CompositeServiceConfig } from './CompositeServiceConfig' import { ServiceConfig } from './ServiceConfig' import { LogLevel } from './Logger' @@ -27,14 +27,13 @@ export interface NormalizedServiceConfig { export function validateAndNormalizeConfig( config: CompositeServiceConfig, ): NormalizedCompositeServiceConfig { - const checker = getCheckers('./CompositeServiceConfig.ts', { - ignoreIndexSignature: true, - }).CompositeServiceConfig - validateType(checker, 'config', config) + validateType('CompositeServiceConfig', 'config', config) const { logLevel = 'info' } = config const { gracefulShutdown = false } = config const { windowsCtrlCShutdown = false } = config + const { serviceDefaults = {} } = config + doExtraServiceConfigChecks('config.serviceDefaults', serviceDefaults) const truthyServiceEntries = Object.entries(config.services).filter( ([, value]) => value, @@ -44,7 +43,7 @@ export function validateAndNormalizeConfig( } const services: { [id: string]: NormalizedServiceConfig } = {} for (const [id, config] of truthyServiceEntries) { - services[id] = validateServiceConfig(id, config) + services[id] = processServiceConfig(id, config, serviceDefaults) } validateDependencyTree(services) @@ -56,52 +55,71 @@ export function validateAndNormalizeConfig( } } -function validateServiceConfig( - id: string, - config: ServiceConfig, -): NormalizedServiceConfig { - const checker = getCheckers('./ServiceConfig.ts', { - ignoreIndexSignature: true, - }).ServiceConfig - validateType(checker, `config.services.${id}`, config) - +function doExtraServiceConfigChecks(path: string, config: ServiceConfig) { if ( typeof config.command !== 'undefined' && (Array.isArray(config.command) ? !config.command.length || !config.command[0].trim() : !config.command.trim()) ) { - throw new ConfigValidationError( - `\`config.services.${id}.command\` is empty`, - ) + throw new ConfigValidationError(`\`${path}.command\` has no binary part`) + } +} + +function processServiceConfig( + id: string, + config: ServiceConfig, + defaults: ServiceConfig, +): NormalizedServiceConfig { + const path = `config.services.${id}` + validateType('ServiceConfig', path, config) + doExtraServiceConfigChecks(path, config) + const merged = { + dependencies: [], + cwd: '.', + // no default command + env: {}, + ready: () => Promise.resolve(), + onCrash: () => {}, + logTailLength: 0, + minimumRestartDelay: 1000, + ...removeUndefinedProperties(defaults), + ...removeUndefinedProperties(config), + } + if (merged.command === undefined) { + throw new ConfigValidationError(`\`${path}.command\` is not defined`) + } + return { + ...merged, + command: normalizeCommand(merged.command), + env: normalizeEnv(merged.env), + } +} + +function removeUndefinedProperties( + object: T, +): T { + const result = { ...object } + for (const [key, value] of Object.entries(result)) { + if (value === undefined) { + delete result[key] + } } + return result +} + +function normalizeCommand(command: string | string[]): string[] { + return Array.isArray(command) ? command : command.split(/\s+/).filter(Boolean) +} - // normalize - const { dependencies = [] } = config - const { cwd = '.' } = config - const command = - typeof config.command === 'string' - ? config.command.split(/\s+/).filter(Boolean) - : config.command - const env = Object.fromEntries( - Object.entries(config.env || {}) +function normalizeEnv(env: { + [p: string]: string | number | undefined +}): { [p: string]: string } { + return Object.fromEntries( + Object.entries(env) .filter(([, value]) => value !== undefined) .map(([key, value]) => [key, String(value)]), ) - const { ready = () => Promise.resolve() } = config - const { onCrash = () => {} } = config - const { logTailLength = 0 } = config - const { minimumRestartDelay = 1000 } = config - return { - dependencies, - cwd, - command, - env, - ready, - onCrash, - logTailLength, - minimumRestartDelay, - } } function validateDependencyTree(services: { @@ -148,7 +166,14 @@ export class ConfigValidationError extends Error { ConfigValidationError.prototype.name = ConfigValidationError.name -function validateType(checker: Checker, reportedPath: string, value: any) { +const tsInterfaceBuilderOptions = { ignoreIndexSignature: true } +const checkers = createCheckers({ + ...getTypeSuite('./CompositeServiceConfig.ts', tsInterfaceBuilderOptions), + ...getTypeSuite('./ServiceConfig.ts', tsInterfaceBuilderOptions), +}) + +function validateType(typeName: string, reportedPath: string, value: any) { + const checker = checkers[typeName] checker.setReportedPath(reportedPath) const error = checker.validate(value) if (error) { diff --git a/test/integration/crashing.test.ts b/test/integration/crashing.test.ts index 94bce6c..fbec78d 100644 --- a/test/integration/crashing.test.ts +++ b/test/integration/crashing.test.ts @@ -11,17 +11,18 @@ function getScript(customCode = '') { const { onceOutputLineIs, configureHttpGateway, startCompositeService } = require('.'); const config = { gracefulShutdown: true, + serviceDefaults: { + ready: ctx => onceOutputLineIs(ctx.output, 'Started 🚀\\n'), + }, services: { api: { command: 'node test/integration/fixtures/http-service.js', env: { PORT: 8000, RESPONSE_TEXT: 'api' }, - ready: ctx => onceOutputLineIs(ctx.output, 'Started 🚀\\n'), }, web: { dependencies: ['api'], command: ['node', 'test/integration/fixtures/http-service.js'], env: { PORT: 8001, RESPONSE_TEXT: 'web' }, - ready: ctx => onceOutputLineIs(ctx.output, 'Started 🚀\\n'), }, gateway: configureHttpGateway({ dependencies: ['api', 'web'], diff --git a/test/unit/core/validateAndNormalizeConfig.test.ts b/test/unit/core/validateAndNormalizeConfig.test.ts index 5849fbf..acd7bfb 100644 --- a/test/unit/core/validateAndNormalizeConfig.test.ts +++ b/test/unit/core/validateAndNormalizeConfig.test.ts @@ -19,7 +19,7 @@ const _vs = (serviceConfig: any) => { describe('core/validateAndNormalizeConfig', () => { describe('CompositeServiceConfig', () => { - it('services property', () => { + it('essential', () => { expect(_v(undefined)).toMatchInlineSnapshot( `"ConfigValidationError: \`config\` is not an object"`, ) @@ -39,9 +39,11 @@ describe('core/validateAndNormalizeConfig', () => { `"ConfigValidationError: \`config.services.foo\` is not an object"`, ) expect(_v({ services: { foo: {} } })).toMatchInlineSnapshot( - `"ConfigValidationError: \`config.services.foo.command\` is missing"`, + `"ConfigValidationError: \`config.services.foo.command\` is not defined"`, ) expect(_v(minValid)).toBeUndefined() + }) + it('service dependency tree', () => { expect( _v({ services: { @@ -76,6 +78,21 @@ describe('core/validateAndNormalizeConfig', () => { ) expect(_v({ ...minValid, logLevel: 'debug' })).toBeUndefined() }) + it('serviceDefaults property', () => { + expect(_v({ serviceDefaults: { command: false }, services: { foo: {} } })) + .toMatchInlineSnapshot(` + "ConfigValidationError: \`config.serviceDefaults\` is not a ServiceConfig + \`config.serviceDefaults.command\` is none of string, 1 more" + `) + expect( + _v({ serviceDefaults: { command: '' }, services: { foo: {} } }), + ).toMatchInlineSnapshot( + `"ConfigValidationError: \`config.serviceDefaults.command\` has no binary part"`, + ) + expect( + _v({ serviceDefaults: { command: 'foo' }, services: { foo: {} } }), + ).toBeUndefined() + }) }) describe('ServiceConfig', () => { it('dependencies property', () => { @@ -92,14 +109,14 @@ describe('core/validateAndNormalizeConfig', () => { `"ConfigValidationError: \`config.services.foo.command\` is none of string, 1 more"`, ) expect(_vs({ command: '' })).toMatchInlineSnapshot( - `"ConfigValidationError: \`config.services.foo.command\` is empty"`, + `"ConfigValidationError: \`config.services.foo.command\` has no binary part"`, ) expect(_vs({ command: 'foo' })).toBeUndefined() expect(_vs({ command: [] })).toMatchInlineSnapshot( - `"ConfigValidationError: \`config.services.foo.command\` is empty"`, + `"ConfigValidationError: \`config.services.foo.command\` has no binary part"`, ) expect(_vs({ command: [''] })).toMatchInlineSnapshot( - `"ConfigValidationError: \`config.services.foo.command\` is empty"`, + `"ConfigValidationError: \`config.services.foo.command\` has no binary part"`, ) expect(_vs({ command: ['foo'] })).toBeUndefined() expect(_vs({ command: ['foo', false] })).toMatchInlineSnapshot(`