util/job_loader.js

const { Context } = require('../core/context');
const fs = require('fs');
const { Job } = require('../core/job');
const { JobParams } = require('../core/job_params');
const { Util } = require('../core/util');

/**
 * JobLoader is a helper class for loading a {@link Job} to be run from
 * the command line. The {@link JobRunner} class takes a single parameter,
 * which is a {@link Job} object. That Job object may be specified on the
 * command line in any of the following ways:
 *
 * * In opts.job as a Job UUID
 * * In opts.job as a path to a JSON file containing a {@link Job} object
 * * In opts.job as a path to a JSON file containing a {@link JobParams} object.
 * * As raw JSON passed through STDIN representing a {@link Job} object
 * * As raw JSON passed through STDIN representing a {@link JobParams} object
 *
 * The JobLoader will figure out what was specified, and will return the
 * appropriate Job object.
 *
 * Note that valid {@link Job} objects are complete and ready to run, while
 * {@link JobParams} objects refer to a {@link Workflow} and specify which
 * files should be passed through that Workflow and what tag values or
 * metadata values should be applied. The JobLoader will convert JobParams
 * objects to full-fledged Job objects.
 *
 * Although both params listed below are optional, you must specify at
 * least one of the two. If you specify both, json takes precedence
 * over opts.job.
 *
 * @param {object} opts - An object, typically the command line options.
 *
 * @param {string} [opts.job] - The path to a file containing the {@link
 * Job} in JSON format, or a job UUID. If this is a UUID, the loader will
 * load the job from the database. If it's any other string, the loader
 * will attempt to load it from a JSON file.
 *
 * @param {string|Buffer} [json] - A string or buffer containing JSON
 * data. Typically, this is read from STDIN when DART is run from the
 * command line.
 */
class JobLoader {

    constructor(opts, json = null) {
        this.opts = opts || { job: null };
        if (json) {
            this.json = json.toString();
        } else {
            this.json = null;
        }
    }

    /**
     * Loads a job. If json is not empty, this tries to parse the
     * data as {@link Job} JSON or as {@link JobParams} JSON. Otherwise
     * if opts.job is a UUID, it tries to load the job with that UUID from
     * the Jobs database. If opts.job is any string other than a UUID, this
     * tries to read the file at the path specified by opts.job and parse
     * it as {@link Job} JSON.
     *
     * This will throw an error if it encounters unparsable JSON, is asked
     * to read a non-existent or unopenable file, or cannot find a {@link
     * Job} with the specified UUID.
     *
     * @returns {Job}
     *
     */
    loadJob () {
        if (!this.json && !this.opts.job) {
            throw new Error(Context.y18n.__('You must specify either %s or %s',
                                            'opts.job', 'json'));
        }
        if (this.json && this.json.length > 0) {
            return this._loadFromJson();
        }
        if (Util.looksLikeUUID(this.opts.job)) {
            return this._loadJobById();
        } else if (this.opts.job) {
            return this._loadObjectFromFile();
        }
    }

    /**
     * This loads the Job with the UUID specified in this.opts.job.
     * Throws an error if job is not found. This is called only if
     * this.opts.job is a string that looks like a UUID.
     *
     * @private
     * @returns {Job}
     */
    _loadJobById() {
        let job = Job.find(this.opts.job);
        if (!job) {
            throw new Error(Context.y18n.__('Cannot find job with id %s', this.opts.job));
        }
        return job;
    }

    /**
     * Loads a {@link Job} from the file path specified in this.opts.job.
     * This will throw an error if the file does not exist, cannot be read,
     * or cannot be parsed as JSON. The JSON in the file can be either a
     * {@link Job} or a {@link JobParams} object.
     *
     * @private
     * @returns {Job}
     */
    _loadObjectFromFile() {
        if (!fs.existsSync(this.opts.job)) {
            throw new Error(Context.y18n.__('Job file does not exist at %s', this.opts.job));
        }
        try {
            this.json = fs.readFileSync(this.opts.job);
        } catch (ex) {
            throw new Error(Context.y18n.__('Error loading job file at %s: %s',
                                            this.opts.job, ex.message));
        }
        return this._loadFromJson();
    }

    /**
     * Loads a Job from the JSON string that passed through STDIN or
     * read from a file. That string may be a JSON-serialized {@link Job}
     * or a JSON-serialized {@link JobParams} object. In either case,
     * this returns a {@link Job} object.
     *
     * This will throw an error if the JSON is invalid or does not appear
     * to represent either a {@link Job} or a {@link JobParams} object.
     *
     * @private
     * @returns {Job}
     */
    _loadFromJson() {
        let data = this._parseJson();
        if (this.looksLikeJob(data)) {
            return Job.inflateFrom(data);
        } else if (this.looksLikeJobParams(data)) {
            return this._loadFromJobParams(data);
        } else {
            throw new Error(Context.y18n.__("JSON data does not look like a job or a workflow."));
        }
    }

    /**
     * Converts a generic hash object that looks like a {@link JobParams}
     * object into a {@link Job}.
     *
     * @private
     * @returns {Job}
     */
    _loadFromJobParams(data) {
        let jobParams = new JobParams(data);
        let job = jobParams.toJob();
        if (!job) {
            let msg = Context.y18n.__('Error creating job.');
            for(let [key, value] of Object.entries(jobParams.errors)) {
                msg += `\n${key}: ${value}`;
            }
            throw new Error(msg);
        }
        return job;
    }

    /**
     * This parses the string or Buffer in this.json into a generic
     * JavaScript object.
     *
     * @private
     * @returns {object}
     */
    _parseJson() {
        try {
            return JSON.parse(this.json);
        } catch (ex) {
            let source = this.opts.job || 'STDIN'
            throw new Error(Context.y18n.__("Error parsing JSON from %s: %s",
                                            source, ex.stack));
        }
    }

    /**
     * Returns true if the generic JavaScript object in param data looks
     * like it may be a {@link Job} object.
     *
     * @param {object} data - Any generic JavaScript object.
     *
     * @returns {boolean}
     *
     */
    looksLikeJob(data) {
        return 'packageOp' in data || 'uploadOps' in data;
    }

    /**
     * Returns true if the generic JavaScript object in param data looks
     * like it may be a {@link JobParams} object.
     *
     * @param {object} data - Any generic JavaScript object.
     *
     * @returns {boolean}
     *
     */
    looksLikeJobParams(data) {
        return 'workflowName' in data;
    }

}

module.exports.JobLoader = JobLoader;