adapt-authoring-core/lib/Config.js

import { Schemas } from 'adapt-schemas'
import fs from 'fs/promises'
import path from 'path'

/**
 * Loads, validates, and provides access to application configuration.
 * Configuration is sourced from user settings files, environment variables, and module schema defaults.
 * @memberof core
 */
class Config {
  /**
   * @param {Object} options
   * @param {String} options.rootDir Application root directory
   * @param {String} options.configFilePath Path to the user configuration file
   * @param {Object} options.dependencies Key/value map of dependency configs
   * @param {String} options.appName The core module name (for sorting)
   * @param {Function} options.log Logging function (level, id, ...args)
   */
  constructor ({ rootDir, configFilePath, dependencies = {}, appName = '', log = () => {} } = {}) {
    /** @ignore */
    this._config = {}
    /**
     * Application root directory
     * @type {String}
     */
    this.rootDir = rootDir
    /**
     * Path to the user configuration file
     * @type {String}
     */
    this.configFilePath = configFilePath
    /**
     * The keys for all attributes marked as public
     * @type {Array<String>}
     */
    this.publicAttributes = []
    /** @ignore */
    this._dependencies = dependencies
    /** @ignore */
    this._appName = appName
    /** @ignore */
    this.log = log
  }

  /**
   * Loads configuration from all sources
   * @returns {Promise}
   */
  async load () {
    await this.storeUserSettings()
    this.storeEnvSettings()
    this.storeSchemaSettings(this._dependencies, this._appName)
    this.log('info', 'config', `using config at ${this.configFilePath}`)
    return this
  }

  /**
   * Determines whether an attribute has a set value
   * @param {String} attr Attribute key name
   * @return {Boolean}
   */
  has (attr) {
    return Object.hasOwn(this._config, attr)
  }

  /**
   * Returns a value for a given attribute
   * @param {String} attr Attribute key name
   * @return {*}
   */
  get (attr) {
    return this._config[attr]
  }

  /**
   * Retrieves all config options marked as 'public'
   * @return {Object}
   */
  getPublicConfig () {
    return this.publicAttributes.reduce((m, a) => {
      m[a] = this.get(a)
      return m
    }, {})
  }

  /**
   * Loads the relevant config file into memory
   * @return {Promise}
   */
  async storeUserSettings () {
    let config
    try {
      await fs.readFile(this.configFilePath)
      config = (await import(this.configFilePath)).default
    } catch (e) {
      this.log('warn', 'config', `Failed to load config at ${this.configFilePath}: ${e}. Will attempt to run with defaults.`)
      return
    }
    Object.entries(config).forEach(([name, c]) => {
      Object.entries(c).forEach(([key, val]) => {
        this._config[`${name}.${key}`] = val
      })
    })
  }

  /**
   * Copy env values to config
   */
  storeEnvSettings () {
    Object.entries(process.env).forEach(([key, val]) => {
      try {
        val = JSON.parse(val)
      } catch {} // ignore parse errors for non-JSON values
      this._config[Config.envVarToConfigKey(key)] = val
    })
  }

  /**
   * Processes all module config schema files
   * @param {Object} dependencies Key/value map of dependency configs
   * @param {String} appName The core module name (for sorting)
   */
  storeSchemaSettings (dependencies, appName) {
    const schemas = new Schemas().init()
    const isCore = d => d.name === appName
    const deps = Object.values(dependencies).sort((a, b) => {
      if (isCore(a)) return -1
      if (isCore(b)) return 1
      return a.name.localeCompare(b.name)
    })
    const coreDep = deps.find(d => isCore(d))
    if (coreDep) this.processModuleSchema(coreDep, schemas)

    const errors = []
    for (const d of deps.filter(d => !isCore(d))) {
      try {
        this.processModuleSchema(d, schemas)
      } catch (e) {
        errors.push(e?.data?.errors ? { modName: e.modName, message: e.data.errors } : { message: String(e) })
      }
    }
    if (errors.length) {
      errors.forEach(e => {
        this.log('error', 'config', `${e.modName ? e.modName + ': ' : ''}${e.message}`)
      })
      throw new Error('Config validation failed')
    }
  }

  /**
   * Processes and validates a single module config schema
   * @param {Object} pkg Package.json data
   * @param {Schemas} schemas Schemas library instance
   */
  processModuleSchema (pkg, schemas) {
    if (!pkg.name || !pkg.rootDir) return
    const schemaPath = path.resolve(pkg.rootDir, 'conf/config.schema.json')
    let schema
    try {
      // TODO config schemas should define $id, remove this workaround once they do
      schema = schemas.createSchema(schemaPath)
      schema.raw.$id = pkg.name
      schema.build({ compile: false })
      schema.built.$id = pkg.name
      schema.compiledWithDefaults = schemas.validatorWithDefaults.compile(schema.built)
      schema.compiled = schemas.validator.compile(schema.built)
    } catch (e) {
      if (e.code !== 'SCHEMA_LOAD_FAILED') {
        this.log('warn', 'config', `${pkg.name}: ${e.message}`)
      }
      return
    }
    const dirKeys = new Set()
    let data = Object.entries(schema.raw.properties).reduce((m, [k, v]) => {
      if (v?._adapt?.isPublic) this.publicAttributes.push(`${pkg.name}.${k}`)
      if (v?.isDirectory) dirKeys.add(k)
      return { ...m, [k]: this.get(`${pkg.name}.${k}`) }
    }, {})
    try {
      data = schema.validate(data)
    } catch (e) {
      e.modName = pkg.name
      throw e
    }
    Object.entries(data).forEach(([key, val]) => {
      if (dirKeys.has(key) && typeof val === 'string') {
        val = this.resolveDirectory(val)
      }
      this._config[`${pkg.name}.${key}`] = val
    })
  }

  /**
   * Resolves directory path variables ($ROOT, $DATA, $TEMP)
   * @param {String} value The path string to resolve
   * @return {String}
   */
  resolveDirectory (value) {
    const vars = [
      ['$ROOT', this.rootDir],
      ['$DATA', this._config['adapt-authoring-core.dataDir']],
      ['$TEMP', this._config['adapt-authoring-core.tempDir']]
    ]
    for (const [key, replacement] of vars) {
      if (value.startsWith(key) && replacement && !replacement.startsWith('$')) {
        return path.resolve(replacement, value.replace(key, '').slice(1))
      }
    }
    return value
  }

  /**
   * Parses an environment variable key into a format expected by Config
   * @param {String} envVar
   * @return {String}
   */
  static envVarToConfigKey (envVar) {
    if (envVar.startsWith('ADAPT_AUTHORING_')) {
      const [modPrefix, key] = envVar.split('__')
      return `${modPrefix.replace(/_/g, '-').toLowerCase()}.${key}`
    }
    return `env.${envVar}`
  }
}

export default Config