diff --git a/package-lock.json b/package-lock.json
index 8b5641c9e1..1fd748744d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -76,6 +76,7 @@
"process": "^0.11.10",
"protobufjs": "^6.11.3",
"qr-image": "^3.2.0",
+ "qs": "^6.10.3",
"reflect-metadata": "^0.1.13",
"scryptsy": "^2.1.0",
"snackbarjs": "^1.1.0",
@@ -10587,6 +10588,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-inspect": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz",
+ "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/object-is": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
@@ -11794,12 +11803,17 @@
"integrity": "sha1-n6gpW+rlDEoUnPn5CaHbRkqGcug="
},
"node_modules/qs": {
- "version": "6.5.3",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
- "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
- "dev": true,
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+ "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+ "dependencies": {
+ "side-channel": "^1.0.4"
+ },
"engines": {
"node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystring": {
@@ -12540,6 +12554,19 @@
"node": ">=0.8.0"
}
},
+ "node_modules/side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "dependencies": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -22380,6 +22407,11 @@
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"dev": true
},
+ "object-inspect": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz",
+ "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ=="
+ },
"object-is": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
@@ -23295,10 +23327,12 @@
"integrity": "sha1-n6gpW+rlDEoUnPn5CaHbRkqGcug="
},
"qs": {
- "version": "6.5.3",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
- "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
- "dev": true
+ "version": "6.11.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+ "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
},
"querystring": {
"version": "0.2.0",
@@ -23889,6 +23923,16 @@
"integrity": "sha512-C2FisSSW8S6TIYHHiMHN0NqzdjWfTekdMpA2FJTbRWnQMLO1RRIXEB9eVZYOlofYmjZA7fY3ChoFu09MeI3wlQ==",
"dev": true
},
+ "side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "requires": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ }
+ },
"signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
diff --git a/package.json b/package.json
index fe93fe52b1..7ed1278c0e 100644
--- a/package.json
+++ b/package.json
@@ -152,6 +152,7 @@
"process": "^0.11.10",
"protobufjs": "^6.11.3",
"qr-image": "^3.2.0",
+ "qs": "^6.10.3",
"reflect-metadata": "^0.1.13",
"scryptsy": "^2.1.0",
"snackbarjs": "^1.1.0",
diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 075e8d6662..71bac0157d 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -39,6 +39,8 @@
"From HTML Entity",
"URL Encode",
"URL Decode",
+ "Query String Encode",
+ "Query String Decode",
"Escape Unicode Characters",
"Unescape Unicode Characters",
"Normalise Unicode",
diff --git a/src/core/operations/QueryStringDecode.mjs b/src/core/operations/QueryStringDecode.mjs
new file mode 100644
index 0000000000..97fbbe7b20
--- /dev/null
+++ b/src/core/operations/QueryStringDecode.mjs
@@ -0,0 +1,74 @@
+/**
+ * @author Benjamin Altpeter [hi@bn.al]
+ * @copyright Crown Copyright 2022
+ * @license Apache-2.0
+ */
+
+import qs from "qs";
+import Operation from "../Operation.mjs";
+
+/**
+ * Query String Decode operation
+ */
+class QueryStringDecode extends Operation {
+ /**
+ * QueryStringDecode constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Query String Decode";
+ this.module = "URL";
+ this.description =
+ "Converts URL query strings into a JSON representation.
e.g. a=b&c=1
becomes {"a": "b", "c": "1"}
";
+ this.infoURL = "https://wikipedia.org/wiki/Query_string";
+ this.inputType = "string";
+ this.outputType = "JSON";
+ this.args = [
+ {
+ name: "Depth",
+ type: "number",
+ value: 5,
+ },
+ {
+ name: "Parameter limit",
+ type: "number",
+ value: 1000,
+ },
+ {
+ name: "Delimiter",
+ type: "string",
+ value: "&",
+ },
+ {
+ name: "Allow dot notation (a.b=c
)?",
+ type: "boolean",
+ value: false,
+ },
+ {
+ name: "Allow comma arrays (a=b,c
)?",
+ type: "boolean",
+ value: false,
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {JSON}
+ */
+ run(input, args) {
+ const [depth, parameterLimit, delimiter, allowDots, comma] = args;
+ return qs.parse(input, {
+ depth,
+ delimiter,
+ parameterLimit,
+ allowDots,
+ comma,
+ ignoreQueryPrefix: true,
+ });
+ }
+}
+
+export default QueryStringDecode;
diff --git a/src/core/operations/QueryStringEncode.mjs b/src/core/operations/QueryStringEncode.mjs
new file mode 100644
index 0000000000..27c841e407
--- /dev/null
+++ b/src/core/operations/QueryStringEncode.mjs
@@ -0,0 +1,64 @@
+/**
+ * @author Benjamin Altpeter [hi@bn.al]
+ * @copyright Crown Copyright 2022
+ * @license Apache-2.0
+ */
+
+import qs from "qs";
+import Operation from "../Operation.mjs";
+
+/**
+ * Query String Encode operation
+ */
+class QueryStringEncode extends Operation {
+ /**
+ * QueryStringEncode constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Query String Encode";
+ this.module = "URL";
+ this.description =
+ "Converts JSON objects into a URL query string representation.
e.g. {"a": "b", "c": 1}
becomes a=b&c=1
";
+ this.infoURL = "https://wikipedia.org/wiki/Query_string";
+ this.inputType = "JSON";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Array format",
+ type: "option",
+ value: ["brackets", "indices", "repeat", "comma"],
+ defaultIndex: 0,
+ },
+ {
+ name: "Object format",
+ type: "option",
+ value: ["brackets", "dots"],
+ defaultIndex: 0,
+ },
+ {
+ name: "Delimiter",
+ type: "string",
+ value: "&",
+ },
+ ];
+ }
+
+ /**
+ * @param {JSON} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [arrayFormat, objectFormat, delimiter] = args;
+ return qs.stringify(input, {
+ arrayFormat,
+ delimiter,
+ allowDots: objectFormat === "dots",
+ encode: false,
+ });
+ }
+}
+
+export default QueryStringEncode;
diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs
index 7a3361f24c..b77f7fcac0 100644
--- a/tests/operations/index.mjs
+++ b/tests/operations/index.mjs
@@ -130,6 +130,7 @@ import "./tests/FletcherChecksum.mjs";
import "./tests/CMAC.mjs";
import "./tests/AESKeyWrap.mjs";
import "./tests/Rabbit.mjs";
+import "./tests/QueryString.mjs";
// Cannot test operations that use the File type yet
// import "./tests/SplitColourChannels.mjs";
diff --git a/tests/operations/tests/QueryString.mjs b/tests/operations/tests/QueryString.mjs
new file mode 100644
index 0000000000..74e21f9041
--- /dev/null
+++ b/tests/operations/tests/QueryString.mjs
@@ -0,0 +1,120 @@
+/**
+ * Query String tests.
+ *
+ * @author Benjamin Altpeter [hi@bn.al]
+ * @copyright Crown Copyright 2022
+ * @license Apache-2.0
+ */
+
+import TestRegister from "../../lib/TestRegister.mjs";
+
+/**
+ * Small helper for JSON.stringify() with the correct settings.
+ * @param {any} obj An object to stringify
+ * @returns A stringified version of the object, indented by four spaces.
+ */
+const json = (obj) => JSON.stringify(obj, null, 4);
+
+TestRegister.addTests([
+ {
+ name: "Query String Decode simple example (defaults)",
+ input: "?a=b&c=1&d=e;f=g",
+ expectedOutput: json({
+ a: "b",
+ c: "1",
+ d: "e;f=g",
+ }),
+ recipeConfig: [
+ { op: "Query String Decode", args: [5, 1000, "&", false, false] },
+ ],
+ },
+ {
+ name: "Query String Decode arrays and objects (defaults)",
+ input: "a[]=b&a[2]=b&c[d]=e&f=g,h&i.j=k",
+ expectedOutput: json({
+ a: ["b", "b"],
+ c: {
+ d: "e",
+ },
+ f: "g,h",
+ "i.j": "k",
+ }),
+ recipeConfig: [
+ { op: "Query String Decode", args: [5, 1000, "&", false, false] },
+ ],
+ },
+ {
+ name: "Query String Decode arrays and objects (extended)",
+ input: "a[]=b&a[2]=b&c[d]=e&f=g,h&i.j=k",
+ expectedOutput: json({
+ a: ["b", "b"],
+ c: {
+ d: "e",
+ },
+ f: ["g", "h"],
+ i: {
+ j: "k",
+ },
+ }),
+ recipeConfig: [
+ { op: "Query String Decode", args: [5, 1000, "&", true, true] },
+ ],
+ },
+ {
+ name: "Query String Decode delimiter",
+ input: "a=b;c=d",
+ expectedOutput: json({
+ a: "b",
+ c: "d",
+ }),
+ recipeConfig: [
+ { op: "Query String Decode", args: [5, 1000, ";", false, false] },
+ ],
+ },
+ {
+ name: "Query String Decode depth (default)",
+ input: "a[b][c][d][e][f][g][h]=5",
+ expectedOutput: json({
+ a: {
+ b: {
+ c: {
+ d: {
+ e: {
+ f: {
+ "[g][h]": "5",
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ recipeConfig: [
+ { op: "Query String Decode", args: [5, 1000, "&", false, false] },
+ ],
+ },
+ {
+ name: "Query String Decode depth (higher)",
+ input: "a[b][c][d][e][f][g][h]=5",
+ expectedOutput: json({
+ a: {
+ b: {
+ c: {
+ d: {
+ e: {
+ f: {
+ g: {
+ h: "5",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ recipeConfig: [
+ { op: "Query String Decode", args: [7, 1000, "&", false, false] },
+ ],
+ },
+]);