Source

global/Expand.js

/**
 * @module Expand
 * @category Global
 * @author Alexis L. <alexis.lecomte@supinfo.com>
 */

import { isArray, isString } from "lodash-es";

const ex = {
	PERMISSION: "permission",
	CAMPUS: "campus", STUDY: "study",
	MODULE: "module", ECTS: "ects",
	JOB: "job",
	COMPTA: "compta",
};
const exPerms = {
	campus: [ ],
	module: [ "READ_MODULES" ],
	ects: [ "READ_MODULES", "READ_ECTS" ],
	job: [ "READ_STUDENTS_JOBS" ],
	compta: [ "READ_COMPTA" ],
};

/**
 * Expand is used to process the parameter named "expand" passed in the route. These expand have the following format:
 * `tableName[~][<FILTER>]`.
 *
 * `[~]`: If a tilde is added to the table name, the join is optional.
 *
 * `[<FILTER>]`: An additional filter for this join.
 * @class
 *
 * @example
 * const expander = new Expand();
 * expander.setAuthorized(["books", "editors", "awards"]).process(["books", "editors<current>", "awards~"], expand => {
 *  const isRequired = expand.required ? "required" : "not required";
 *  const hasExtra = expand.how ? ` and have an extra filter (${expand.how})` : "";
 *
 * 	console.log(`The join ${expand.name} is ${isRequired}{hasExtra}.`);
 * 	// -> The join books is required.
 * 	// -> The join editors is required and have an extra filter(current).
 * 	// -> The join awards is not required.
 * });
 */
export default class Expand {
	currUser = null;
	requestUUID = null;
	authorizedTableNames = null;

	/**
	 * @constructor
	 *
	 * @param {module:LoggedUser} user
	 * @param {Array<string>} authorizedExpands
	 * @return {Expand}
	 */
	constructor(user = null, authorizedExpands = null) {
		if (user) this.setUser(user);
		if (authorizedExpands) this.setAuthorized(authorizedExpands);

		return this;
	}

	/* ---- Getters --------------------------------- */
	/** @type {string} - PERMISSION */ static get PERMISSION() { return ex.PERMISSION; }
	/** @type {string} - CAMPUS */ static get CAMPUS() { return ex.CAMPUS; }
	/** @type {string} - STUDY */ static get STUDY() { return ex.STUDY; }
	/** @type {string} - MODULE */ static get MODULE() { return ex.MODULE; }
	/** @type {string} - ECTS */ static get ECTS() { return ex.ECTS; }
	/** @type {string} - JOB */ static get JOB() { return ex.JOB; }
	/** @type {string} - COMPTA */ static get COMPTA() { return ex.COMPTA; }

	/* ---- Setters --------------------------------- */
	/**
	 * Sets the current user for permission checks.
	 * @function
	 *
	 * @param {module:LoggedUser} user
	 * @return {Expand}
	 */
	setUser(user) {
		this.currUser = user;
		return this;
	}

	/**
	 * Sets the UUID searched in the request. This UUID is used for permissions checks.
	 * @function
	 *
	 * @param {*} uuid
	 * @return {Expand}
	 */
	setRequestUUID(uuid) {
		if (isString(uuid)) {
			this.requestUUID = uuid;
		}

		return this;
	}

	/**
	 * Sets the authorized expands
	 * @function
	 *
	 * @param {Array<string>} tableNames - List of authorized expand/table names
	 * @return {Expand}
	 */
	setAuthorized(tableNames) {
		if (isArray(tableNames)) {
			this.authorizedTableNames = tableNames;
		}

		return this;
	}

	/* ---- Functions ------------------------------- */
	/**
	 * Filter the expands being processed.
	 * @function
	 * @private
	 *
	 * @param {Array<string>} expands - Expands being processed
	 * @return {Array<string>} - Filtered expands
	 */
	_filter(expands) {
		if (this.authorizedTableNames) {
			return expands.filter(tableName => {
				return this.authorizedTableNames.some(authorizedName => tableName.includes(authorizedName));
			});
		} else return expands;
	}

	/**
	 * Sort the expands being processed.
	 * @function
	 * @private
	 *
	 * @param {Array<string>} expands - Expands being processed
	 * @return {Array<string>} - Sorted expands
	 */
	_sort(expands) {
		if (this.authorizedTableNames) {
			return expands.sort((a, b) => {
				const nameA = Expand._extractName(a);
				const nameB = Expand._extractName(b);

				return this.authorizedTableNames.indexOf(nameA) - this.authorizedTableNames.indexOf(nameB);
			});
		} else return expands;
	}

	/**
	 * Read an "expand" and extract information from it. See the class description to get detail on processing.
	 * @function
	 * @private
	 *
	 * @param {string} expand - The "expand" being processed
	 * @return {{ name: string, required: boolean, how: (string|null) }} - Information extracted from the "expand"
	 */
	static _extractParts(expand) {
		const parts = expand.split(/(^\w+)/).filter(Boolean);
		const build = { name: parts[0], required: true, how: null };
		parts.shift();

		if (parts.length > 0) {
			build.required = !parts.includes("~");
			build.how = parts.some(part => /^<\w+>$/.test(part)) ? expand.split("<").pop().split(">")[0] : null;
		}

		return build;
	}

	/**
	 * Read an "expand" and extract only its name. See the class description to get detail on processing.
	 * @function
	 * @private
	 *
	 * @param {string} expand - The "expand" being processed
	 * @return {string} - The "expand" name
	 */
	static _extractName(expand) {
		return Expand._extractParts(expand).name;
	}

	/**
	 * Filters an array asynchronously.
	 * @function
	 * @async
	 * @private
	 *
	 * @see From [this StackOverflow answer](https://stackoverflow.com/a/46842181).
	 *
	 * @example
	 * const numbers = await this._asyncFilter([1, 2, 3], async (number) => {
	 *   return await excludeOddButAsync(number);
	 * });
	 * console.log(numbers) // -> [2]
	 *
	 * @param {Array<*>} arr
	 * @param {function} callback - Asynchronous callback with the current item
	 * @return {Promise<Array>} - The filtered array
	 */
	async _asyncFilter(arr, callback) {
		const fail = Symbol();
		return (await Promise.all(arr.map(async item => (await callback(item)) ? item : fail))).filter(i => i !== fail);
	}

	/**
	 * Processes the expands and calls a callback for each of them.
	 * @function
	 *
	 * @param {Array<string>} expands - Expands being processed
	 * @param {function} callback
	 */
	async process(expands, callback) {
		const splittedExpands = this._sort(this._filter(expands)).map(ex => Expand._extractParts(ex));
		const usableExpands = await this._asyncFilter(splittedExpands, async splittedEx => {
			if ((this.currUser)
				&& (!this.requestUUID || (this.requestUUID !== this.currUser.uuid))
				&& exPerms[splittedEx.name]) {
				return await this.currUser.hasAllPermissions(exPerms[splittedEx.name]);
			} else return true;
		});

		usableExpands.map(callback);
	}
}