[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Improve client/source schema validation generation and error handling

* Use runtime-generated schemas
* Ensures validated schemas always match code
* Can use schemas for individual source/clients without having to generate files
* catch invididual validation errors and log instead of crashing
* refactor validation error output into chained error instead of as individual log statements to improve clarity

FoxxMD (Oct 9, 2024, 12:18 PM EDT) 5b26cc73 72a4c454

+284 -149
+7
src/backend/common/infrastructure/Atomic.ts
··· 48 48 'vlc' 49 49 ]; 50 50 51 + export const isSourceType = (data: string): data is SourceType => { 52 + return sourceTypes.includes(data as SourceType); 53 + } 54 + 51 55 export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; 52 56 53 57 export type ClientType = ··· 59 63 'lastfm', 60 64 'listenbrainz' 61 65 ]; 66 + export const isClientType = (data: string): data is ClientType => { 67 + return clientTypes.includes(data as ClientType); 68 + } 62 69 63 70 export type InitState = 0 | 1 | 2; 64 71 export const NOT_INITIALIZED: InitState = 0;
+16
src/backend/common/infrastructure/config/aioConfig.ts
··· 5 5 import { WebhookConfig } from "./health/webhooks.js"; 6 6 import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js"; 7 7 import { SourceAIOConfig } from "./source/sources.js"; 8 + import { ClientType, SourceType } from "../Atomic.js"; 8 9 9 10 10 11 export interface SourceDefaults extends CommonSourceOptions { ··· 70 71 clients?: ClientAIOConfig[] 71 72 } 72 73 74 + export interface AIOClientRelaxedConfig { 75 + clientDefaults?: RequestRetryOptions 76 + clients?: object[] 77 + } 78 + 73 79 export interface AIOSourceConfig { 74 80 sourceDefaults?: SourceRetryOptions 75 81 sources?: SourceAIOConfig[] 76 82 } 83 + 84 + export interface AIOSourceRelaxedConfig { 85 + sourceDefaults?: SourceRetryOptions 86 + sources?: object[] 87 + } 88 + 89 + export interface TypedConfig<T = string> { 90 + type: T 91 + // [key: string]: any 92 + }
+5
src/backend/index.ts
··· 16 16 import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js"; 17 17 import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js"; 18 18 import { parseBool, readJson, sleep } from "./utils.js"; 19 + import { getTsConfigGenerator } from './utils/SchemaUtils.js'; 19 20 20 21 dayjs.extend(utc) 21 22 dayjs.extend(isBetween); ··· 82 83 83 84 const root = getRoot({...config, logger}); 84 85 initLogger.info(`Version: ${root.get('version')}`); 86 + 87 + initLogger.info('Generating schema definitions...'); 88 + getTsConfigGenerator(); 89 + initLogger.info('Schema definitions generated'); 85 90 86 91 initServer(logger, appLoggerStream, output); 87 92
+51 -45
src/backend/scrobblers/ScrobbleClients.ts
··· 2 2 import { childLogger, Logger } from '@foxxmd/logging'; 3 3 import dayjs, { Dayjs } from "dayjs"; 4 4 import { PlayObject } from "../../core/Atomic.js"; 5 - import { clientTypes, ConfigMeta } from "../common/infrastructure/Atomic.js"; 5 + import { ClientType, clientTypes, ConfigMeta, isClientType } from "../common/infrastructure/Atomic.js"; 6 6 import { AIOConfig } from "../common/infrastructure/config/aioConfig.js"; 7 7 import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js"; 8 8 import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js"; 9 9 import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js"; 10 10 import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js"; 11 - import * as aioSchema from '../common/schema/aio-client.json'; 12 - import * as clientSchema from '../common/schema/client.json'; 13 11 import { WildcardEmitter } from "../common/WildcardEmitter.js"; 14 12 import { Notifiers } from "../notifier/Notifiers.js"; 15 - import { joinedUrl, readJson, validateJson, } from "../utils.js"; 13 + import { joinedUrl, readJson, thresholdResultSummary } from "../utils.js"; 14 + import { getTypeSchemaFromConfigGenerator } from "../utils/SchemaUtils.js"; 15 + import { validateJson } from "../utils/ValidationUtils.js"; 16 16 import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 17 17 import LastfmScrobbler from "./LastfmScrobbler.js"; 18 18 import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js"; 19 19 import MalojaScrobbler from "./MalojaScrobbler.js"; 20 + import { Definition } from 'typescript-json-schema'; 20 21 21 22 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 22 23 ··· 29 30 logger: Logger; 30 31 configDir: string; 31 32 localUrl: URL; 33 + 34 + private schemaDefinitions: Record<string, Definition> = {}; 32 35 33 36 emitter: WildcardEmitter; 34 37 ··· 74 77 return [clientsReady, messages]; 75 78 } 76 79 80 + private getSchemaByType = (type: ClientType): Definition => { 81 + if(this.schemaDefinitions[type] === undefined) { 82 + switch(type) { 83 + case 'maloja': 84 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MalojaClientConfig"); 85 + break; 86 + case 'lastfm': 87 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastfmClientConfig"); 88 + break; 89 + case 'listenbrainz': 90 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenBrainzClientConfig"); 91 + break; 92 + } 93 + } 94 + return this.schemaDefinitions[type]; 95 + } 96 + 77 97 buildClientsFromConfig = async (notifier: Notifiers) => { 78 98 const configs: ParsedConfig[] = []; 79 99 ··· 84 104 // think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore 85 105 throw new Error('config.json could not be parsed'); 86 106 } 107 + 108 + const relaxedSchema = getTypeSchemaFromConfigGenerator("AIOClientRelaxedConfig"); 109 + 87 110 let clientDefaults = {}; 88 111 if (configFile !== undefined) { 89 - const aioConfig = validateJson<AIOConfig>(configFile, aioSchema, this.logger); 112 + const aioConfig = validateJson<AIOConfig>(configFile, relaxedSchema, this.logger); 90 113 const { 91 114 clients: mainConfigClientConfigs = [], 92 115 clientDefaults: cd = {}, 93 116 } = aioConfig; 94 117 clientDefaults = cd; 95 - // const validMainConfigs = mainConfigClientConfigs.reduce((acc: any, curr: any, i: any) => { 96 - // if(curr === null) { 97 - // this.logger.error(`The client config entry at index ${i} in config.json is null but should be an object, will not parse`); 98 - // return acc; 99 - // } 100 - // if(typeof curr !== 'object') { 101 - // this.logger.error(`The client config entry at index ${i} in config.json should be an object, will not parse`); 102 - // return acc; 103 - // } 104 - // return acc.concat(curr); 105 - // }, []); 106 - for (const c of mainConfigClientConfigs) { 118 + for (const [index, c] of mainConfigClientConfigs.entries()) { 107 119 const {name = 'unnamed'} = c; 120 + if(!isClientType(c.type.toLocaleLowerCase())) { 121 + 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(' | ')}`); 122 + continue; 123 + } 124 + if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) { 125 + this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`); 126 + continue; 127 + } 128 + try { 129 + validateJson<AIOConfig>(c, this.getSchemaByType(c.type.toLocaleLowerCase() as ClientType), this.logger); 130 + } catch (e) { 131 + this.logger.error(new Error(`Client config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`, {cause: e})); 132 + continue; 133 + } 108 134 configs.push({...c, 109 135 name, 110 136 source: 'config.json', ··· 193 219 continue; 194 220 } 195 221 for(const [i,rawConf] of rawClientConfigs.entries()) { 222 + if(['lastfm','listenbrainz'].includes(clientType) && 223 + ((rawConf as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) 224 + { 225 + this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`); 226 + continue; 227 + } 196 228 try { 197 - const validConfig = validateJson<ClientConfig>(rawConf, clientSchema, this.logger); 229 + const validConfig = validateJson<ClientConfig>(rawConf, this.getSchemaByType(clientType), this.logger); 198 230 // @ts-expect-error configureAs should exist 199 231 const {configureAs = defaultConfigureAs} = validConfig; 200 232 if (configureAs === 'client') { ··· 206 238 configs.push(parsedConfig); 207 239 } 208 240 } catch (e: any) { 209 - this.logger.error(`The config entry at index ${i} from ${clientType}.json was not valid`); 241 + this.logger.error(new Error(`The config entry at index ${i} from ${clientType}.json was not valid`, {cause: e})); 210 242 } 211 243 } 212 - /* for (const [i,m] of clientConfigs.entries()) { 213 - if(m === null) { 214 - this.logger.error(`The config entry at index ${i} from ${clientType}.json is null`); 215 - continue; 216 - } 217 - if (typeof m !== 'object') { 218 - this.logger.error(`The config entry at index ${i} from ${clientType}.json was not an object, skipping`, m); 219 - continue; 220 - } 221 - const {configureAs = defaultConfigureAs} = m; 222 - if(configureAs === 'client') { 223 - m.source = `${clientType}.json`; 224 - m.type = clientType; 225 - configs.push(m); 226 - } 227 - }*/ 228 244 } 229 245 } 230 - 231 - // we have all possible client configurations so we'll check they are minimally valid 232 - /*const validConfigs = configs.reduce((acc, c) => { 233 - const isValid = isValidConfigStructure(c, {type: true, data: true}); 234 - if (isValid !== true) { 235 - 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(' | ')}`); 236 - return acc; 237 - } 238 - return acc.concat(c); 239 - }, []);*/ 240 246 241 247 // all client configs are minimally valid 242 248 // now check that names are unique
+98 -44
src/backend/sources/ScrobbleSources.ts
··· 1 1 /* eslint-disable no-case-declarations */ 2 2 import { childLogger, Logger } from '@foxxmd/logging'; 3 3 import EventEmitter from "events"; 4 - import { ConfigMeta, InternalConfig, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js"; 4 + import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js"; 5 5 import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js"; 6 6 import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js"; 7 7 import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; ··· 27 27 import { VLCData, VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; 28 28 import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js"; 29 29 import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; 30 - import * as aioSchema from "../common/schema/aio-source.json"; 31 - import * as sourceSchema from "../common/schema/source.json"; 32 30 import { WildcardEmitter } from "../common/WildcardEmitter.js"; 33 - import { parseBool, readJson, validateJson } from "../utils.js"; 31 + import { parseBool, readJson } from "../utils.js"; 32 + import { validateJson } from "../utils/ValidationUtils.js"; 34 33 import AbstractSource from "./AbstractSource.js"; 35 34 import { ChromecastSource } from "./ChromecastSource.js"; 36 35 import DeezerSource from "./DeezerSource.js"; ··· 51 50 import { VLCSource } from "./VLCSource.js"; 52 51 import { WebScrobblerSource } from "./WebScrobblerSource.js"; 53 52 import YTMusicSource from "./YTMusicSource.js"; 53 + import { Definition } from 'typescript-json-schema'; 54 + import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js'; 54 55 55 56 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 56 57 ··· 64 65 logger: Logger; 65 66 internalConfig: InternalConfig; 66 67 68 + private schemaDefinitions: Record<string, Definition> = {}; 69 + 67 70 emitter: WildcardEmitter; 68 71 69 72 constructor(emitter: EventEmitter, internal: InternalConfigOptional, parentLogger: Logger) { 70 73 this.emitter = emitter; 71 - this.logger = childLogger(parentLogger, 'Sources'); // winston.loggers.get('app').child({labels: ['Sources']}, mergeArr); 74 + this.logger = childLogger(parentLogger, 'Sources'); 72 75 this.internalConfig = { 73 76 ...internal, 74 77 logger: this.logger ··· 108 111 return [sourcesReady, messages]; 109 112 } 110 113 114 + private getSchemaByType = (type: SourceType): Definition => { 115 + if(this.schemaDefinitions[type] === undefined) { 116 + switch(type) { 117 + case 'spotify': 118 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SpotifySourceConfig"); 119 + break; 120 + case 'plex': 121 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("PlexSourceConfig"); 122 + break; 123 + case 'tautulli': 124 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig"); 125 + break; 126 + case 'deezer': 127 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig"); 128 + break; 129 + case 'subsonic': 130 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig"); 131 + break; 132 + case 'jellyfin': 133 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("JellyfinCompatConfig"); 134 + break; 135 + case 'lastfm': 136 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastfmSourceConfig"); 137 + break; 138 + case 'ytmusic': 139 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("YTMusicSourceConfig"); 140 + break; 141 + case 'mpris': 142 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPRISSourceConfig"); 143 + break; 144 + case 'mopidy': 145 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MopidySourceConfig"); 146 + break; 147 + case 'listenbrainz': 148 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenBrainzSourceConfig"); 149 + break; 150 + case 'jriver': 151 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("JRiverSourceConfig"); 152 + break; 153 + case 'kodi': 154 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KodiSourceConfig"); 155 + break; 156 + case 'chromecast': 157 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ChromecastSourceConfig"); 158 + break; 159 + case 'webscrobbler': 160 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("WebScrobblerSourceConfig"); 161 + break; 162 + case 'musikcube': 163 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusikcubeSourceConfig"); 164 + break; 165 + case 'mpd': 166 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig"); 167 + break; 168 + case 'vlc': 169 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig"); 170 + break; 171 + } 172 + } 173 + return this.schemaDefinitions[type]; 174 + } 175 + 111 176 buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => { 112 177 const configs: ParsedConfig[] = additionalConfigs; 113 178 ··· 117 182 } catch (e) { 118 183 throw new Error('config.json could not be parsed'); 119 184 } 185 + 186 + const relaxedSchema = getTypeSchemaFromConfigGenerator("AIOSourceRelaxedConfig"); 187 + 120 188 let sourceDefaults = {}; 121 189 if (configFile !== undefined) { 122 - const aioConfig = validateJson<AIOConfig>(configFile, aioSchema, this.logger); 190 + const aioConfig = validateJson<AIOConfig>(configFile, relaxedSchema, this.logger); 123 191 const { 124 192 sources: mainConfigSourcesConfigs = [], 125 193 sourceDefaults: sd = {}, 126 194 } = aioConfig; 127 195 sourceDefaults = sd; 128 - /* const validMainConfigs = mainConfigSourcesConfigs.reduce((acc: any, curr: any, i: any) => { 129 - if(curr === null) { 130 - this.logger.error(`The source config entry at index ${i} in config.json is null but should be an object, will not parse`); 131 - return acc; 196 + for (const [index, c] of mainConfigSourcesConfigs.entries()) { 197 + const {name = 'unnamed'} = c; 198 + if(!isSourceType(c.type.toLocaleLowerCase())) { 199 + 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(' | ')}`); 200 + continue; 132 201 } 133 - if(typeof curr !== 'object') { 134 - this.logger.error(`The source config entry at index ${i} in config.json should be an object, will not parse`); 135 - return acc; 202 + if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source')) 203 + { 204 + this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`); 205 + continue; 136 206 } 137 - return acc.concat(curr); 138 - }, []);*/ 139 - for (const c of mainConfigSourcesConfigs) { 140 - const {name = 'unnamed'} = c; 207 + try { 208 + validateJson<SourceConfig>(c, this.getSchemaByType(c.type.toLocaleLowerCase() as SourceType), this.logger); 209 + } catch (e) { 210 + this.logger.error(new Error(`Source config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`, {cause: e})); 211 + continue; 212 + } 141 213 configs.push({...c, 142 214 name, 143 215 source: 'config.json', ··· 419 491 continue; 420 492 } 421 493 for (const [i,rawConf] of sourceConfigs.entries()) { 494 + if(['lastfm','listenbrainz'].includes(sourceType) && 495 + ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source')) 496 + { 497 + this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`); 498 + continue; 499 + } 422 500 try { 423 - const validConfig = validateJson<SourceConfig>(rawConf, sourceSchema, this.logger); 501 + const validConfig = validateJson<SourceConfig>(rawConf, this.getSchemaByType(sourceType), this.logger); 424 502 425 503 // @ts-expect-error will eventually have all info (lazy) 426 504 const parsedConfig: ParsedConfig = { ··· 428 506 source: `${sourceType}.json`, 429 507 type: sourceType 430 508 } 431 - 432 - if(!['lastfm','listenbrainz'].includes(sourceType) || ((validConfig as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs === 'source')) { 433 - configs.push(parsedConfig); 434 - } else { 435 - if('configureAs' in validConfig) { 436 - if(validConfig.configureAs === 'source') { 437 - configs.push(parsedConfig); 438 - } else { 439 - this.logger.verbose(`${sourceType} has 'configureAs: client' so will skip adding as a source`); 440 - } 441 - } else { 442 - this.logger.verbose(`${sourceType} did not have 'configureAs' specified! Assuming 'client' so will skip adding as a source`); 443 - } 444 - } 509 + configs.push(parsedConfig); 445 510 } catch (e: any) { 446 - this.logger.error(`The config entry at index ${i} from ${sourceType}.json was not valid`); 511 + this.logger.error(new Error(`The config entry at index ${i} from ${sourceType}.json was not valid`, {cause: e})); 447 512 } 448 513 } 449 514 } 450 515 } 451 - 452 - // we have all possible configurations so we'll check they are minimally valid 453 - /* const validConfigs = configs.reduce((acc, c) => { 454 - const isValid = isValidConfigStructure(c, {type: true, data: true}); 455 - if (isValid !== true) { 456 - // @ts-expect-error TS(2339): Property 'source' does not exist on type 'never'. 457 - 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(' | ')}`); 458 - return acc; 459 - } 460 - return acc.concat(c); 461 - }, []);*/ 462 516 463 517 // finally! all configs are valid, structurally, and can now be passed to addClient 464 518 // do a last check that names (within each type) are unique and warn if not, but add anyways
+1 -59
src/backend/utils.ts
··· 1 1 import { Logger } from '@foxxmd/logging'; 2 - import { parseRegexSingle, SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core"; 2 + import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 3 3 import backoffStrategies from '@kenyip/backoff-strategies'; 4 4 import address from "address"; 5 - import * as AjvNS from 'ajv'; 6 - import Ajv, { Schema } from 'ajv'; 7 5 import { replaceResultTransformer, stripIndentTransformer, TemplateTag, trimResultTransformer } from 'common-tags'; 8 6 import dayjs, { Dayjs } from 'dayjs'; 9 7 import { Duration } from "dayjs/plugin/duration.js"; ··· 349 347 seconds: Number.parseInt(seconds), 350 348 milliseconds: Number.parseInt(milli) 351 349 }); 352 - } 353 - 354 - export const createAjvFactory = (logger: Logger): AjvNS.default => { 355 - const validator = new Ajv.default({logger: logger, verbose: true, strict: "log", allowUnionTypes: true}); 356 - // https://ajv.js.org/strict-mode.html#unknown-keywords 357 - validator.addKeyword('deprecationMessage'); 358 - return validator; 359 - } 360 - 361 - export const validateJson = <T>(config: object, schema: Schema, logger: Logger): T => { 362 - const ajv = createAjvFactory(logger); 363 - const valid = ajv.validate(schema, config); 364 - if (valid) { 365 - return config as unknown as T; 366 - } else { 367 - logger.error('Json config was not valid. Please use schema to check validity.', {leaf: 'Config'}); 368 - if (Array.isArray(ajv.errors)) { 369 - for (const err of ajv.errors) { 370 - const parts = [ 371 - `At: ${err.instancePath}`, 372 - ]; 373 - let data; 374 - if (typeof err.data === 'string') { 375 - data = err.data; 376 - } else if (err.data !== null && typeof err.data === 'object' && (err.data as any).name !== undefined) { 377 - data = `Object named '${(err.data as any).name}'`; 378 - } 379 - if (data !== undefined) { 380 - parts.push(`Data: ${data}`); 381 - } 382 - let suffix = ''; 383 - if (err.params.allowedValues !== undefined) { 384 - suffix = err.params.allowedValues.join(', '); 385 - suffix = ` [${suffix}]`; 386 - } 387 - parts.push(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`); 388 - 389 - // if we have a reference in the description parse it out so we can log it here for context 390 - if (err.parentSchema !== undefined && err.parentSchema.description !== undefined) { 391 - const desc = err.parentSchema.description as string; 392 - const seeIndex = desc.indexOf('[See]'); 393 - if (seeIndex !== -1) { 394 - let newLineIndex: number | undefined = desc.indexOf('\n', seeIndex); 395 - if (newLineIndex === -1) { 396 - newLineIndex = undefined; 397 - } 398 - const seeFragment = desc.slice(seeIndex + 5, newLineIndex); 399 - parts.push(`See:${seeFragment}`); 400 - } 401 - } 402 - 403 - logger.error(`Schema Error:\r\n${parts.join('\r\n')}`, {leaf: 'Config'}); 404 - } 405 - } 406 - throw new Error('Config schema validity failure'); 407 - } 408 350 } 409 351 410 352 export const remoteHostIdentifiers = (req: Request): RemoteIdentityParts => {
+47
src/backend/utils/SchemaUtils.ts
··· 1 + import * as TJS from "typescript-json-schema"; 2 + import {sync} from "glob"; 3 + import { resolve } from "path"; 4 + import { projectDir } from "../common/index.js"; 5 + 6 + // const includeOnly = [ 7 + // sync(resolve(projectDir, "src/backend/**/*.ts"), {ignore: resolve(projectDir, "src/backend/tests/**/*")}), 8 + // sync(resolve(projectDir, "src/core/**/*.ts")) 9 + // ].flat(1); 10 + 11 + export const buildSchemaGenerator = (program?: TJS.Program, settings: TJS.PartialArgs = {}) => { 12 + return TJS.buildGenerator(program, {...defaultGeneratorArgs, ...settings}); 13 + } 14 + 15 + let configProgram: TJS.Program, 16 + generatorFromConfig: TJS.JsonSchemaGenerator; 17 + 18 + export const getTsConfigProgram = (): TJS.Program => { 19 + if(configProgram === undefined) { 20 + const tsConfig = resolve(projectDir, "src/backend/tsconfig.json"); 21 + configProgram = TJS.programFromConfig(tsConfig, 22 + sync(resolve(projectDir, "src/backend/common/infrastructure/config/**/*.ts")) 23 + ); 24 + } 25 + return configProgram; 26 + } 27 + 28 + export const getTsConfigGenerator = (): TJS.JsonSchemaGenerator => { 29 + if(generatorFromConfig === undefined) { 30 + generatorFromConfig = buildSchemaGenerator(getTsConfigProgram()); 31 + } 32 + return generatorFromConfig; 33 + } 34 + 35 + export const getTypeSchemaFromConfigGenerator = (type: string): TJS.Definition | null => { 36 + return TJS.generateSchema(getTsConfigProgram(), type, undefined, [], getTsConfigGenerator()); 37 + } 38 + 39 + export const defaultGeneratorArgs: TJS.PartialArgs = { 40 + required: true, 41 + titles: true, 42 + tsNodeRegister: true, 43 + validationKeywords: ['deprecationMessage'], 44 + constAsEnum: true, 45 + ref: true, 46 + esModuleInterop: true 47 + };
+58
src/backend/utils/ValidationUtils.ts
··· 1 + import { Logger } from "@foxxmd/logging"; 2 + import * as AjvNS from "ajv"; 3 + import Ajv, { Schema } from "ajv"; 4 + 5 + export const createAjvFactory = (logger: Logger): AjvNS.default => { 6 + const validator = new Ajv.default({logger: logger, verbose: true, strict: "log", allowUnionTypes: true}); 7 + // https://ajv.js.org/strict-mode.html#unknown-keywords 8 + validator.addKeyword('deprecationMessage'); 9 + return validator; 10 + } 11 + export const validateJson = <T>(config: object, schema: Schema, logger: Logger): T => { 12 + const ajv = createAjvFactory(logger); 13 + const valid = ajv.validate(schema, config); 14 + if (valid) { 15 + return config as unknown as T; 16 + } else { 17 + const schemaErrors = ['Json config was not valid. Please use schema to check validity.']; 18 + if (Array.isArray(ajv.errors)) { 19 + for (const err of ajv.errors) { 20 + const parts = [ 21 + `At: ${err.instancePath}`, 22 + ]; 23 + let data; 24 + if (typeof err.data === 'string') { 25 + data = err.data; 26 + } else if (err.data !== null && typeof err.data === 'object' && (err.data as any).name !== undefined) { 27 + data = `Object named '${(err.data as any).name}'`; 28 + } 29 + if (data !== undefined) { 30 + parts.push(`Data: ${data}`); 31 + } 32 + let suffix = ''; 33 + if (err.params.allowedValues !== undefined) { 34 + suffix = err.params.allowedValues.join(', '); 35 + suffix = ` [${suffix}]`; 36 + } 37 + parts.push(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`); 38 + 39 + // if we have a reference in the description parse it out so we can log it here for context 40 + if (err.parentSchema !== undefined && err.parentSchema.description !== undefined) { 41 + const desc = err.parentSchema.description as string; 42 + const seeIndex = desc.indexOf('[See]'); 43 + if (seeIndex !== -1) { 44 + let newLineIndex: number | undefined = desc.indexOf('\n', seeIndex); 45 + if (newLineIndex === -1) { 46 + newLineIndex = undefined; 47 + } 48 + const seeFragment = desc.slice(seeIndex + 5, newLineIndex); 49 + parts.push(`See:${seeFragment}`); 50 + } 51 + } 52 + 53 + schemaErrors.push(`Schema Error:\r\n${parts.join('\r\n')}`); 54 + } 55 + } 56 + throw new Error(schemaErrors.join('\n\n')); 57 + } 58 + }
+1 -1
tsconfig.json
··· 16 16 "isolatedModules": true, 17 17 "noEmit": true, 18 18 "jsx": "react-jsx", 19 - "sourceMap": true, 19 + "sourceMap": false, 20 20 }, 21 21 "include": [ 22 22 "src"