adapt-authoring-config/lib/ConfigModule.js

/* eslint no-console: 0 */
import { AbstractModule } from 'adapt-authoring-core'
import chalk from 'chalk'
import path from 'path'
import { pathToFileURL } from 'url'
/**
 * Module to expose config API
 * @memberof config
 * @extends {AbstractModule}
 */
class ConfigModule extends AbstractModule {
  /** @override */
  async init () {
    // set references to module on main App instance
    this.app.config = this

    /** @ignore */
    this._config = {}
    /**
     * Path to the user configuration file
     * @type {String}
     */
    this.configFilePath = path.join(this.app.rootDir, 'conf', `${process.env.NODE_ENV}.config.js`)
    /**
     * The keys for all attributes which can be modified during runtime
     * @type {Array<String>}
     */
    this.mutableAttributes = []
    /**
     * The keys for all attributes marked as public
     * @type {Array<String>}
     */
    this.publicAttributes = []

    try {
      // need to wait for errors to ensure correct logging
      await this.app.waitForModule('errors')

      await this.storeUserSettings()
      await this.storeEnvSettings()
      await this.storeSchemaSettings()

      this.log('info', `using config at ${this.configFilePath}`)
    } catch (e) {
      console.log(e)
      console.log(`\n${chalk.red(`Config failed to initialise for environment '${process.env.NODE_ENV}'. See above for details.`)}\n`)
      throw this.app.errors.LOAD_ERROR
    }
    /*
    * Note: we wait until after the ready signal before initialising router because ConfigModule needs to be
    * available straight away (and not wait for server etc.)
    */
    this.onReady().then(() => this.initRouter())
  }

  /**
   * Adds routing functionality
   * @return {Promise}
   */
  async initRouter () {
    const [auth, server] = await this.app.waitForModule('auth', 'server')
    const router = server.api.createChildRouter('config')
    router.addRoute({
      route: '/',
      handlers: { get: (req, res) => res.json(this.getPublicConfig(req.params.mutable)) },
      meta: {
        get: {
          summary: 'Retrieve public config data',
          responses: {
            200: {
              description: 'The public config item data',
              content: { 'application/json': { schema: { type: 'object' } } }
            }
          }
        }
      }
    })
    auth.unsecureRoute(router.path, 'get')
  }

  /**
   * Copy env values to config
   * @return {Promise}
   */
  async storeEnvSettings () {
    Object.entries(process.env).forEach(([key, val]) => {
      try { // try to parse to allow for non-string values
        val = JSON.parse(val)
      } catch {} // ignore errors
      this.set(this.envVarToConfigKey(key), val)
    })
  }

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

  /**
   * Loads the relevant config file into memory
   * @return {Promise}
   */
  async storeUserSettings () {
    let c
    try {
      c = (await import(pathToFileURL(this.configFilePath))).default
    } catch (e) {
      if (e.code !== 'ENOENT' && e.code !== 'ERR_MODULE_NOT_FOUND') {
        console.trace(e)
        throw e
      }
    }
    if (!c) {
      console.log(chalk.yellow(`No config file found at '${this.configFilePath}', attempting to run with defaults\n`))
      return
    }
    Object.entries(c).forEach(([name, config]) => {
      Object.entries(config).forEach(([key, val]) => {
        this.set(`${name}.${key}`, val)
      })
    })
  }

  /**
   * Processes all module config schema files
   * @return {Promise}
   */
  async storeSchemaSettings () {
    const jsonschema = await this.app.waitForModule('jsonschema')
    const deps = Object.values(this.app.dependencies)
    // run core first as other modules may use its config values
    await this.processModuleSchema(deps.find(d => d.name === this.app.name), jsonschema)
    const promises = deps.map(d => this.processModuleSchema(d, jsonschema))
    let hasErrored = false;

    (await Promise.allSettled(promises)).forEach(r => {
      if (r.status === 'rejected') {
        hasErrored = true
        if (r.reason?.data?.errors) {
          console.log(`${r.reason.modName}: ${r.reason.data.errors}`)
        } else {
          console.log(r.reason)
        }
      }
    })
    if (hasErrored) throw new Error()
  }

  /**
   * Processes and validates a single module config schema (checks the user config specifies any required fields, and that they are the expected type)
   * @param {Object} pkg Package.json data
   * @param {JsonSchemaModule} jsonschema Module instance for validation
   * @return {Promise}
   */
  async processModuleSchema (pkg, jsonschema) {
    if (!pkg.name || !pkg.rootDir) return

    const schemaPath = path.resolve(pkg.rootDir, 'conf/config.schema.json')
    let schema
    try {
      schema = await (await jsonschema.createSchema(schemaPath)).build()
    } catch (e) {
      return
    }
    // validate user config data
    let data = Object.entries(schema.raw.properties).reduce((m, [k, v]) => {
      if (v?._adapt?.isMutable) this.mutableAttributes.push(`${pkg.name}.${k}`)
      if (v?._adapt?.isPublic) this.publicAttributes.push(`${pkg.name}.${k}`)
      return { ...m, [k]: this.get(`${pkg.name}.${k}`) }
    }, {})
    try {
      data = await schema.validate(data)
    } catch (e) {
      e.modName = pkg.name
      throw e
    }
    // apply validated config settings
    Object.entries(data).forEach(([key, val]) => this.set(`${pkg.name}.${key}`, val, { force: true }))
  }

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

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

  /**
   * Stores a value for the passed attribute
   * @param {String} attr Attribute key name
   * @param {*} val Value to set
   * @param {objeect} options Custom options
   * @param {objeect} options.force Whether to force an update
   */
  set (attr, val, options = {}) {
    if (this.has(attr) && !this.mutableAttributes.includes(attr) && options.force !== true) {
      return
    }
    this._config[attr] = val
  }

  /**
   * Retrieves all config options marked as 'public'
   * @param {Boolean} isMutable Whether options should also be mutable
   * @return {Object}
   */
  getPublicConfig (isMutable) {
    return this.publicAttributes.reduce((m, a) => {
      if (!isMutable || (isMutable && this.mutableAttributes.includes(a))) {
        m[a] = this.get(a)
      }
      return m
    }, {})
  }
}

export default ConfigModule