Skip to content

Commit

Permalink
feat(core): Add serviceDefaults config
Browse files Browse the repository at this point in the history
  • Loading branch information
zenflow committed Sep 21, 2020
1 parent 92d1952 commit 701b47c
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 57 deletions.
3 changes: 2 additions & 1 deletion composite-service.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | '';
};
Expand Down Expand Up @@ -62,7 +63,7 @@ export interface ReadyContext {

// @public
export interface ServiceConfig {
command: string | string[];
command?: string | string[];
cwd?: string;
dependencies?: string[];
env?: {
Expand Down
8 changes: 7 additions & 1 deletion src/core/CompositeServiceConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
11 changes: 6 additions & 5 deletions src/core/ServiceConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
111 changes: 68 additions & 43 deletions src/core/validateAndNormalizeConfig.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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<T extends { [key: string]: any }>(
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: {
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions test/integration/crashing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
27 changes: 22 additions & 5 deletions test/unit/core/validateAndNormalizeConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`,
)
Expand All @@ -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: {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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(`
Expand Down

0 comments on commit 701b47c

Please sign in to comment.