diff --git a/src/node/Recipe.mjs b/src/node/Recipe.mjs new file mode 100644 index 00000000..cb60cc71 --- /dev/null +++ b/src/node/Recipe.mjs @@ -0,0 +1,91 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ + +import {operations} from "./index"; +import { sanitise } from "./apiUtils"; + +/** + * Similar to core/Recipe, Recipe controls a list of operations and + * the SyncDish the operate on. However, this Recipe is for the node + * environment. + */ +class Recipe { + + /** + * Recipe constructor + * @param recipeConfig + */ + constructor(recipeConfig) { + this._parseConfig(recipeConfig); + } + + + /** + * Validate an ingredient $ coerce to operation if necessary. + * @param {String | Function | Object} ing + */ + _validateIngredient(ing) { + if (typeof ing === "string") { + const op = operations.find((op) => { + return sanitise(op.opName) === sanitise(ing); + }); + if (op) { + return op; + } else { + throw new TypeError(`Couldn't find an operation with name '${ing}'.`); + } + } else if (typeof ing === "function") { + if (operations.findIndex(o => o === ing) > -1) { + return ing; + } else { + throw new TypeError("Inputted function not a Chef operation."); + } + // CASE: op with configuration + } else if (ing.op && ing.args) { + // Return op and args pair for opList item. + const sanitisedOp = this._validateIngredient(ing.op); + return {op: sanitisedOp, args: ing.args}; + } else { + throw new TypeError("Recipe can only contain function names or functions"); + } + } + + + /** + * Parse config for recipe. + * @param {String | Function | String[] | Function[] | [String | Function]} recipeConfig + */ + _parseConfig(recipeConfig) { + if (!recipeConfig) { + this.opList = []; + return; + } + + if (!Array.isArray(recipeConfig)) { + recipeConfig = [recipeConfig]; + } + + this.opList = recipeConfig.map((ing) => this._validateIngredient(ing)); + } + + /** + * Run the dish through each operation, one at a time. + * @param {SyncDish} dish + * @returns {SyncDish} + */ + execute(dish) { + return this.opList.reduce((prev, curr) => { + // CASE where opLis item is op and args + if (curr.hasOwnProperty("op") && curr.hasOwnProperty("args")) { + return curr.op(prev, curr.args); + } + // CASE opList item is just op. + return curr(prev); + }, dish); + } +} + +export default Recipe; diff --git a/src/node/api.mjs b/src/node/api.mjs new file mode 100644 index 00000000..cb36ffa0 --- /dev/null +++ b/src/node/api.mjs @@ -0,0 +1,183 @@ +/** + * Wrap operations for consumption in Node. + * + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ + +import SyncDish from "./SyncDish"; +import Recipe from "./Recipe"; +import OperationConfig from "./config/OperationConfig.json"; +import { sanitise } from "./apiUtils"; + + +/** + * Extract default arg value from operation argument + * @param {Object} arg - an arg from an operation + */ +function extractArg(arg) { + if (arg.type === "option") { + // pick default option if not already chosen + return typeof arg.value === "string" ? arg.value : arg.value[0]; + } + + if (arg.type === "editableOption") { + return typeof arg.value === "string" ? arg.value : arg.value[0].value; + } + + if (arg.type === "toggleString") { + // ensure string and option exist when user hasn't defined + arg.string = arg.string || ""; + arg.option = arg.option || arg.toggleValues[0]; + return arg; + } + + return arg.value; +} + +/** + * transformArgs + * + * Take the default args array and update with any user-defined + * operation arguments. Allows user to define arguments in object style, + * with accommodating name matching. Using named args in the API is more + * clear to the user. + * + * Argument name matching is case and space insensitive + * @private + * @param {Object[]} originalArgs + * @param {Object} newArgs + */ +function transformArgs(originalArgs, newArgs) { + const allArgs = Object.assign([], originalArgs); + + if (newArgs) { + Object.keys(newArgs).map((key) => { + const index = allArgs.findIndex((arg) => { + return arg.name.toLowerCase().replace(/ /g, "") === + key.toLowerCase().replace(/ /g, ""); + }); + + if (index > -1) { + const argument = allArgs[index]; + if (["toggleString"].indexOf(argument.type) > -1) { + argument.string = newArgs[key].string; + argument.option = newArgs[key].option; + } else if (argument.type === "editableOption") { + // takes key: "option", key: {name, val: "string"}, key: {name, val: [...]} + argument.value = typeof newArgs[key] === "string" ? newArgs[key]: newArgs[key].value; + } else { + argument.value = newArgs[key]; + } + } + }); + } + return allArgs.map(extractArg); +} + +/** + * Ensure an input is a SyncDish object. + * @param input + */ +const ensureIsDish = function ensureIsDish(input) { + let dish; + if (input instanceof SyncDish) { + dish = input; + } else { + dish = new SyncDish(); + const type = SyncDish.typeEnum(input.constructor.name); + dish.set(input, type); + } + return dish; +}; + +/** + * Wrap an operation to be consumed by node API. + * new Operation().run() becomes operation() + * Perform type conversion on input + * @private + * @param {Operation} Operation + * @returns {Function} The operation's run function, wrapped in + * some type conversion logic + */ +export function wrap(OpClass) { + /** + * Wrapped operation run function + * @param {*} input + * @param {Object | String[]} args - either in Object or normal args array + * @returns {SyncDish} operation's output, on a Dish. + * @throws {OperationError} if the operation throws one. + */ + const wrapped = (input, args=null) => { + const operation = new OpClass(); + + const dish = ensureIsDish(input); + + // Transform object-style args to original args array + if (!Array.isArray(args)) { + args = transformArgs(operation.args, args); + } + const transformedInput = dish.get(operation.inputType); + const result = operation.run(transformedInput, args); + return new SyncDish({ + value: result, + type: operation.outputType + }); + }; + + // used in chef.help + wrapped.opName = OpClass.name; + return wrapped; +} + +/** + * @namespace Api + * @param {String} searchTerm - the name of the operation to get help for. + * Case and whitespace are ignored in search. + * @returns {Object} Describe function matching searchTerm. + */ +export function help(searchTerm) { + let sanitised = false; + if (typeof searchTerm === "string") { + sanitised = searchTerm; + } else if (typeof searchTerm === "function") { + sanitised = searchTerm.opName; + } + + if (!sanitised) { + return null; + } + + const key = Object.keys(OperationConfig) + .find(o => sanitise(o) === sanitise(sanitised)); + if (key) { + const result = OperationConfig[key]; + result.name = key; + return result; + } + return null; +} + +/** + * bake [Wrapped] - Perform an array of operations on some input. + * @param operations array of chef's operations (used in wrapping stage) + * @returns {Function} + */ +export function bake(operations){ + + /** + * bake + * + * @param {*} input - some input for a recipe. + * @param {String | Function | String[] | Function[] | [String | Function]} recipeConfig - + * An operation, operation name, or an array of either. + * @returns {SyncDish} of the result + * @throws {TypeError} if invalid recipe given. + */ + return function(input, recipeConfig) { + const recipe = new Recipe(recipeConfig); + const dish = ensureIsDish(input); + return recipe.execute(dish); + }; +} diff --git a/src/node/apiUtils.mjs b/src/node/apiUtils.mjs index c3fefd24..87658ce4 100644 --- a/src/node/apiUtils.mjs +++ b/src/node/apiUtils.mjs @@ -1,121 +1,11 @@ /** - * Wrap operations for consumption in Node. + * Utility functions for the node environment * * @author d98762625 [d98762625@gmail.com] * @copyright Crown Copyright 2018 * @license Apache-2.0 */ -import SyncDish from "./SyncDish"; -import OperationConfig from "./config/OperationConfig.json"; - -/** - * Extract default arg value from operation argument - * @param {Object} arg - an arg from an operation - */ -function extractArg(arg) { - if (arg.type === "option") { - // pick default option if not already chosen - return typeof arg.value === "string" ? arg.value : arg.value[0]; - } - - if (arg.type === "editableOption") { - return typeof arg.value === "string" ? arg.value : arg.value[0].value; - } - - if (arg.type === "toggleString") { - // ensure string and option exist when user hasn't defined - arg.string = arg.string || ""; - arg.option = arg.option || arg.toggleValues[0]; - return arg; - } - - return arg.value; -} - -/** - * transformArgs - * - * Take the default args array and update with any user-defined - * operation arguments. Allows user to define arguments in object style, - * with accommodating name matching. Using named args in the API is more - * clear to the user. - * - * Argument name matching is case and space insensitive - * @private - * @param {Object[]} originalArgs - * @param {Object} newArgs - */ -function transformArgs(originalArgs, newArgs) { - const allArgs = Object.assign([], originalArgs); - - if (newArgs) { - Object.keys(newArgs).map((key) => { - const index = allArgs.findIndex((arg) => { - return arg.name.toLowerCase().replace(/ /g, "") === - key.toLowerCase().replace(/ /g, ""); - }); - - if (index > -1) { - const argument = allArgs[index]; - if (["toggleString"].indexOf(argument.type) > -1) { - argument.string = newArgs[key].string; - argument.option = newArgs[key].option; - } else if (argument.type === "editableOption") { - // takes key: "option", key: {name, val: "string"}, key: {name, val: [...]} - argument.value = typeof newArgs[key] === "string" ? newArgs[key]: newArgs[key].value; - } else { - argument.value = newArgs[key]; - } - } - }); - } - return allArgs.map(extractArg); -} - -/** - * Wrap an operation to be consumed by node API. - * new Operation().run() becomes operation() - * Perform type conversion on input - * @private - * @param {Operation} Operation - * @returns {Function} The operation's run function, wrapped in - * some type conversion logic - */ -export function wrap(OpClass) { - /** - * Wrapped operation run function - * @param {*} input - * @param {Object[]} args - * @returns {SyncDish} operation's output, on a Dish. - * @throws {OperationError} if the operation throws one. - */ - const wrapped = (input, args=null) => { - const operation = new OpClass(); - - let dish; - if (input instanceof SyncDish) { - dish = input; - } else { - dish = new SyncDish(); - const type = SyncDish.typeEnum(input.constructor.name); - dish.set(input, type); - } - args = transformArgs(operation.args, args); - const transformedInput = dish.get(operation.inputType); - const result = operation.run(transformedInput, args); - return new SyncDish({ - value: result, - type: operation.outputType - }); - }; - - // used in chef.help - wrapped.opName = OpClass.name; - return wrapped; -} - - /** * SomeName => someName * @param {String} name - string to be altered @@ -134,32 +24,10 @@ export function decapitalise(name) { return `${name.charAt(0).toLowerCase()}${name.substr(1)}`; } - /** - * @namespace Api - * @param {String} searchTerm - the name of the operation to get help for. - * Case and whitespace are ignored in search. - * @returns {Object} Describe function matching searchTerm. + * Remove spaces, make lower case. + * @param str */ -export function help(searchTerm) { - let sanitised = false; - if (typeof searchTerm === "string") { - sanitised = searchTerm; - } else if (typeof searchTerm === "function") { - sanitised = searchTerm.opName; - } - - if (!sanitised) { - return null; - } - - const key = Object.keys(OperationConfig) - .find(o => o.replace(/ /g, "").toLowerCase() === sanitised.replace(/ /g, "").toLowerCase()); - if (key) { - const result = OperationConfig[key]; - result.name = key; - return result; - } - return null; +export function sanitise(str) { + return str.replace(/ /g, "").toLowerCase(); } - diff --git a/src/node/config/scripts/generateNodeIndex.mjs b/src/node/config/scripts/generateNodeIndex.mjs index 1e11fb1e..bc0a8bfe 100644 --- a/src/node/config/scripts/generateNodeIndex.mjs +++ b/src/node/config/scripts/generateNodeIndex.mjs @@ -39,7 +39,7 @@ let code = `/** import "babel-polyfill"; -import { wrap, help } from "./apiUtils"; +import { wrap, help, bake } from "./api"; import { `; @@ -88,8 +88,18 @@ includedOperations.forEach((op) => { code +=` +const operations = [\n`; + +includedOperations.forEach((op) => { + code += ` ${decapitalise(op)},\n`; +}); + +code += `]; + +chef.bake = bake(operations); export default chef; export { + operations, `; includedOperations.forEach((op) => { diff --git a/test/tests/nodeApi/nodeApi.mjs b/test/tests/nodeApi/nodeApi.mjs index 485f9e0f..e9009414 100644 --- a/test/tests/nodeApi/nodeApi.mjs +++ b/test/tests/nodeApi/nodeApi.mjs @@ -132,5 +132,154 @@ TestRegister.addApiTests([ const result = chef.help(chef.toBase32); assert.strictEqual(result.name, "To Base32"); assert.strictEqual(result.module, "Default"); - }) + }), + + it("chef.bake: should exist", () => { + assert(chef.bake); + }), + + it("chef.bake: should return SyncDish", () => { + const result = chef.bake("input", "to base 64"); + assert(result instanceof SyncDish); + }), + + it("chef.bake: should take an input and an op name and perform it", () => { + const result = chef.bake("some input", "to base 32"); + assert.strictEqual(result.toString(), "ONXW2ZJANFXHA5LU"); + }), + + it("chef.bake: should complain if recipe isnt a valid object", () => { + try { + chef.bake("some input", 3264); + } catch (e) { + assert.strictEqual(e.name, "TypeError"); + assert.strictEqual(e.message, "Recipe can only contain function names or functions"); + } + }), + + it("chef.bake: Should complain if string op is invalid", () => { + try { + chef.bake("some input", "not a valid operation"); + assert.fail("Shouldn't be hit"); + } catch (e) { + assert.strictEqual(e.name, "TypeError"); + assert.strictEqual(e.message, "Couldn't find an operation with name 'not a valid operation'."); + } + }), + + it("chef.bake: Should take an input and an operation and perform it", () => { + const result = chef.bake("https://google.com/search?q=help", chef.parseURI); + assert.strictEqual(result.toString(), "Protocol:\thttps:\nHostname:\tgoogle.com\nPath name:\t/search\nArguments:\n\tq = help\n"); + }), + + it("chef.bake: Should complain if an invalid operation is inputted", () => { + try { + chef.bake("https://google.com/search?q=help", () => {}); + assert.fail("Shouldn't be hit"); + } catch (e) { + assert.strictEqual(e.name, "TypeError"); + assert.strictEqual(e.message, "Inputted function not a Chef operation."); + } + }), + + it("chef.bake: accepts an array of operation names and performs them all in order", () => { + const result = chef.bake("https://google.com/search?q=that's a complicated question", ["URL encode", "URL decode", "Parse URI"]); + assert.strictEqual(result.toString(), "Protocol:\thttps:\nHostname:\tgoogle.com\nPath name:\t/search\nArguments:\n\tq = that's a complicated question\n"); + }), + + it("chef.bake: if recipe is empty array, return input as dish", () => { + const result = chef.bake("some input", []); + assert.strictEqual(result.toString(), "some input"); + assert(result instanceof SyncDish, "Result is not instance of SyncDish"); + }), + + it("chef.bake: accepts an array of operations as recipe", () => { + const result = chef.bake("https://google.com/search?q=that's a complicated question", [chef.URLEncode, chef.URLDecode, chef.parseURI]); + assert.strictEqual(result.toString(), "Protocol:\thttps:\nHostname:\tgoogle.com\nPath name:\t/search\nArguments:\n\tq = that's a complicated question\n"); + }), + + it("should complain if an invalid operation is inputted as part of array", () => { + try { + chef.bake("something", [() => {}]); + } catch (e) { + assert.strictEqual(e.name, "TypeError"); + assert.strictEqual(e.message, "Inputted function not a Chef operation."); + } + }), + + it("chef.bake: should take single JSON object describing op and args OBJ", () => { + const result = chef.bake("some input", { + op: chef.toHex, + args: { + Delimiter: "Colon" + } + }); + assert.strictEqual(result.toString(), "73:6f:6d:65:20:69:6e:70:75:74"); + }), + + it("chef.bake: should take single JSON object describing op and args ARRAY", () => { + const result = chef.bake("some input", { + op: chef.toHex, + args: ["Colon"] + }); + assert.strictEqual(result.toString(), "73:6f:6d:65:20:69:6e:70:75:74"); + }), + + it("chef.bake: should error if op in JSON is not chef op", () => { + try { + chef.bake("some input", { + op: () => {}, + args: ["Colon"], + }); + } catch (e) { + assert.strictEqual(e.name, "TypeError"); + assert.strictEqual(e.message, "Inputted function not a Chef operation."); + } + }), + + it("chef.bake: should take multiple ops in JSON object form, some ops by string", () => { + const result = chef.bake("some input", [ + { + op: chef.toHex, + args: ["Colon"] + }, + { + op: "to octal", + args: { + delimiter: "Semi-colon", + } + } + ]); + assert.strictEqual(result.toString(), "67;63;72;66;146;72;66;144;72;66;65;72;62;60;72;66;71;72;66;145;72;67;60;72;67;65;72;67;64"); + }), + + it("chef.bake: should handle op with multiple args", () => { + const result = chef.bake("some input", { + op: "to morse code", + args: { + formatOptions: "Dash/Dot", + wordDelimiter: "Comma", + letterDelimiter: "Backslash", + } + }); + assert.strictEqual(result.toString(), "DotDotDot\\DashDashDash\\DashDash\\Dot,DotDot\\DashDot\\DotDashDashDot\\DotDotDash\\Dash"); + }), + + it("chef.bake: should take compact JSON format from Chef Website as recipe", () => { + const result = chef.bake("some input", [{"op": "To Morse Code", "args": ["Dash/Dot", "Backslash", "Comma"]}, {"op": "Hex to PEM", "args": ["SOMETHING"]}, {"op": "To Snake case", "args": [false]}]); + assert.strictEqual(result.toString(), "begin_something_anananaaaaak_da_aaak_da_aaaaananaaaaaaan_da_aaaaaaanan_da_aaak_end_something"); + }), + + it("chef.bake: should accept Clean JSON format from Chef website as recipe", () => { + const result = chef.bake("some input", [ + { "op": "To Morse Code", + "args": ["Dash/Dot", "Backslash", "Comma"] }, + { "op": "Hex to PEM", + "args": ["SOMETHING"] }, + { "op": "To Snake case", + "args": [false] } + ]); + assert.strictEqual(result.toString(), "begin_something_anananaaaaak_da_aaak_da_aaaaananaaaaaaan_da_aaaaaaanan_da_aaak_end_something"); + }), + ]); diff --git a/test/tests/nodeApi/ops.mjs b/test/tests/nodeApi/ops.mjs index 633d2f49..e66f4109 100644 --- a/test/tests/nodeApi/ops.mjs +++ b/test/tests/nodeApi/ops.mjs @@ -28,6 +28,7 @@ import { cartesianProduct, CSSMinify, toBase64, + toHex, } from "../../../src/node/index"; import TestRegister from "../../TestRegister"; @@ -139,5 +140,12 @@ color: white; assert.strictEqual(result.toString(), "c29tZSBpbnB1dA=="); }), + it("toHex: accepts args", () => { + const result = toHex("some input", { + delimiter: "Colon", + }); + assert.strictEqual(result.toString(), "73:6f:6d:65:20:69:6e:70:75:74"); + }) + ]);