libflitter/utility/UtilityUnit.js

/**
 * @module libflitter/utility/UtilityUnit
 */

const Unit = require('../Unit')
const path = require('path')

const Output = require('./services/Output.service')
const error_context = require('../errors/error_context.fn')

/**
 * The utility unit contains various utility functions and tools that
 * are made available to almost all other units. Currently, a function
 * for restricting console.log calls by logging level is the one method
 * included. Also provides the path to the root folder of the app.
 * 
 * @extends module:libflitter/Unit~Unit
 */
class UtilityUnit extends Unit {
    /**
     * Defines the services required by this unit.
     * @returns {Array<string>}
     */
    static get services() {
        return [...super.services, 'configs']
    }

    /**
     * Array of additional service names provided by this unit.
     * @returns {Array<string>}
     */
    static get provides() {
        return ['output']
    }

    /**
     * Gets the name of the service provided by this unit: 'utility'
     * @returns {string} - 'utility'
     */
    static get name() {
        return 'utility'
    }

    /**
     * Instantiate the unit.
     * @param {string} [app_root = './app'] - path to the 'app' folder
     */
    constructor(app_root = './app'){
        super()

        /**
         * Fully-qualified path to the 'app' folder.
         * @type {Promise<void> | Promise<string>}
         */
        this.directory = path.resolve(app_root)

        /**
         * Fully-qualified path to the 'app/services' folder.
         * @type {Promise<void> | Promise<string>}
         */
        this.services_dir = path.resolve(app_root, 'services')
    }

    /**
     * Loads the unit. Binds the application root and global logging function to the appropriate contexts.
     * @param {module:libflitter/app/FlitterApp~FlitterApp} app - the Flitter app
     * @param {module:libflitter/Context~Context} context - the unit's context
     * @returns {Promise<void>}
     */
    async go(app){
        super.go(app)

        const output = new Output()
        output.level = this.configs.get('server.logging.level')
        output.timestamp = this.configs.get('server.logging.include_timestamp')

        // Replace the output service instantiated by FlitterApp
        app.output = output
        this.output = output

        app.di().container.register_singleton(Output.name, output)
    }

    /**
     * Get the fully-qualified path to the root folder of the Flitter application.
     * @returns {string}
     */
    root(){
        if ( require && require.main && require.main.filename ) return path.dirname(require.main.filename)
        else if ( process && process.mainModule && process.mainModule.filename ) return path.dirname(process.mainModule.filename)
        else return process.cwd()
    }

    /**
     * Resolve the absolute path to a file, relative to the root directory of the Flitter app.
     * This is equivalent to path.resolve(UtilityUnit.path(), ...segments)
     * @param {string...} segments
     */
    path(...segments) {
        return path.resolve(this.root(), ...segments)
    }

    /**
     * Returns true if the application is running on Windows.
     * @returns {boolean}
     */
    is_windows() {
        return process.platform === 'win32'
    }

    /**
     * Returns true if the application is running on Linux.
     * @returns {boolean}
     */
    is_linux() {
        return process.platform === 'linux'
    }

    /**
     * Returns true if the application is running on macOS.
     * @returns {boolean}
     */
    is_mac() {
        return process.platform === 'darwin'
    }

    /**
     * Get the directives provided by this unit.
     * @returns {Object}
     */
    directives(){
        return {
            // ServerDirective
        }
    }

    /**
     * Get the directories managed by this unit. Includes 'root' for the application root.
     * @returns {{root: string}}
     */
    directories() {
        return {
            root: this.root()
        }
    }

    /**
     * Get the unit's migrations.
     * @returns {string}
     */
    migrations(){
        return path.resolve(__dirname, 'migrations')
    }

    /**
     * Make a deep copy of some item. If item is primitive, return it.
     * It item is an array, create a new array with deep copies of each array item.
     * If item is an object, create a new object with key-values such that the keys are the
     * same, but the values are deep copies of the keys.
     *
     * Assumes items can eventually be resolved to primitive types, and that they
     * contain no circular structure.
     * @param item
     * @returns {Object}
     */
    deep_copy(item){
        try {
            if (Array.isArray(item)) {
                const ret = []
                item.forEach(e => {
                    ret.push(this.deep_copy(e))
                })
                return ret
            } else if (typeof item === 'object') {
                const ret = {}
                Object.keys(item).forEach(key => {
                    ret[key] = this.deep_copy(item[key])
                })
                return ret
            } else return item
        } catch (e) {
            throw error_context(e, {
                deep_copy_item: item,
            })
        }
    }

    /* Useful functions */

    /**
     * Deep merges object 2 into object 1, recursively.
     * This mutates object 1.
     * @param obj1
     * @param obj2
     * @returns {*}
     */
    deep_merge(obj1, obj2) {
        try {
            for (let key in obj2) {
                if (!Object.keys(obj1).includes(key)) obj1[key] = obj2[key]
                else if (typeof obj1[key] !== 'object') obj1[key] = obj2[key]
                else if (typeof obj2[key] !== 'object') obj1[key] = obj2[key]
                else obj1[key] = this.deep_merge(obj1[key], obj2[key])
            }

            return obj1
        } catch (e) {
            throw error_context(e, {
                deep_merge_obj1: obj1,
                deep_merge_obj2: obj2,
            })
        }
    }

    /**
     * Grabs an environment variable by name and tries to infer its type.
     * @param {string} name
     * @param default_value
     * @return {string|null|boolean|number}
     */
    env(name, default_value){
        const val = process.env[name]
        if ( !val ) return typeof default_value !== "undefined" ? default_value : null
        else return this.infer(val)
    }

    /**
     * Attempt to infer the variable type of a string's data.
     * @param {string} val
     * @return {boolean|null|*|number|undefined}
     */
    infer(val){
        try {
            if (!val) return null
            else if (val.toLowerCase() === 'true') return true
            else if (val.toLowerCase() === 'false') return false
            else if (!isNaN(val)) return +val
            else if (this.is_json(val)) return JSON.parse(val)
            else if (val.toLowerCase() === 'null') return null
            else if (val.toLowerCase() === 'undefined') return undefined
            else return val
        } catch (e) {
            throw error_context(e, {
                infer_value: val,
            })
        }
    }

    /**
     * Checks if a string is valid JSON.
     * @param string
     * @return {boolean} - true if the string is valid JSON
     */
    is_json(string){
        try {
            JSON.parse(string)
            return true
        }
        catch (e) {
            return false
        }
    }
}

module.exports = UtilityUnit