src/validateAndNormalizeConfig.ts
import { getTypeSuite } from "ts-interface-builder/macro";import { createCheckers, IErrorDetail } from "ts-interface-checker";import { CompositeServiceConfig } from "./interfaces/CompositeServiceConfig";import { ServiceConfig } from "./interfaces/ServiceConfig";import { LogLevel } from "./Logger";import { ReadyContext } from "./interfaces/ReadyContext";import { OnCrashContext } from "./interfaces/OnCrashContext"; export interface NormalizedCompositeServiceConfig { logLevel: LogLevel; gracefulShutdown: boolean; windowsCtrlCShutdown: boolean; services: { [id: string]: NormalizedServiceConfig };} export interface NormalizedServiceConfig { dependencies: string[]; cwd: string; command: string[]; env: { [key: string]: string }; ready: (ctx: ReadyContext) => Promise<void>; forceKillTimeout: number; onCrash: (ctx: OnCrashContext) => void; crashesLength: number; logTailLength: number; minimumRestartDelay: number;} export function validateAndNormalizeConfig( config: CompositeServiceConfig): NormalizedCompositeServiceConfig { validateType("CompositeServiceConfig", "config", config); const { logLevel = "info" } = config; const windowsCtrlCShutdown = process.platform === "win32" && Boolean(config.windowsCtrlCShutdown); const gracefulShutdown = !windowsCtrlCShutdown && Boolean(config.gracefulShutdown); const { serviceDefaults = {} } = config; doExtraServiceConfigChecks("config.serviceDefaults", serviceDefaults); const truthyServiceEntries = Object.entries(config.services).filter(([, value]) => value) as [ string, ServiceConfig ][]; if (truthyServiceEntries.length === 0) { throw new ConfigValidationError("`config.services` has no entries"); } const services: { [id: string]: NormalizedServiceConfig } = {}; for (const [id, config] of truthyServiceEntries) { services[id] = processServiceConfig(id, config, serviceDefaults); } validateDependencyTree(services); return { logLevel, gracefulShutdown, windowsCtrlCShutdown, services, };} /** * Structural validations for ServerConfig not covered by validateType/ts-interface-checker */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(`\`${path}.command\` has no binary part`); }} const serviceBaseDefaults = { dependencies: [], cwd: ".", command: undefined, // no default command env: process.env, ready: () => Promise.resolve(), forceKillTimeout: 5000, onCrash: (ctx: OnCrashContext) => { if (!ctx.isServiceReady) throw new Error("Crashed before becoming ready"); }, crashesLength: 0, logTailLength: 0, minimumRestartDelay: 0,}; function processServiceConfig( id: string, config: ServiceConfig, defaults: ServiceConfig): NormalizedServiceConfig { const path = `config.services.${id}`; validateType("ServiceConfig", path, config); doExtraServiceConfigChecks(path, config); const merged = { ...serviceBaseDefaults, ...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);} 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)]) );} Function `validateDependencyTree` has a Cognitive Complexity of 14 (exceeds 5 allowed). Consider refactoring.
Function `validateDependencyTree` has 33 lines of code (exceeds 25 allowed). Consider refactoring.function validateDependencyTree(services: { [id: string]: NormalizedServiceConfig }): void { const serviceIds = Object.keys(services); for (const [serviceId, { dependencies }] of Object.entries(services)) { for (const dependency of dependencies) { if (!serviceIds.includes(dependency)) { throw new ConfigValidationError( `Service "${serviceId}" depends on unknown service "${dependency}"` ); } if (services[dependency].ready === serviceBaseDefaults.ready) { throw new ConfigValidationError( `Service "${serviceId}" depends on service "${dependency}" which has no defined \`ready\` config` ); } } } for (const serviceId of serviceIds) { validateNoCyclicDeps(serviceId, []); } function validateNoCyclicDeps(serviceId: string, path: string[]) { const isLooped = path.includes(serviceId); if (isLooped) { throw new ConfigValidationError( `Service "${serviceId}" has cyclic dependency ${path .slice(path.indexOf(serviceId)) .concat(serviceId) .join(" -> ")}` ); } for (const dep of services[serviceId].dependencies) { validateNoCyclicDeps(dep, [...path, serviceId]); } return null; }} export class ConfigValidationError extends Error { constructor(message: string) { super(message); Object.setPrototypeOf(this, ConfigValidationError.prototype); }} ConfigValidationError.prototype.name = ConfigValidationError.name; const tsInterfaceBuilderOptions = { ignoreIndexSignature: true };const checkers = createCheckers({ ...getTypeSuite("./interfaces/CompositeServiceConfig.ts", tsInterfaceBuilderOptions), ...getTypeSuite("./interfaces/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) { throw new ConfigValidationError(getErrorMessage(error[0])); }} function getErrorMessage(error: IErrorDetail): string { return getErrorMessageLines(error).join("\n");} function getErrorMessageLines(error: IErrorDetail): string[] { let result = [`\`${error.path}\` ${error.message}`]; if (error.nested) { for (const nested of error.nested) { result = result.concat(getErrorMessageLines(nested).map((s) => ` ${s}`)); } } return result;}