Bladeren bron

Build chef.bake.

d98762625 7 jaren geleden
bovenliggende
commit
9abaadf1b6
6 gewijzigde bestanden met toevoegingen van 448 en 139 verwijderingen
  1. 91 0
      src/node/Recipe.mjs
  2. 183 0
      src/node/api.mjs
  3. 5 137
      src/node/apiUtils.mjs
  4. 11 1
      src/node/config/scripts/generateNodeIndex.mjs
  5. 150 1
      test/tests/nodeApi/nodeApi.mjs
  6. 8 0
      test/tests/nodeApi/ops.mjs

+ 91 - 0
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;

+ 183 - 0
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);
+    };
+}

+ 5 - 137
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.
+ * 
+ * @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();
 }
-

+ 11 - 1
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) => {

+ 150 - 1
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");
+    }),
+
 ]);

+ 8 - 0
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");
+    })
+
 ]);