├── .prettierignore ├── .prettierrc ├── .gitignore ├── rollup.config.mjs ├── package.json ├── LICENSE ├── index.js ├── README.md └── test.js /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | package-lock.json 4 | dist 5 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import buble from 'rollup-plugin-buble'; 2 | 3 | export default { 4 | input: 'index.js', 5 | output: { 6 | format: 'umd', 7 | name: 'jsonTemplates', 8 | file: 'dist/index.js', 9 | }, 10 | plugins: [buble()], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-templates", 3 | "version": "5.2.0", 4 | "description": "Simple JSON value templating.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "rollup -c", 8 | "pretest": "npm run build", 9 | "test": "mocha", 10 | "prettier": "prettier --write .", 11 | "prepublishOnly": "npm test", 12 | "postpublish": "git push && git push --tags" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/datavis-tech/json-templates.git" 17 | }, 18 | "keywords": [ 19 | "json", 20 | "template" 21 | ], 22 | "author": "Curran Kelleher", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/datavis-tech/json-templates/issues" 26 | }, 27 | "homepage": "https://github.com/datavis-tech/json-templates#readme", 28 | "devDependencies": { 29 | "mocha": "^11.1.0", 30 | "prettier": "^3.5.3", 31 | "rollup": "^4.40.0", 32 | "rollup-plugin-buble": "^0.19.8" 33 | }, 34 | "dependencies": { 35 | "object-path": "^0.11.8" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Curran Kelleher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // json-templates 2 | // Simple templating within JSON structures. 3 | // 4 | // Created by Curran Kelleher and Chrostophe Serafin. 5 | // Contributions from Paul Brewer and Javier Blanco Martinez. 6 | const objectPath = require('object-path'); 7 | 8 | // An enhanced version of `typeof` that handles arrays and dates as well. 9 | function type(value) { 10 | let valueType = typeof value; 11 | if (Array.isArray(value)) { 12 | valueType = 'array'; 13 | } else if (value instanceof Date) { 14 | valueType = 'date'; 15 | } else if (value === null) { 16 | valueType = 'null'; 17 | } 18 | 19 | return valueType; 20 | } 21 | 22 | // Constructs a template function with deduped `parameters` property. 23 | function Template(fn, parameters) { 24 | fn.parameters = Array.from( 25 | new Map(parameters.map((parameter) => [parameter.key, parameter])).values(), 26 | ); 27 | return fn; 28 | } 29 | 30 | // Parses the given template object. 31 | // 32 | // Returns a function `template(context)` that will "fill in" the template 33 | // with the context object passed to it. 34 | // 35 | // The returned function has a `parameters` property, 36 | // which is an array of parameter descriptor objects, 37 | // each of which has a `key` property and possibly a `defaultValue` property. 38 | function parse(value, options) { 39 | switch (type(value)) { 40 | case 'string': 41 | return parseString(value, options); 42 | case 'object': 43 | return parseObject(value, options); 44 | case 'array': 45 | return parseArray(value, options); 46 | default: 47 | return Template(function () { 48 | return value; 49 | }, []); 50 | } 51 | } 52 | 53 | // Parses leaf nodes of the template object that are strings. 54 | // Also used for parsing keys that contain templates. 55 | function parseString(str, options = {}) { 56 | const regex = /\{\{\s*([\p{L}_$][\p{L}\p{N}_.$-]*)(?::([^}]*))?\s*\}\}/gu; 57 | 58 | let templateFn = () => str; 59 | 60 | const matches = Array.from(str.matchAll(regex)); 61 | const parameters = matches.map((match) => { 62 | const r = { 63 | key: match[1], 64 | }; 65 | if (match[2]) { 66 | r.defaultValue = match[2]; 67 | } 68 | return r; 69 | }); 70 | 71 | if (matches.length > 0) { 72 | templateFn = (context = {}) => { 73 | return matches.reduce((result, match, i) => { 74 | const parameter = parameters[i]; 75 | let value = objectPath.get( 76 | context, 77 | options.rawKey ? [parameter.key] : parameter.key, 78 | ); 79 | 80 | if (typeof value === 'undefined') { 81 | value = parameter.defaultValue; 82 | } 83 | 84 | if (typeof value === 'function') { 85 | value = value(); 86 | } 87 | 88 | if (matches.length === 1 && str.trim() === match[0]) { 89 | return value; 90 | } 91 | 92 | if (value instanceof Date) { 93 | value = value.toISOString(); 94 | } 95 | 96 | return result.replace(match[0], value == null ? '' : value); 97 | }, str); 98 | }; 99 | } 100 | 101 | return Template(templateFn, parameters); 102 | } 103 | 104 | // Parses non-leaf-nodes in the template object that are objects. 105 | function parseObject(object, options) { 106 | const children = Object.keys(object).map((key) => ({ 107 | keyTemplate: parseString(key, options), 108 | valueTemplate: parse(object[key], options), 109 | })); 110 | const templateParameters = children.reduce( 111 | (parameters, child) => 112 | parameters.concat( 113 | child.valueTemplate.parameters, 114 | child.keyTemplate.parameters, 115 | ), 116 | [], 117 | ); 118 | const templateFn = (context) => { 119 | return children.reduce((newObject, child) => { 120 | newObject[child.keyTemplate(context)] = child.valueTemplate(context); 121 | return newObject; 122 | }, {}); 123 | }; 124 | 125 | return Template(templateFn, templateParameters); 126 | } 127 | 128 | // Parses non-leaf-nodes in the template object that are arrays. 129 | function parseArray(array, options) { 130 | const templates = array.map((t) => parse(t, options)); 131 | const templateParameters = templates.reduce( 132 | (parameters, template) => parameters.concat(template.parameters), 133 | [], 134 | ); 135 | const templateFn = (context) => 136 | templates.map((template) => template(context)); 137 | 138 | return Template(templateFn, templateParameters); 139 | } 140 | 141 | module.exports = parse; 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-template 2 | 3 | Simple JSON value templating. 4 | 5 | ## Usage 6 | 7 | Here's how you can use this library. Begin by installing via NPM: 8 | 9 | `npm install json-templates` 10 | 11 | Here's a small example of usage showing the simplest case, a single string. 12 | 13 | ```js 14 | const parse = require('json-templates'); 15 | 16 | const template = parse('{{foo}}'); 17 | 18 | console.log(template.parameters); // Prints [{ key: "foo" }] 19 | 20 | console.log(template({ foo: 'bar' })); // Prints "bar" 21 | ``` 22 | 23 | ### Parameter Default Values 24 | 25 | Parameters can have default values, specified using a colon. These come into play only when the parameter is `undefined`. 26 | 27 | ```js 28 | const template = parse('{{foo:bar}}'); 29 | 30 | console.log(template.parameters); // Prints [{ key: "foo", defaultValue: "bar" }] 31 | 32 | console.log(template()); // Prints "bar", using the default value. 33 | 34 | console.log(template({ foo: 'baz' })); // Prints "baz", using the given value. 35 | ``` 36 | 37 | ### Nested Objects and Arrays 38 | 39 | Parameters can come from a nested object. 40 | 41 | ```js 42 | const template = parse('{{foo.value:baz}}'); 43 | 44 | console.log(template.parameters); // Prints [{ key: "foo.value", defaultValue: "baz" }] 45 | 46 | console.log(template()); // Prints "baz", using the default value. 47 | 48 | console.log(template({ foo: { value: 'bar' } })); // Prints "bar", using the given value. 49 | 50 | // Example with parameter coming from array 51 | const template = parse({ a: '{{foo.1:baz}}' }); 52 | 53 | console.log(template.parameters); // Prints [{ key: "foo.1", defaultValue: "baz" }] 54 | 55 | console.log(template()); // Prints { a: "baz" }, using the default value. 56 | 57 | console.log(template({ foo: ['baq', 'bar'] })); // Prints { a: "bar" }, using the given value of array. 58 | ``` 59 | 60 | ### Multiple Parameters in Strings 61 | 62 | You can use multiple parameters in a single string, with or without text between them: 63 | 64 | ```js 65 | const template = parse('{{foo}}{{bar}}'); // Adjacent parameters 66 | console.log(template({ foo: 1, bar: 'a' })); // Prints "1a" 67 | 68 | const template2 = parse('Hello {{firstName}} {{lastName}}!'); // With text between 69 | console.log(template2({ firstName: 'John', lastName: 'Doe' })); // Prints "Hello John Doe!" 70 | ``` 71 | 72 | ### Template Keys 73 | 74 | You can use templates in object keys, not just values: 75 | 76 | ```js 77 | const template = parse({ 78 | 'A simple {{message}} to': 'value', 79 | }); 80 | console.log(template({ message: 'hello' })); // Prints { "A simple hello to": "value" } 81 | ``` 82 | 83 | ### Special Characters in Parameter Names 84 | 85 | The library supports several special characters in parameter names: 86 | 87 | ```js 88 | // $ symbol can be used anywhere 89 | const template1 = parse('{{$foo}}'); 90 | const template2 = parse('{{foo$}}'); 91 | 92 | // - symbol can be used anywhere except as first character 93 | const template3 = parse('{{foo-bar}}'); // Works 94 | const template4 = parse('{{-foo}}'); // Won't work 95 | ``` 96 | 97 | ### Unicode Support 98 | 99 | Parameter names can include Unicode characters: 100 | 101 | ```js 102 | const template = parse('{{中文}}'); 103 | console.log(template({ 中文: 'value' })); // Prints "value" 104 | ``` 105 | 106 | ### Function Values 107 | 108 | Templates can handle functions as values: 109 | 110 | ```js 111 | const template = parse('{{userCard}}'); 112 | console.log( 113 | template({ 114 | userCard: () => ({ id: 1, user: 'John' }), 115 | }), 116 | ); // Prints { id: 1, user: 'John' } 117 | ``` 118 | 119 | ### Date Objects 120 | 121 | The library has special handling for Date objects: 122 | 123 | ```js 124 | const now = new Date(); 125 | const template1 = parse('{{now}}'); 126 | console.log(template1({ now })); // Preserves Date object 127 | 128 | const template2 = parse('Created on {{now}}'); 129 | console.log(template2({ now })); // Converts to ISO string when part of a larger string 130 | ``` 131 | 132 | ### Null and Undefined Handling 133 | 134 | The library handles null and undefined values in specific ways: 135 | 136 | ```js 137 | const template = parse('{{foo}} {{bar}}'); 138 | 139 | // undefined parameters without defaults become empty strings 140 | console.log(template({ foo: undefined })); // Prints " " 141 | 142 | // null parameters become empty strings when part of a larger string 143 | console.log(template({ foo: null })); // Prints " " 144 | 145 | // null values in templates are preserved 146 | const template2 = parse({ key: null }); 147 | console.log(template2()); // Prints { key: null } 148 | ``` 149 | 150 | ### Raw Key Option 151 | 152 | By default, dot notation in parameter keys is interpreted as nested object access. You can change this with the rawKey option: 153 | 154 | ```js 155 | const template = parse('{{foo.bar:baz}}', { rawKey: true }); 156 | // Now looks for a literal property named "foo.bar" instead of bar inside foo 157 | console.log(template({ 'foo.bar': 'value' })); // Prints "value" 158 | ``` 159 | 160 | ### Complex Example: ElasticSearch Query 161 | 162 | The kind of templating you can see in the above examples gets applied to any string values in complex object structures such as ElasticSearch queries. Here's an example: 163 | 164 | ```js 165 | const template = parse({ 166 | index: 'myindex', 167 | body: { 168 | query: { 169 | match: { 170 | title: '{{myTitle}}', 171 | }, 172 | }, 173 | facets: { 174 | tags: { 175 | terms: { 176 | field: 'tags', 177 | }, 178 | }, 179 | }, 180 | }, 181 | }); 182 | 183 | console.log(template.parameters); // Prints [{ key: "myTitle" }] 184 | 185 | console.log(template({ title: 'test' })); 186 | ``` 187 | 188 | The last line prints the following structure: 189 | 190 | ```js 191 | { 192 | index: "myindex", 193 | body: { 194 | query: { 195 | match: { 196 | title: "test" 197 | } 198 | }, 199 | facets: { 200 | tags: { 201 | terms: { 202 | field: "tags" 203 | } 204 | } 205 | } 206 | } 207 | } 208 | ``` 209 | 210 | ## Why? 211 | 212 | The use case for this came about while working with ElasticSearch queries that need to be parameterized. We wanted the ability to _specify query templates within JSON_, and also make any of the string values parameterizable. The idea was to make something kind of like [Handlebars](http://handlebarsjs.com/), but just for the values within the query. 213 | 214 | We also needed to know which parameters are required to "fill in" a given query template (in order to check if we have the right context parameters to actually execute the query). Related to this requirement, sometimes certain parameters should have default values. These parameters are not strictly required from the context. If not specified, the default value from the template will be used, otherwise the value from the context will be used. 215 | 216 | Here's how the above `title` parameter could have a default value of `test`: 217 | 218 | ```json 219 | { 220 | "index": "myindex", 221 | "body": { 222 | "query": { 223 | "match": { 224 | "title": "{{title:test}}" 225 | } 226 | }, 227 | "facets": { 228 | "tags": { 229 | "terms": { 230 | "field": "tags" 231 | } 232 | } 233 | } 234 | } 235 | } 236 | ``` 237 | 238 | Also it was a fun challenge and a great opportunity to write some heady recursive functional code. 239 | 240 | ## Related Work 241 | 242 | - [json-templater](https://www.npmjs.com/package/json-templater) 243 | - [bodybuilder](https://github.com/danpaz/bodybuilder) 244 | - [elasticsearch-query-builder](https://github.com/leonardw/elasticsearch-query-builder) 245 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | // These are the unit tests for the library. 2 | // Run them with the command `npm test`. 3 | // By Curran Kelleher 4 | // September 2016 5 | 6 | // tests for duplication/deduplication added by Paul Brewer, Economic & Financial Technology Consulting LLC, Dec 2017 7 | 8 | const assert = require('assert'); 9 | const parse = require('./dist'); 10 | 11 | describe('json-template', () => { 12 | // Handling of strings is the most critical part of the functionality. 13 | // This section tests the string templating functionality, 14 | // including default values and edge cases. 15 | describe('strings', () => { 16 | it('should compute template for a string with a single parameter', () => { 17 | const template = parse('{{foo}}'); 18 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 19 | assert.equal(template({ foo: 'bar' }), 'bar'); 20 | }); 21 | 22 | it('should compute template for a string with a nested object parameter', () => { 23 | const template = parse('{{foo.value:baz}}'); 24 | assert.deepEqual(template.parameters, [ 25 | { key: 'foo.value', defaultValue: 'baz' }, 26 | ]); 27 | assert.equal(template({ foo: { value: 'bar' } }), 'bar'); 28 | assert.equal(template(), 'baz'); 29 | }); 30 | 31 | it('should compute template for a string as raw key with rawKey option', () => { 32 | const template = parse('{{foo.value:baz}}', { rawKey: true }); 33 | assert.deepEqual(template.parameters, [ 34 | { key: 'foo.value', defaultValue: 'baz' }, 35 | ]); 36 | assert.equal(template({ 'foo.value': 'bar' }), 'bar'); 37 | assert.equal(template(), 'baz'); 38 | }); 39 | 40 | it('should compute template for strings with no parameters', () => { 41 | ['foo', '{{}}', '}}{{', '}}foo{{'].forEach(function (value) { 42 | const template = parse(value); 43 | assert.deepEqual(template.parameters, []); 44 | assert.equal(template(), value); 45 | }); 46 | }); 47 | 48 | it('should compute template with default for a string', () => { 49 | const template = parse('{{foo:bar}}'); 50 | assert.deepEqual(template.parameters, [ 51 | { 52 | key: 'foo', 53 | defaultValue: 'bar', 54 | }, 55 | ]); 56 | assert.equal(template(), 'bar'); 57 | assert.equal(template({ foo: 'baz' }), 'baz'); 58 | assert.equal(template({ unknownParam: 'baz' }), 'bar'); 59 | }); 60 | 61 | it('should compute template with default for a string with multiple colons', () => { 62 | const template = parse('{{foo:bar:baz}}'); 63 | assert.deepEqual(template.parameters, [ 64 | { 65 | key: 'foo', 66 | defaultValue: 'bar:baz', 67 | }, 68 | ]); 69 | assert.equal(template(), 'bar:baz'); 70 | assert.equal(template({ foo: 'baz' }), 'baz'); 71 | assert.equal(template({ unknownParam: 'baz' }), 'bar:baz'); 72 | }); 73 | 74 | it('should compute template for a string with inner parameter', () => { 75 | const template = parse('Hello {{foo}}, how are you ?'); 76 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 77 | assert.equal(template({ foo: 'john' }), 'Hello john, how are you ?'); 78 | }); 79 | 80 | it('should compute template for a string with multiple inner parameters', () => { 81 | const template = parse('Hello {{firstName}} {{lastName}}, how are you ?'); 82 | assert.deepEqual(template.parameters, [ 83 | { key: 'firstName' }, 84 | { key: 'lastName' }, 85 | ]); 86 | assert.equal( 87 | template({ firstName: 'Jane', lastName: 'Doe' }), 88 | 'Hello Jane Doe, how are you ?', 89 | ); 90 | }); 91 | 92 | it('should handle extra whitespace', () => { 93 | const template = parse( 94 | 'Hello {{firstName }} {{ lastName}}, how are you ?', 95 | ); 96 | assert.deepEqual(template.parameters, [ 97 | { key: 'firstName' }, 98 | { key: 'lastName' }, 99 | ]); 100 | assert.equal( 101 | template({ firstName: 'Jane', lastName: 'Doe' }), 102 | 'Hello Jane Doe, how are you ?', 103 | ); 104 | }); 105 | 106 | it('should handle dashes in defaults', () => { 107 | const template = parse('{{startTime:now-24h}}'); 108 | assert.deepEqual(template.parameters, [ 109 | { key: 'startTime', defaultValue: 'now-24h' }, 110 | ]); 111 | assert.equal(template({ startTime: 'now-48h' }), 'now-48h'); 112 | assert.equal(template(), 'now-24h'); 113 | }); 114 | 115 | it('should handle special characters in defaults', () => { 116 | const template = parse('{{foo:-+., @/()?=*_}}'); 117 | assert.deepEqual(template.parameters, [ 118 | { key: 'foo', defaultValue: '-+., @/()?=*_' }, 119 | ]); 120 | assert.equal(template({ foo: '-+., @/()?=*_' }), '-+., @/()?=*_'); 121 | assert.equal(template(), '-+., @/()?=*_'); 122 | }); 123 | 124 | it('should handle email address in defaults', () => { 125 | const template = parse('{{email:jdoe@mail.com}}'); 126 | assert.deepEqual(template.parameters, [ 127 | { key: 'email', defaultValue: 'jdoe@mail.com' }, 128 | ]); 129 | assert.equal(template({ email: 'jdoe@mail.com' }), 'jdoe@mail.com'); 130 | assert.equal(template(), 'jdoe@mail.com'); 131 | }); 132 | 133 | it('should handle phone number in defaults', () => { 134 | const template = parse('{{phone:+1 (256) 34-34-4556}}'); 135 | assert.deepEqual(template.parameters, [ 136 | { key: 'phone', defaultValue: '+1 (256) 34-34-4556' }, 137 | ]); 138 | assert.equal( 139 | template({ phone: '+1 (256) 34-34-4556' }), 140 | '+1 (256) 34-34-4556', 141 | ); 142 | assert.equal(template(), '+1 (256) 34-34-4556'); 143 | }); 144 | 145 | it('should handle url in defaults', () => { 146 | const template = parse('{{url:http://www.host.com/path?key_1=value}}'); 147 | assert.deepEqual(template.parameters, [ 148 | { key: 'url', defaultValue: 'http://www.host.com/path?key_1=value' }, 149 | ]); 150 | assert.equal( 151 | template({ url: 'http://www.host.com/path?key_1=value' }), 152 | 'http://www.host.com/path?key_1=value', 153 | ); 154 | assert.equal(template(), 'http://www.host.com/path?key_1=value'); 155 | }); 156 | 157 | it('should handle empty strings for parameter value', () => { 158 | const template = parse('{{foo}}'); 159 | assert.equal(template({ foo: '' }), ''); 160 | }); 161 | 162 | it('should handle null and undefined as empty strings for parameter value', () => { 163 | const template = parse('{{foo}} {{bar}}'); 164 | assert.equal(template({ foo: null }), ' '); 165 | }); 166 | 167 | it('number variable inside string should be replaced', () => { 168 | const result = parse('abc{{a}}def')({ a: 1 }); 169 | assert.equal(result, 'abc1def'); 170 | }); 171 | }); 172 | 173 | // This section tests that the parse function recursively 174 | // traverses objects, and applies the string templating correctly. 175 | describe('objects', () => { 176 | it('should compute template with an object that has inner parameter', () => { 177 | const template = parse({ title: 'Hello {{foo}}, how are you ?' }); 178 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 179 | assert.deepEqual(template({ foo: 'john' }), { 180 | title: 'Hello john, how are you ?', 181 | }); 182 | }); 183 | 184 | it('should compute template with an object', () => { 185 | const template = parse({ title: '{{foo}}' }); 186 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 187 | assert.deepEqual(template({ foo: 'bar' }), { title: 'bar' }); 188 | }); 189 | 190 | it('should use a number as a value', () => { 191 | const template = parse({ title: '{{foo}}' }); 192 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 193 | assert.deepEqual(JSON.stringify(template({ foo: 5 })), '{"title":5}'); 194 | }); 195 | 196 | it('should use a $ symbol in a name', () => { 197 | const template = parse({ title: '{{$foo}}' }); 198 | assert.deepEqual(template.parameters, [{ key: '$foo' }]); 199 | assert.deepEqual(template({ $foo: 'bar' }), { title: 'bar' }); 200 | }); 201 | 202 | it('should use a $ symbol in a name, any place', () => { 203 | const template = parse({ title: '{{$foo$}}' }); 204 | assert.deepEqual(template.parameters, [{ key: '$foo$' }]); 205 | assert.deepEqual(template({ $foo$: 'bar' }), { title: 'bar' }); 206 | }); 207 | 208 | it('should use a - symbol in a name, any place except first letter', () => { 209 | const template = parse({ title: '{{foo-}}' }); 210 | assert.deepEqual(template.parameters, [{ key: 'foo-' }]); 211 | assert.deepEqual(template({ 'foo-': 'bar' }), { title: 'bar' }); 212 | 213 | assert.deepEqual(parse({ title: '{{-a}}' })({ '-a': 'bar' }), { 214 | title: '{{-a}}', 215 | }); 216 | }); 217 | 218 | it('should compute template with an object with multiple parameters', () => { 219 | const template = parse({ 220 | title: '{{myTitle}}', 221 | description: '{{myDescription}}', 222 | }); 223 | 224 | assert.deepEqual(template.parameters, [ 225 | { key: 'myTitle' }, 226 | { key: 'myDescription' }, 227 | ]); 228 | 229 | assert.deepEqual( 230 | template({ 231 | myTitle: 'foo', 232 | myDescription: 'bar', 233 | }), 234 | { 235 | title: 'foo', 236 | description: 'bar', 237 | }, 238 | ); 239 | }); 240 | 241 | it('should compute template for an object with a nested object parameter', () => { 242 | const template = parse({ a: '{{foo.1:baz}}' }); 243 | assert.deepEqual(template.parameters, [ 244 | { key: 'foo.1', defaultValue: 'baz' }, 245 | ]); 246 | assert.deepEqual(template({ foo: ['baq', 'bar'] }), { a: 'bar' }); 247 | assert.deepEqual(template(), { a: 'baz' }); 248 | }); 249 | 250 | it('should compute template with nested objects', () => { 251 | const template = parse({ 252 | body: { 253 | title: '{{foo}}', 254 | }, 255 | }); 256 | 257 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 258 | 259 | assert.deepEqual(template({ foo: 'bar' }), { 260 | body: { 261 | title: 'bar', 262 | }, 263 | }); 264 | }); 265 | 266 | it('should compute template keys', () => { 267 | const template = parse({ 268 | body: { 269 | 'A simple {{message}} to': '{{foo}}', 270 | }, 271 | }); 272 | 273 | assert.deepEqual(template.parameters, [ 274 | { key: 'foo' }, 275 | { key: 'message' }, 276 | ]); 277 | 278 | assert.deepEqual(template({ foo: 'bar', message: 'hello' }), { 279 | body: { 280 | 'A simple hello to': 'bar', 281 | }, 282 | }); 283 | }); 284 | 285 | describe('duplication and deduplication', () => { 286 | // tested: (i) duplication: if a template uses {{project}} twice, is it set consistently? 287 | // (ii) deduplication: if a template uses {{project}} twice, does key:"project" appear only once in template.parameters ? 288 | // untested: what to do with {{param:default1}}, {{param:default2}}, and/or {{param}} in the same template? is this invalid? do we throw an error? 289 | 290 | const template = parse({ 291 | disk: '/project/{{project}}/region/{{region}}/ssd', 292 | vm: '/project/{{project}}/region/{{region}}/cpu', 293 | }); 294 | 295 | it('should correctly fill duplicate references in a template', () => { 296 | assert.deepEqual(template({ project: 'alpha', region: 'us-central' }), { 297 | disk: '/project/alpha/region/us-central/ssd', 298 | vm: '/project/alpha/region/us-central/cpu', 299 | }); 300 | }); 301 | 302 | it('should deduplicate template parameters', () => { 303 | assert.deepEqual(template.parameters, [ 304 | { key: 'project' }, 305 | { key: 'region' }, 306 | ]); 307 | }); 308 | }); 309 | 310 | it('should compute template keys with default value', () => { 311 | const template = parse({ 312 | body: { 313 | 'A simple {{message:hello}} to': '{{foo}}', 314 | }, 315 | }); 316 | 317 | assert.deepEqual(template.parameters, [ 318 | { key: 'foo' }, 319 | { key: 'message', defaultValue: 'hello' }, 320 | ]); 321 | 322 | assert.deepEqual(template({ foo: 'bar' }), { 323 | body: { 324 | 'A simple hello to': 'bar', 325 | }, 326 | }); 327 | }); 328 | 329 | it('should compute template keys with default value and period in the string', () => { 330 | const template = parse({ 331 | body: { 332 | 'A simple {{message:hello.foo}} to': '{{foo}}', 333 | }, 334 | }); 335 | 336 | assert.deepEqual(template.parameters, [ 337 | { key: 'foo' }, 338 | { key: 'message', defaultValue: 'hello.foo' }, 339 | ]); 340 | 341 | assert.deepEqual(template({ foo: 'bar' }), { 342 | body: { 343 | 'A simple hello.foo to': 'bar', 344 | }, 345 | }); 346 | }); 347 | 348 | it('should allow template with null leaf values', () => { 349 | const spec = { 350 | x: '{{foo}}', 351 | y: null, 352 | }; 353 | const template = parse(spec); 354 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 355 | assert.deepEqual(template({ foo: 'bar' }), { x: 'bar', y: null }); 356 | }); 357 | }); 358 | 359 | // This section tests that the parse function recursively 360 | // traverses arrays, and applies the string templating correctly. 361 | describe('arrays', () => { 362 | it('should compute template with an array', () => { 363 | const template = parse(['{{foo}}']); 364 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 365 | assert.equal(JSON.stringify(template({ foo: 'bar' })), '["bar"]'); 366 | }); 367 | 368 | it('should compute template with a nested array', () => { 369 | const template = parse([['{{foo}}']]); 370 | assert.deepEqual(template.parameters, [{ key: 'foo' }]); 371 | assert.equal(JSON.stringify(template({ foo: 'bar' })), '[["bar"]]'); 372 | }); 373 | }); 374 | 375 | // This section tests that the parse function applies the templating 376 | // on string with function 377 | describe('function', () => { 378 | it('should compute template with function', () => { 379 | const template = parse(['{{userCard}}']); 380 | assert.deepEqual(template.parameters, [{ key: 'userCard' }]); 381 | assert.deepEqual( 382 | template({ userCard: () => ({ id: 1, user: 'John' }) }), 383 | [{ id: 1, user: 'John' }], 384 | ); 385 | }); 386 | 387 | it('should compute template with function with multiple inner parameters', () => { 388 | const template = parse( 389 | JSON.stringify({ username: '{{username}}', password: '{{password}}' }), 390 | ); 391 | assert.deepEqual(template.parameters, [ 392 | { key: 'username' }, 393 | { key: 'password' }, 394 | ]); 395 | assert.equal( 396 | template({ username: () => 'John', password: () => 'John' }), 397 | '{"username":"John","password":"John"}', 398 | ); 399 | }); 400 | }); 401 | 402 | describe('date', () => { 403 | it('should compute template with Date', () => { 404 | const template = parse('{{now}}'); 405 | const now = new Date(); 406 | assert.strictEqual(template({ now }), now); 407 | }); 408 | 409 | it('should compute template with Date', () => { 410 | const template = parse('a{{now}}'); 411 | const now = new Date(); 412 | assert.strictEqual(template({ now }), `a${now.toISOString()}`); 413 | }); 414 | }); 415 | 416 | // This section tests that arbitrary types may be present 417 | // as leaf nodes of the object tree, and they are handled correctly. 418 | describe('unknown types', () => { 419 | it('should compute template with numbers', () => { 420 | const template = parse(1); 421 | assert.deepEqual(template.parameters, []); 422 | assert.equal(template(), 1); 423 | }); 424 | 425 | it('should compute template with booleans', () => { 426 | const template = parse(true); 427 | assert.deepEqual(template.parameters, []); 428 | assert.equal(template(), true); 429 | }); 430 | 431 | it('should compute template with dates', () => { 432 | const value = new Date(); 433 | const template = parse(value); 434 | assert.deepEqual(template.parameters, []); 435 | assert.equal(template(), value); 436 | }); 437 | 438 | it('should compute template with functions', () => { 439 | const value = () => { 440 | return 'foo'; 441 | }; 442 | const template = parse(value); 443 | assert.deepEqual(template.parameters, []); 444 | assert.equal(template(), value); 445 | }); 446 | }); 447 | 448 | // This section tests for our main use case of this library - ElasticSearch queries. 449 | // These examples demonstrate that the templating works for complex object structures 450 | // that we will encounter when using the templating functionality with ElasticSearch. 451 | describe('mixed data structures', () => { 452 | it('should compute template with ElasticSearch query', () => { 453 | // Query example from https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-search 454 | const template = parse({ 455 | index: 'myindex', 456 | body: { 457 | query: { 458 | match: { 459 | title: '{{title}}', 460 | }, 461 | }, 462 | facets: { 463 | tags: { 464 | terms: { 465 | field: 'tags', 466 | }, 467 | }, 468 | }, 469 | }, 470 | }); 471 | 472 | assert.deepEqual(template.parameters, [{ key: 'title' }]); 473 | 474 | assert.deepEqual(template({ title: 'test' }), { 475 | index: 'myindex', 476 | body: { 477 | query: { 478 | match: { 479 | title: 'test', 480 | }, 481 | }, 482 | facets: { 483 | tags: { 484 | terms: { 485 | field: 'tags', 486 | }, 487 | }, 488 | }, 489 | }, 490 | }); 491 | }); 492 | 493 | it('should compute template with ElasticSearch query including default value', () => { 494 | const template = parse({ 495 | index: 'myindex', 496 | body: { 497 | query: { 498 | match: { 499 | title: '{{title:test}}', 500 | }, 501 | }, 502 | facets: { 503 | tags: { 504 | terms: { 505 | field: 'tags', 506 | }, 507 | }, 508 | }, 509 | }, 510 | }); 511 | 512 | assert.deepEqual(template.parameters, [ 513 | { 514 | key: 'title', 515 | defaultValue: 'test', 516 | }, 517 | ]); 518 | 519 | assert.deepEqual(template(), { 520 | index: 'myindex', 521 | body: { 522 | query: { 523 | match: { 524 | title: 'test', 525 | }, 526 | }, 527 | facets: { 528 | tags: { 529 | terms: { 530 | field: 'tags', 531 | }, 532 | }, 533 | }, 534 | }, 535 | }); 536 | 537 | assert.deepEqual(template({ title: 'foo' }), { 538 | index: 'myindex', 539 | body: { 540 | query: { 541 | match: { 542 | title: 'foo', 543 | }, 544 | }, 545 | facets: { 546 | tags: { 547 | terms: { 548 | field: 'tags', 549 | }, 550 | }, 551 | }, 552 | }, 553 | }); 554 | }); 555 | 556 | it('should compute template with ElasticSearch query including arrays', () => { 557 | // Query example from https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html 558 | const template = parse({ 559 | bool: { 560 | must: { 561 | term: { 562 | user: 'kimchy', 563 | }, 564 | }, 565 | filter: { 566 | term: { 567 | tag: 'tech', 568 | }, 569 | }, 570 | must_not: { 571 | range: { 572 | age: { 573 | from: 10, 574 | to: 20, 575 | }, 576 | }, 577 | }, 578 | should: [ 579 | { 580 | term: { 581 | tag: '{{myTag1}}', 582 | }, 583 | }, 584 | { 585 | term: { 586 | tag: '{{myTag2}}', 587 | }, 588 | }, 589 | ], 590 | minimum_should_match: 1, 591 | boost: 1, 592 | }, 593 | }); 594 | 595 | assert.deepEqual(template.parameters, [ 596 | { key: 'myTag1' }, 597 | { key: 'myTag2' }, 598 | ]); 599 | 600 | assert.deepEqual( 601 | template({ 602 | myTag1: 'wow', 603 | myTag2: 'cats', 604 | }), 605 | { 606 | bool: { 607 | must: { 608 | term: { 609 | user: 'kimchy', 610 | }, 611 | }, 612 | filter: { 613 | term: { 614 | tag: 'tech', 615 | }, 616 | }, 617 | must_not: { 618 | range: { 619 | age: { 620 | from: 10, 621 | to: 20, 622 | }, 623 | }, 624 | }, 625 | should: [ 626 | { 627 | term: { 628 | tag: 'wow', 629 | }, 630 | }, 631 | { 632 | term: { 633 | tag: 'cats', 634 | }, 635 | }, 636 | ], 637 | minimum_should_match: 1, 638 | boost: 1, 639 | }, 640 | }, 641 | ); 642 | }); 643 | }); 644 | 645 | // This section tests that the parse function is capable to replace simple strings, objects and arrays 646 | describe('Replacement functionality', () => { 647 | it('should replace object without stringify', () => { 648 | const template = parse({ 649 | s: '1', 650 | b: '{{c.d}}', 651 | }); 652 | const context = { 653 | c: { 654 | d: { 655 | j: 'a', 656 | }, 657 | }, 658 | }; 659 | const expected = { 660 | s: '1', 661 | b: { 662 | j: 'a', 663 | }, 664 | }; 665 | assert.deepEqual(template.parameters, [{ key: 'c.d' }]); 666 | assert.equal(JSON.stringify(template(context)), JSON.stringify(expected)); 667 | }); 668 | 669 | it('should replace array without stringify', () => { 670 | const template = parse({ 671 | s: '1', 672 | b: '{{c.d}}', 673 | }); 674 | const context = { 675 | c: { 676 | d: ['a', 'b', 'c'], 677 | }, 678 | }; 679 | const expected = { 680 | s: '1', 681 | b: ['a', 'b', 'c'], 682 | }; 683 | assert.deepEqual(template.parameters, [{ key: 'c.d' }]); 684 | assert.equal(JSON.stringify(template(context)), JSON.stringify(expected)); 685 | }); 686 | }); 687 | 688 | // This section tests that if the match is not found the template should remains undefined 689 | describe('no match on the given context', () => { 690 | it('should replace the given template by undefined if no match found for an string', () => { 691 | const template = parse('{{foo}}'); 692 | assert.strictEqual(template({}), undefined); 693 | }); 694 | 695 | it('should replace the given template by undefined if no match found for an object', () => { 696 | const template = parse({ boo: '{{foo}}' }); 697 | assert.deepStrictEqual(template({}), { boo: undefined }); 698 | }); 699 | 700 | it('should replace the given template by null if the found value is null', () => { 701 | const template = parse({ boo: '{{foo}}' }); 702 | assert.deepStrictEqual(template({ foo: null }), { boo: null }); 703 | }); 704 | }); 705 | 706 | describe('string template', () => { 707 | it('should be string type when there are more than one slots', () => { 708 | const template = parse('{{foo}}{{bar}}'); 709 | assert.equal(template({ foo: 1, bar: 'a' }), '1a'); 710 | assert.equal(template({ bar: 'a' }), 'a'); 711 | assert.equal(template({ foo: 1 }), '1'); 712 | assert.equal(template({ foo: true, bar: false }), 'truefalse'); 713 | assert.equal(template({ foo: undefined }), ''); 714 | assert.equal(template({ foo: null }), ''); 715 | assert.equal(template({}), ''); 716 | assert.equal(template(), ''); 717 | assert.equal(template({ foo: Number.NaN }), 'NaN'); 718 | }); 719 | 720 | it('default value', () => { 721 | const template = parse({ 722 | boo: '{{foo.isNull:null}} {{foo.isUndefined:undefined}} {{foo.isNonNull}}', 723 | }); 724 | assert.deepStrictEqual( 725 | template({ foo: { isNull: null, isNonNull: 'value' } }), 726 | { boo: ' undefined value' }, 727 | ); 728 | }); 729 | }); 730 | 731 | describe('unicode', () => { 732 | it('should compute key with unicode', () => { 733 | const template = parse({ 734 | title: '{{中文}}', 735 | }); 736 | 737 | assert.deepEqual(template.parameters, [{ key: '中文' }]); 738 | 739 | assert.deepEqual(template({ 中文: 'foo' }), { 740 | title: 'foo', 741 | }); 742 | }); 743 | }); 744 | }); 745 | --------------------------------------------------------------------------------