Adapt authoring tool UI documentation

v1.0.0-rc.4

adapt-authoring-config/lib/ConfigModule.js

  1. /* eslint no-console: 0 */
  2. import { AbstractModule } from 'adapt-authoring-core'
  3. import chalk from 'chalk'
  4. import path from 'path'
  5. import { pathToFileURL } from 'url'
  6. /**
  7. * Module to expose config API
  8. * @memberof config
  9. * @extends {AbstractModule}
  10. */
  11. class ConfigModule extends AbstractModule {
  12. /** @override */
  13. async init () {
  14. // set references to module on main App instance
  15. this.app.config = this
  16. /** @ignore */
  17. this._config = {}
  18. /**
  19. * Path to the user configuration file
  20. * @type {String}
  21. */
  22. this.configFilePath = path.join(this.app.rootDir, 'conf', `${process.env.NODE_ENV}.config.js`)
  23. /**
  24. * The keys for all attributes which can be modified during runtime
  25. * @type {Array<String>}
  26. */
  27. this.mutableAttributes = []
  28. /**
  29. * The keys for all attributes marked as public
  30. * @type {Array<String>}
  31. */
  32. this.publicAttributes = []
  33. try {
  34. // need to wait for errors to ensure correct logging
  35. await this.app.waitForModule('errors')
  36. await this.storeUserSettings()
  37. await this.storeEnvSettings()
  38. await this.storeSchemaSettings()
  39. this.log('info', `using config at ${this.configFilePath}`)
  40. } catch (e) {
  41. console.log(e)
  42. console.log(`\n${chalk.red(`Config failed to initialise for environment '${process.env.NODE_ENV}'. See above for details.`)}\n`)
  43. throw this.app.errors.LOAD_ERROR
  44. }
  45. /*
  46. * Note: we wait until after the ready signal before initialising router because ConfigModule needs to be
  47. * available straight away (and not wait for server etc.)
  48. */
  49. this.onReady().then(() => this.initRouter())
  50. }
  51. /**
  52. * Adds routing functionality
  53. * @return {Promise}
  54. */
  55. async initRouter () {
  56. const [auth, server] = await this.app.waitForModule('auth', 'server')
  57. const router = server.api.createChildRouter('config')
  58. router.addRoute({
  59. route: '/',
  60. handlers: { get: (req, res) => res.json(this.getPublicConfig(req.params.mutable)) },
  61. meta: {
  62. get: {
  63. summary: 'Retrieve public config data',
  64. responses: {
  65. 200: {
  66. description: 'The public config item data',
  67. content: { 'application/json': { schema: { type: 'object' } } }
  68. }
  69. }
  70. }
  71. }
  72. })
  73. auth.unsecureRoute(router.path, 'get')
  74. }
  75. /**
  76. * Copy env values to config
  77. * @return {Promise}
  78. */
  79. async storeEnvSettings () {
  80. Object.entries(process.env).forEach(([key, val]) => {
  81. try { // try to parse to allow for non-string values
  82. val = JSON.parse(val)
  83. } catch {} // ignore errors
  84. this.set(this.envVarToConfigKey(key), val)
  85. })
  86. }
  87. /**
  88. * Parses an environment variable key into a format expected by this module
  89. * @param {String} envVar
  90. * @return {String} The formatted key
  91. */
  92. envVarToConfigKey (envVar) {
  93. if (envVar.startsWith('ADAPT_AUTHORING_')) {
  94. const [modPrefix, key] = envVar.split('__')
  95. return `${modPrefix.replace(/_/g, '-').toLowerCase()}.${key}`
  96. }
  97. return `env.${envVar}`
  98. }
  99. /**
  100. * Loads the relevant config file into memory
  101. * @return {Promise}
  102. */
  103. async storeUserSettings () {
  104. let c
  105. try {
  106. c = (await import(pathToFileURL(this.configFilePath))).default
  107. } catch (e) {
  108. if (e.code !== 'ENOENT' && e.code !== 'ERR_MODULE_NOT_FOUND') {
  109. console.trace(e)
  110. throw e
  111. }
  112. }
  113. if (!c) {
  114. console.log(chalk.yellow(`No config file found at '${this.configFilePath}', attempting to run with defaults\n`))
  115. return
  116. }
  117. Object.entries(c).forEach(([name, config]) => {
  118. Object.entries(config).forEach(([key, val]) => {
  119. this.set(`${name}.${key}`, val)
  120. })
  121. })
  122. }
  123. /**
  124. * Processes all module config schema files
  125. * @return {Promise}
  126. */
  127. async storeSchemaSettings () {
  128. const jsonschema = await this.app.waitForModule('jsonschema')
  129. const deps = Object.values(this.app.dependencies)
  130. // run core first as other modules may use its config values
  131. await this.processModuleSchema(deps.find(d => d.name === this.app.name), jsonschema)
  132. const promises = deps.map(d => this.processModuleSchema(d, jsonschema))
  133. let hasErrored = false;
  134. (await Promise.allSettled(promises)).forEach(r => {
  135. if (r.status === 'rejected') {
  136. hasErrored = true
  137. if (r.reason?.data?.errors) {
  138. console.log(`${r.reason.modName}: ${r.reason.data.errors}`)
  139. } else {
  140. console.log(r.reason)
  141. }
  142. }
  143. })
  144. if (hasErrored) throw new Error()
  145. }
  146. /**
  147. * Processes and validates a single module config schema (checks the user config specifies any required fields, and that they are the expected type)
  148. * @param {Object} pkg Package.json data
  149. * @param {JsonSchemaModule} jsonschema Module instance for validation
  150. * @return {Promise}
  151. */
  152. async processModuleSchema (pkg, jsonschema) {
  153. if (!pkg.name || !pkg.rootDir) return
  154. const schemaPath = path.resolve(pkg.rootDir, 'conf/config.schema.json')
  155. let schema
  156. try {
  157. schema = await (await jsonschema.createSchema(schemaPath)).build()
  158. } catch (e) {
  159. return
  160. }
  161. // validate user config data
  162. let data = Object.entries(schema.raw.properties).reduce((m, [k, v]) => {
  163. if (v?._adapt?.isMutable) this.mutableAttributes.push(`${pkg.name}.${k}`)
  164. if (v?._adapt?.isPublic) this.publicAttributes.push(`${pkg.name}.${k}`)
  165. return { ...m, [k]: this.get(`${pkg.name}.${k}`) }
  166. }, {})
  167. try {
  168. data = await schema.validate(data)
  169. } catch (e) {
  170. e.modName = pkg.name
  171. throw e
  172. }
  173. // apply validated config settings
  174. Object.entries(data).forEach(([key, val]) => this.set(`${pkg.name}.${key}`, val, { force: true }))
  175. }
  176. /**
  177. * Determines whether an attribute has a set value
  178. * @param {String} attr Attribute key name
  179. * @return {Boolean} Whether the value exists
  180. */
  181. has (attr) {
  182. return Object.hasOwn(this._config, attr)
  183. }
  184. /**
  185. * Returns a value for a given attribute
  186. * @param {String} attr Attribute key name
  187. * @return {*} The attribute's value
  188. */
  189. get (attr) {
  190. return this._config[attr]
  191. }
  192. /**
  193. * Stores a value for the passed attribute
  194. * @param {String} attr Attribute key name
  195. * @param {*} val Value to set
  196. * @param {objeect} options Custom options
  197. * @param {objeect} options.force Whether to force an update
  198. */
  199. set (attr, val, options = {}) {
  200. if (this.has(attr) && !this.mutableAttributes.includes(attr) && options.force !== true) {
  201. return
  202. }
  203. this._config[attr] = val
  204. }
  205. /**
  206. * Retrieves all config options marked as 'public'
  207. * @param {Boolean} isMutable Whether options should also be mutable
  208. * @return {Object}
  209. */
  210. getPublicConfig (isMutable) {
  211. return this.publicAttributes.reduce((m, a) => {
  212. if (!isMutable || (isMutable && this.mutableAttributes.includes(a))) {
  213. m[a] = this.get(a)
  214. }
  215. return m
  216. }, {})
  217. }
  218. }
  219. export default ConfigModule