diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 109b8fbb..158f4cad 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -48,6 +48,10 @@ export const sourceTypes: SourceType[] = [ 'vlc' ]; +export const isSourceType = (data: string): data is SourceType => { + return sourceTypes.includes(data as SourceType); +} + export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; export type ClientType = @@ -59,6 +63,9 @@ export const clientTypes: ClientType[] = [ 'lastfm', 'listenbrainz' ]; +export const isClientType = (data: string): data is ClientType => { + return clientTypes.includes(data as ClientType); +} export type InitState = 0 | 1 | 2; export const NOT_INITIALIZED: InitState = 0; diff --git a/src/backend/common/infrastructure/config/aioConfig.ts b/src/backend/common/infrastructure/config/aioConfig.ts index 05cd5e5e..cfe6134d 100644 --- a/src/backend/common/infrastructure/config/aioConfig.ts +++ b/src/backend/common/infrastructure/config/aioConfig.ts @@ -5,6 +5,7 @@ import { RequestRetryOptions } from "./common.js"; import { WebhookConfig } from "./health/webhooks.js"; import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js"; import { SourceAIOConfig } from "./source/sources.js"; +import { ClientType, SourceType } from "../Atomic.js"; export interface SourceDefaults extends CommonSourceOptions { @@ -70,7 +71,22 @@ export interface AIOClientConfig { clients?: ClientAIOConfig[] } +export interface AIOClientRelaxedConfig { + clientDefaults?: RequestRetryOptions + clients?: object[] +} + export interface AIOSourceConfig { sourceDefaults?: SourceRetryOptions sources?: SourceAIOConfig[] } + +export interface AIOSourceRelaxedConfig { + sourceDefaults?: SourceRetryOptions + sources?: object[] +} + +export interface TypedConfig { + type: T + // [key: string]: any +} \ No newline at end of file diff --git a/src/backend/index.ts b/src/backend/index.ts index b74a34ca..d97aeead 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -16,6 +16,7 @@ import { initServer } from "./server/index.js"; import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js"; import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js"; import { parseBool, readJson, sleep } from "./utils.js"; +import { getTsConfigGenerator } from './utils/SchemaUtils.js'; dayjs.extend(utc) dayjs.extend(isBetween); @@ -83,6 +84,10 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const root = getRoot({...config, logger}); initLogger.info(`Version: ${root.get('version')}`); + initLogger.info('Generating schema definitions...'); + getTsConfigGenerator(); + initLogger.info('Schema definitions generated'); + initServer(logger, appLoggerStream, output); if(process.env.IS_LOCAL === 'true') { diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 886147f3..5081a0eb 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -2,21 +2,22 @@ import { childLogger, Logger } from '@foxxmd/logging'; import dayjs, { Dayjs } from "dayjs"; import { PlayObject } from "../../core/Atomic.js"; -import { clientTypes, ConfigMeta } from "../common/infrastructure/Atomic.js"; +import { ClientType, clientTypes, ConfigMeta, isClientType } from "../common/infrastructure/Atomic.js"; import { AIOConfig } from "../common/infrastructure/config/aioConfig.js"; import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js"; import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js"; import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js"; import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js"; -import * as aioSchema from '../common/schema/aio-client.json'; -import * as clientSchema from '../common/schema/client.json'; import { WildcardEmitter } from "../common/WildcardEmitter.js"; import { Notifiers } from "../notifier/Notifiers.js"; -import { joinedUrl, readJson, validateJson, } from "../utils.js"; +import { joinedUrl, readJson, thresholdResultSummary } from "../utils.js"; +import { getTypeSchemaFromConfigGenerator } from "../utils/SchemaUtils.js"; +import { validateJson } from "../utils/ValidationUtils.js"; import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; import LastfmScrobbler from "./LastfmScrobbler.js"; import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js"; import MalojaScrobbler from "./MalojaScrobbler.js"; +import { Definition } from 'typescript-json-schema'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -30,6 +31,8 @@ export default class ScrobbleClients { configDir: string; localUrl: URL; + private schemaDefinitions: Record = {}; + emitter: WildcardEmitter; sourceEmitter: WildcardEmitter; @@ -74,6 +77,23 @@ export default class ScrobbleClients { return [clientsReady, messages]; } + private getSchemaByType = (type: ClientType): Definition => { + if(this.schemaDefinitions[type] === undefined) { + switch(type) { + case 'maloja': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MalojaClientConfig"); + break; + case 'lastfm': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastfmClientConfig"); + break; + case 'listenbrainz': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenBrainzClientConfig"); + break; + } + } + return this.schemaDefinitions[type]; + } + buildClientsFromConfig = async (notifier: Notifiers) => { const configs: ParsedConfig[] = []; @@ -84,27 +104,33 @@ export default class ScrobbleClients { // think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore throw new Error('config.json could not be parsed'); } + + const relaxedSchema = getTypeSchemaFromConfigGenerator("AIOClientRelaxedConfig"); + let clientDefaults = {}; if (configFile !== undefined) { - const aioConfig = validateJson(configFile, aioSchema, this.logger); + const aioConfig = validateJson(configFile, relaxedSchema, this.logger); const { clients: mainConfigClientConfigs = [], clientDefaults: cd = {}, } = aioConfig; clientDefaults = cd; - // const validMainConfigs = mainConfigClientConfigs.reduce((acc: any, curr: any, i: any) => { - // if(curr === null) { - // this.logger.error(`The client config entry at index ${i} in config.json is null but should be an object, will not parse`); - // return acc; - // } - // if(typeof curr !== 'object') { - // this.logger.error(`The client config entry at index ${i} in config.json should be an object, will not parse`); - // return acc; - // } - // return acc.concat(curr); - // }, []); - for (const c of mainConfigClientConfigs) { + for (const [index, c] of mainConfigClientConfigs.entries()) { const {name = 'unnamed'} = c; + if(!isClientType(c.type.toLocaleLowerCase())) { + this.logger.error(`Client config ${index + 1} (${name}) in config.json has an invalid client type of '${c.type}'. Must be one of ${clientTypes.join(' | ')}`); + continue; + } + if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) { + this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`); + continue; + } + try { + validateJson(c, this.getSchemaByType(c.type.toLocaleLowerCase() as ClientType), this.logger); + } catch (e) { + this.logger.error(new Error(`Client config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`, {cause: e})); + continue; + } configs.push({...c, name, source: 'config.json', @@ -193,8 +219,14 @@ export default class ScrobbleClients { continue; } for(const [i,rawConf] of rawClientConfigs.entries()) { + if(['lastfm','listenbrainz'].includes(clientType) && + ((rawConf as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) + { + this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`); + continue; + } try { - const validConfig = validateJson(rawConf, clientSchema, this.logger); + const validConfig = validateJson(rawConf, this.getSchemaByType(clientType), this.logger); // @ts-expect-error configureAs should exist const {configureAs = defaultConfigureAs} = validConfig; if (configureAs === 'client') { @@ -206,38 +238,12 @@ export default class ScrobbleClients { configs.push(parsedConfig); } } catch (e: any) { - this.logger.error(`The config entry at index ${i} from ${clientType}.json was not valid`); + this.logger.error(new Error(`The config entry at index ${i} from ${clientType}.json was not valid`, {cause: e})); } } -/* for (const [i,m] of clientConfigs.entries()) { - if(m === null) { - this.logger.error(`The config entry at index ${i} from ${clientType}.json is null`); - continue; - } - if (typeof m !== 'object') { - this.logger.error(`The config entry at index ${i} from ${clientType}.json was not an object, skipping`, m); - continue; - } - const {configureAs = defaultConfigureAs} = m; - if(configureAs === 'client') { - m.source = `${clientType}.json`; - m.type = clientType; - configs.push(m); - } - }*/ } } - // we have all possible client configurations so we'll check they are minimally valid - /*const validConfigs = configs.reduce((acc, c) => { - const isValid = isValidConfigStructure(c, {type: true, data: true}); - if (isValid !== true) { - this.logger.error(`Client config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] will not be used because it has structural errors: ${isValid.join(' | ')}`); - return acc; - } - return acc.concat(c); - }, []);*/ - // all client configs are minimally valid // now check that names are unique const nameGroupedConfigs = configs.reduce((acc: groupedNamedConfigs, curr: ParsedConfig) => { diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index da33da3a..555e1059 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -1,7 +1,7 @@ /* eslint-disable no-case-declarations */ import { childLogger, Logger } from '@foxxmd/logging'; import EventEmitter from "events"; -import { ConfigMeta, InternalConfig, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js"; +import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js"; import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js"; import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js"; import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; @@ -27,10 +27,9 @@ import { TautulliSourceConfig } from "../common/infrastructure/config/source/tau import { VLCData, VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js"; import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; -import * as aioSchema from "../common/schema/aio-source.json"; -import * as sourceSchema from "../common/schema/source.json"; import { WildcardEmitter } from "../common/WildcardEmitter.js"; -import { parseBool, readJson, validateJson } from "../utils.js"; +import { parseBool, readJson } from "../utils.js"; +import { validateJson } from "../utils/ValidationUtils.js"; import AbstractSource from "./AbstractSource.js"; import { ChromecastSource } from "./ChromecastSource.js"; import DeezerSource from "./DeezerSource.js"; @@ -51,6 +50,8 @@ import TautulliSource from "./TautulliSource.js"; import { VLCSource } from "./VLCSource.js"; import { WebScrobblerSource } from "./WebScrobblerSource.js"; import YTMusicSource from "./YTMusicSource.js"; +import { Definition } from 'typescript-json-schema'; +import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -64,11 +65,13 @@ export default class ScrobbleSources { logger: Logger; internalConfig: InternalConfig; + private schemaDefinitions: Record = {}; + emitter: WildcardEmitter; constructor(emitter: EventEmitter, internal: InternalConfigOptional, parentLogger: Logger) { this.emitter = emitter; - this.logger = childLogger(parentLogger, 'Sources'); // winston.loggers.get('app').child({labels: ['Sources']}, mergeArr); + this.logger = childLogger(parentLogger, 'Sources'); this.internalConfig = { ...internal, logger: this.logger @@ -108,6 +111,68 @@ export default class ScrobbleSources { return [sourcesReady, messages]; } + private getSchemaByType = (type: SourceType): Definition => { + if(this.schemaDefinitions[type] === undefined) { + switch(type) { + case 'spotify': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SpotifySourceConfig"); + break; + case 'plex': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("PlexSourceConfig"); + break; + case 'tautulli': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig"); + break; + case 'deezer': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig"); + break; + case 'subsonic': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig"); + break; + case 'jellyfin': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("JellyfinCompatConfig"); + break; + case 'lastfm': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastfmSourceConfig"); + break; + case 'ytmusic': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("YTMusicSourceConfig"); + break; + case 'mpris': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPRISSourceConfig"); + break; + case 'mopidy': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MopidySourceConfig"); + break; + case 'listenbrainz': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenBrainzSourceConfig"); + break; + case 'jriver': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("JRiverSourceConfig"); + break; + case 'kodi': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KodiSourceConfig"); + break; + case 'chromecast': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ChromecastSourceConfig"); + break; + case 'webscrobbler': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("WebScrobblerSourceConfig"); + break; + case 'musikcube': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusikcubeSourceConfig"); + break; + case 'mpd': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig"); + break; + case 'vlc': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig"); + break; + } + } + return this.schemaDefinitions[type]; + } + buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => { const configs: ParsedConfig[] = additionalConfigs; @@ -117,27 +182,34 @@ export default class ScrobbleSources { } catch (e) { throw new Error('config.json could not be parsed'); } + + const relaxedSchema = getTypeSchemaFromConfigGenerator("AIOSourceRelaxedConfig"); + let sourceDefaults = {}; if (configFile !== undefined) { - const aioConfig = validateJson(configFile, aioSchema, this.logger); + const aioConfig = validateJson(configFile, relaxedSchema, this.logger); const { sources: mainConfigSourcesConfigs = [], sourceDefaults: sd = {}, } = aioConfig; sourceDefaults = sd; -/* const validMainConfigs = mainConfigSourcesConfigs.reduce((acc: any, curr: any, i: any) => { - if(curr === null) { - this.logger.error(`The source config entry at index ${i} in config.json is null but should be an object, will not parse`); - return acc; + for (const [index, c] of mainConfigSourcesConfigs.entries()) { + const {name = 'unnamed'} = c; + if(!isSourceType(c.type.toLocaleLowerCase())) { + this.logger.error(`Source config ${index + 1} (${name}) in config.json has an invalid source type of '${c.type}'. Must be one of ${sourceTypes.join(' | ')}`); + continue; } - if(typeof curr !== 'object') { - this.logger.error(`The source config entry at index ${i} in config.json should be an object, will not parse`); - return acc; + if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source')) + { + this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`); + continue; + } + try { + validateJson(c, this.getSchemaByType(c.type.toLocaleLowerCase() as SourceType), this.logger); + } catch (e) { + this.logger.error(new Error(`Source config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`, {cause: e})); + continue; } - return acc.concat(curr); - }, []);*/ - for (const c of mainConfigSourcesConfigs) { - const {name = 'unnamed'} = c; configs.push({...c, name, source: 'config.json', @@ -419,8 +491,14 @@ export default class ScrobbleSources { continue; } for (const [i,rawConf] of sourceConfigs.entries()) { + if(['lastfm','listenbrainz'].includes(sourceType) && + ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source')) + { + this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`); + continue; + } try { - const validConfig = validateJson(rawConf, sourceSchema, this.logger); + const validConfig = validateJson(rawConf, this.getSchemaByType(sourceType), this.logger); // @ts-expect-error will eventually have all info (lazy) const parsedConfig: ParsedConfig = { @@ -428,38 +506,14 @@ export default class ScrobbleSources { source: `${sourceType}.json`, type: sourceType } - - if(!['lastfm','listenbrainz'].includes(sourceType) || ((validConfig as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs === 'source')) { - configs.push(parsedConfig); - } else { - if('configureAs' in validConfig) { - if(validConfig.configureAs === 'source') { - configs.push(parsedConfig); - } else { - this.logger.verbose(`${sourceType} has 'configureAs: client' so will skip adding as a source`); - } - } else { - this.logger.verbose(`${sourceType} did not have 'configureAs' specified! Assuming 'client' so will skip adding as a source`); - } - } + configs.push(parsedConfig); } catch (e: any) { - this.logger.error(`The config entry at index ${i} from ${sourceType}.json was not valid`); + this.logger.error(new Error(`The config entry at index ${i} from ${sourceType}.json was not valid`, {cause: e})); } } } } - // we have all possible configurations so we'll check they are minimally valid -/* const validConfigs = configs.reduce((acc, c) => { - const isValid = isValidConfigStructure(c, {type: true, data: true}); - if (isValid !== true) { - // @ts-expect-error TS(2339): Property 'source' does not exist on type 'never'. - this.logger.error(`Source config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] will not be used because it has structural errors: ${isValid.join(' | ')}`); - return acc; - } - return acc.concat(c); - }, []);*/ - // finally! all configs are valid, structurally, and can now be passed to addClient // do a last check that names (within each type) are unique and warn if not, but add anyways const typeGroupedConfigs = configs.reduce((acc: groupedNamedConfigs, curr: ParsedConfig) => { diff --git a/src/backend/utils.ts b/src/backend/utils.ts index d7d5325e..641cdc4d 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -1,9 +1,7 @@ import { Logger } from '@foxxmd/logging'; -import { parseRegexSingle, SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core"; +import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import backoffStrategies from '@kenyip/backoff-strategies'; import address from "address"; -import * as AjvNS from 'ajv'; -import Ajv, { Schema } from 'ajv'; import { replaceResultTransformer, stripIndentTransformer, TemplateTag, trimResultTransformer } from 'common-tags'; import dayjs, { Dayjs } from 'dayjs'; import { Duration } from "dayjs/plugin/duration.js"; @@ -351,62 +349,6 @@ export const parseDurationFromTimestamp = (timestamp: any) => { }); } -export const createAjvFactory = (logger: Logger): AjvNS.default => { - const validator = new Ajv.default({logger: logger, verbose: true, strict: "log", allowUnionTypes: true}); - // https://ajv.js.org/strict-mode.html#unknown-keywords - validator.addKeyword('deprecationMessage'); - return validator; -} - -export const validateJson = (config: object, schema: Schema, logger: Logger): T => { - const ajv = createAjvFactory(logger); - const valid = ajv.validate(schema, config); - if (valid) { - return config as unknown as T; - } else { - logger.error('Json config was not valid. Please use schema to check validity.', {leaf: 'Config'}); - if (Array.isArray(ajv.errors)) { - for (const err of ajv.errors) { - const parts = [ - `At: ${err.instancePath}`, - ]; - let data; - if (typeof err.data === 'string') { - data = err.data; - } else if (err.data !== null && typeof err.data === 'object' && (err.data as any).name !== undefined) { - data = `Object named '${(err.data as any).name}'`; - } - if (data !== undefined) { - parts.push(`Data: ${data}`); - } - let suffix = ''; - if (err.params.allowedValues !== undefined) { - suffix = err.params.allowedValues.join(', '); - suffix = ` [${suffix}]`; - } - parts.push(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`); - - // if we have a reference in the description parse it out so we can log it here for context - if (err.parentSchema !== undefined && err.parentSchema.description !== undefined) { - const desc = err.parentSchema.description as string; - const seeIndex = desc.indexOf('[See]'); - if (seeIndex !== -1) { - let newLineIndex: number | undefined = desc.indexOf('\n', seeIndex); - if (newLineIndex === -1) { - newLineIndex = undefined; - } - const seeFragment = desc.slice(seeIndex + 5, newLineIndex); - parts.push(`See:${seeFragment}`); - } - } - - logger.error(`Schema Error:\r\n${parts.join('\r\n')}`, {leaf: 'Config'}); - } - } - throw new Error('Config schema validity failure'); - } -} - export const remoteHostIdentifiers = (req: Request): RemoteIdentityParts => { const remote = req.connection.remoteAddress; const proxyRemote = Array.isArray(req.headers["x-forwarded-for"]) ? req.headers["x-forwarded-for"][0] : req.headers["x-forwarded-for"]; diff --git a/src/backend/utils/SchemaUtils.ts b/src/backend/utils/SchemaUtils.ts new file mode 100644 index 00000000..5b3b2688 --- /dev/null +++ b/src/backend/utils/SchemaUtils.ts @@ -0,0 +1,47 @@ +import * as TJS from "typescript-json-schema"; +import {sync} from "glob"; +import { resolve } from "path"; +import { projectDir } from "../common/index.js"; + +// const includeOnly = [ +// sync(resolve(projectDir, "src/backend/**/*.ts"), {ignore: resolve(projectDir, "src/backend/tests/**/*")}), +// sync(resolve(projectDir, "src/core/**/*.ts")) +// ].flat(1); + +export const buildSchemaGenerator = (program?: TJS.Program, settings: TJS.PartialArgs = {}) => { + return TJS.buildGenerator(program, {...defaultGeneratorArgs, ...settings}); +} + +let configProgram: TJS.Program, +generatorFromConfig: TJS.JsonSchemaGenerator; + +export const getTsConfigProgram = (): TJS.Program => { + if(configProgram === undefined) { + const tsConfig = resolve(projectDir, "src/backend/tsconfig.json"); + configProgram = TJS.programFromConfig(tsConfig, + sync(resolve(projectDir, "src/backend/common/infrastructure/config/**/*.ts")) + ); + } + return configProgram; +} + +export const getTsConfigGenerator = (): TJS.JsonSchemaGenerator => { + if(generatorFromConfig === undefined) { + generatorFromConfig = buildSchemaGenerator(getTsConfigProgram()); + } + return generatorFromConfig; +} + +export const getTypeSchemaFromConfigGenerator = (type: string): TJS.Definition | null => { + return TJS.generateSchema(getTsConfigProgram(), type, undefined, [], getTsConfigGenerator()); +} + +export const defaultGeneratorArgs: TJS.PartialArgs = { + required: true, + titles: true, + tsNodeRegister: true, + validationKeywords: ['deprecationMessage'], + constAsEnum: true, + ref: true, + esModuleInterop: true +}; \ No newline at end of file diff --git a/src/backend/utils/ValidationUtils.ts b/src/backend/utils/ValidationUtils.ts new file mode 100644 index 00000000..5ecbffa1 --- /dev/null +++ b/src/backend/utils/ValidationUtils.ts @@ -0,0 +1,58 @@ +import { Logger } from "@foxxmd/logging"; +import * as AjvNS from "ajv"; +import Ajv, { Schema } from "ajv"; + +export const createAjvFactory = (logger: Logger): AjvNS.default => { + const validator = new Ajv.default({logger: logger, verbose: true, strict: "log", allowUnionTypes: true}); + // https://ajv.js.org/strict-mode.html#unknown-keywords + validator.addKeyword('deprecationMessage'); + return validator; +} +export const validateJson = (config: object, schema: Schema, logger: Logger): T => { + const ajv = createAjvFactory(logger); + const valid = ajv.validate(schema, config); + if (valid) { + return config as unknown as T; + } else { + const schemaErrors = ['Json config was not valid. Please use schema to check validity.']; + if (Array.isArray(ajv.errors)) { + for (const err of ajv.errors) { + const parts = [ + `At: ${err.instancePath}`, + ]; + let data; + if (typeof err.data === 'string') { + data = err.data; + } else if (err.data !== null && typeof err.data === 'object' && (err.data as any).name !== undefined) { + data = `Object named '${(err.data as any).name}'`; + } + if (data !== undefined) { + parts.push(`Data: ${data}`); + } + let suffix = ''; + if (err.params.allowedValues !== undefined) { + suffix = err.params.allowedValues.join(', '); + suffix = ` [${suffix}]`; + } + parts.push(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`); + + // if we have a reference in the description parse it out so we can log it here for context + if (err.parentSchema !== undefined && err.parentSchema.description !== undefined) { + const desc = err.parentSchema.description as string; + const seeIndex = desc.indexOf('[See]'); + if (seeIndex !== -1) { + let newLineIndex: number | undefined = desc.indexOf('\n', seeIndex); + if (newLineIndex === -1) { + newLineIndex = undefined; + } + const seeFragment = desc.slice(seeIndex + 5, newLineIndex); + parts.push(`See:${seeFragment}`); + } + } + + schemaErrors.push(`Schema Error:\r\n${parts.join('\r\n')}`); + } + } + throw new Error(schemaErrors.join('\n\n')); + } +} diff --git a/tsconfig.json b/tsconfig.json index 1b606f42..109f70a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "sourceMap": true, + "sourceMap": false, }, "include": [ "src"