From 55bfe2acb9d2fbc5eac70f0c7036bcd5e955b913 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 20 Feb 2017 10:32:27 -0800 Subject: [PATCH] Templating Improvements and Response Tags (#88) * Version bump * Got async Nunjucks extensions working * Got JSONPath extension working * Swapped render function for new stuff * Wrote custom recursive render function * traverse -> clone * add-module-exports * Moved tests --- app/common/__fixtures__/nestedfolders.js | 2 +- app/common/__tests__/database.test.js | 10 +- app/common/__tests__/render.test.js | 93 +++++++------ app/common/render.js | 128 ++++++++---------- app/models/__mocks__/uuid.js | 34 ++++- app/models/response.js | 5 + app/package.json | 4 +- app/sync/index.js | 2 +- app/templating/extensions/NowExtension.js | 26 ++++ .../extensions/ResponseJsonPathExtension.js | 51 +++++++ .../extensions/TimestampExtension.js | 12 ++ app/templating/extensions/UuidExtension.js | 27 ++++ .../extensions/__tests__/NowExtension.test.js | 23 ++++ .../ResponseJsonPathExtension.test.js | 86 ++++++++++++ .../__tests__/TimestampExtension.test.js | 14 ++ .../__tests__/UuidExtension.test.js | 19 +++ .../extensions/base/BaseExtension.js | 49 +++++++ app/templating/extensions/index.js | 13 ++ app/templating/index.js | 42 ++++++ app/ui/components/Toast.js | 1 - .../modals/WorkspaceEnvironmentsEditModal.js | 6 +- app/ui/containers/App.js | 2 +- appveyor.yml | 4 +- package.json | 16 +-- 24 files changed, 525 insertions(+), 144 deletions(-) create mode 100644 app/templating/extensions/NowExtension.js create mode 100644 app/templating/extensions/ResponseJsonPathExtension.js create mode 100644 app/templating/extensions/TimestampExtension.js create mode 100644 app/templating/extensions/UuidExtension.js create mode 100644 app/templating/extensions/__tests__/NowExtension.test.js create mode 100644 app/templating/extensions/__tests__/ResponseJsonPathExtension.test.js create mode 100644 app/templating/extensions/__tests__/TimestampExtension.test.js create mode 100644 app/templating/extensions/__tests__/UuidExtension.test.js create mode 100644 app/templating/extensions/base/BaseExtension.js create mode 100644 app/templating/extensions/index.js create mode 100644 app/templating/index.js diff --git a/app/common/__fixtures__/nestedfolders.js b/app/common/__fixtures__/nestedfolders.js index 2fc953fac1..7aba71d0fa 100644 --- a/app/common/__fixtures__/nestedfolders.js +++ b/app/common/__fixtures__/nestedfolders.js @@ -1,6 +1,6 @@ import * as models from '../../models'; -export default { +export const data = { [models.workspace.type]: [{ _id: 'wrk_1', name: 'Wrk 1' diff --git a/app/common/__tests__/database.test.js b/app/common/__tests__/database.test.js index 18425bd6a5..a5bb561046 100644 --- a/app/common/__tests__/database.test.js +++ b/app/common/__tests__/database.test.js @@ -1,14 +1,16 @@ -import * as db from '../database'; import * as models from '../../models'; +import * as db from '../database'; function loadFixture (name) { - const fixtures = require(`../__fixtures__/${name}`); + const fixtures = require(`../__fixtures__/${name}`).data; const promises = []; for (const type of Object.keys(fixtures)) { for (const doc of fixtures[type]) { promises.push(db.insert(Object.assign({}, doc, {type}))); } } + + return Promise.all(promises); } describe('init()', () => { @@ -86,9 +88,7 @@ describe('bufferChanges()', () => { }); describe('requestCreate()', () => { - beforeEach(() => { - return db.init(models.types(), {inMemoryOnly: true}, true); - }); + beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true)); it('creates a valid request', async () => { const now = Date.now(); diff --git a/app/common/__tests__/render.test.js b/app/common/__tests__/render.test.js index 7dab78e6bb..28aebb0ef5 100644 --- a/app/common/__tests__/render.test.js +++ b/app/common/__tests__/render.test.js @@ -4,29 +4,33 @@ import * as models from '../../models'; jest.mock('electron'); describe('render()', () => { - it('renders hello world', () => { - const rendered = renderUtils.render('Hello {{ msg }}!', {msg: 'World'}); + it('renders hello world', async () => { + const rendered = await renderUtils.render('Hello {{ msg }}!', {msg: 'World'}); expect(rendered).toBe('Hello World!'); }); - it('renders custom tag: uuid', () => { - const rendered = renderUtils.render('Hello {% uuid %}!'); + it('renders custom tag: uuid', async () => { + const rendered = await renderUtils.render('Hello {% uuid %}!'); expect(rendered).toMatch(/Hello [a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}!/); }); - it('renders custom tag: timestamp', () => { - const rendered = renderUtils.render('Hello {% timestamp %}!'); + it('renders custom tag: timestamp', async () => { + const rendered = await renderUtils.render('Hello {% timestamp %}!'); expect(rendered).toMatch(/Hello \d{13}!/); }); - it('fails on invalid template', () => { - const fn = () => renderUtils.render('Hello {{ msg }!', {msg: 'World'}); - expect(fn).toThrowError('expected variable end'); + it('fails on invalid template', async () => { + try { + await renderUtils.render('Hello {{ msg }!', {msg: 'World'}); + fail('Render should have failed'); + } catch (err) { + expect(err.message).toContain('expected variable end'); + } }); }); describe('buildRenderContext()', () => { - it('cascades properly', () => { + it('cascades properly', async () => { const ancestors = [ { type: models.requestGroup.type, @@ -48,7 +52,7 @@ describe('buildRenderContext()', () => { data: {foo: 'sub', sub: true} }; - const context = renderUtils.buildRenderContext( + const context = await renderUtils.buildRenderContext( ancestors, rootEnvironment, subEnvironment @@ -62,14 +66,14 @@ describe('buildRenderContext()', () => { }); }); - it('rendered recursive should not infinite loop', () => { + it('rendered recursive should not infinite loop', async () => { const ancestors = [{ // Sub Environment type: models.requestGroup.type, environment: {recursive: '{{ recursive }}/hello'} }]; - const context = renderUtils.buildRenderContext(ancestors); + const context = await renderUtils.buildRenderContext(ancestors); // This is longer than 3 because it multiplies every time (1 -> 2 -> 4 -> 8) expect(context).toEqual({ @@ -77,7 +81,7 @@ describe('buildRenderContext()', () => { }); }); - it('render up to 3 recursion levels', () => { + it('render up to 3 recursion levels', async () => { const ancestors = [{ // Sub Environment type: models.requestGroup.type, @@ -90,7 +94,7 @@ describe('buildRenderContext()', () => { } }]; - const context = renderUtils.buildRenderContext(ancestors); + const context = await renderUtils.buildRenderContext(ancestors); expect(context).toEqual({ d: '/d', @@ -101,7 +105,7 @@ describe('buildRenderContext()', () => { }); }); - it('rendered sibling environment variables', () => { + it('rendered sibling environment variables', async () => { const ancestors = [{ // Sub Environment type: models.requestGroup.type, @@ -111,12 +115,12 @@ describe('buildRenderContext()', () => { } }]; - const context = renderUtils.buildRenderContext(ancestors); + const context = await renderUtils.buildRenderContext(ancestors); expect(context).toEqual({sibling: 'sibling', test: 'sibling/hello'}); }); - it('rendered parent environment variables', () => { + it('rendered parent environment variables', async () => { const ancestors = [{ name: 'Parent', type: models.requestGroup.type, @@ -131,12 +135,12 @@ describe('buildRenderContext()', () => { } }]; - const context = renderUtils.buildRenderContext(ancestors); + const context = await renderUtils.buildRenderContext(ancestors); expect(context).toEqual({grandparent: 'grandparent', test: 'grandparent parent'}); }); - it('rendered parent same name environment variables', () => { + it('rendered parent same name environment variables', async () => { const ancestors = [{ name: 'Parent', type: models.requestGroup.type, @@ -151,12 +155,12 @@ describe('buildRenderContext()', () => { } }]; - const context = renderUtils.buildRenderContext(ancestors); + const context = await renderUtils.buildRenderContext(ancestors); expect(context).toEqual({base_url: 'https://insomnia.rest/resource'}); }); - it('rendered parent, ignoring sibling environment variables', () => { + it('rendered parent, ignoring sibling environment variables', async () => { const ancestors = [{ name: 'Parent', type: models.requestGroup.type, @@ -180,13 +184,13 @@ describe('buildRenderContext()', () => { } }]; - const context = renderUtils.buildRenderContext(ancestors); - const result = renderUtils.render('{{ urls.admin }}/foo', context); + const context = await renderUtils.buildRenderContext(ancestors); + const result = await renderUtils.render('{{ urls.admin }}/foo', context); expect(result).toEqual('https://parent.com/admin/foo'); }); - it('renders child environment variables', () => { + it('renders child environment variables', async () => { const ancestors = [{ name: 'Parent', type: models.requestGroup.type, @@ -201,12 +205,12 @@ describe('buildRenderContext()', () => { } }]; - const context = renderUtils.buildRenderContext(ancestors); + const context = await renderUtils.buildRenderContext(ancestors); expect(context).toEqual({parent: 'parent', test: 'parent grandparent'}); }); - it('cascades properly and renders', () => { + it('cascades properly and renders', async () => { const ancestors = [ { type: models.requestGroup.type, @@ -235,7 +239,7 @@ describe('buildRenderContext()', () => { data: {winner: 'root', root: true, base_url: 'ignore this'} }; - const context = renderUtils.buildRenderContext(ancestors, + const context = await renderUtils.buildRenderContext(ancestors, rootEnvironment, subEnvironment ); @@ -250,12 +254,12 @@ describe('buildRenderContext()', () => { }); }); - it('works with minimal parameters', () => { + it('works with minimal parameters', async () => { const ancestors = null; const rootEnvironment = null; const subEnvironment = null; - const context = renderUtils.buildRenderContext( + const context = await renderUtils.buildRenderContext( ancestors, rootEnvironment, subEnvironment @@ -266,8 +270,8 @@ describe('buildRenderContext()', () => { }); describe('recursiveRender()', () => { - it('correctly renders simple Object', () => { - const newObj = renderUtils.recursiveRender({ + it('correctly renders simple Object', async () => { + const newObj = await renderUtils.recursiveRender({ foo: '{{ foo }}', bar: 'bar', baz: '{{ bad }}' @@ -280,13 +284,14 @@ describe('recursiveRender()', () => { }) }); - it('correctly renders complex Object', () => { + it('correctly renders complex Object', async () => { const d = new Date(); const obj = { foo: '{{ foo }}', null: null, bool: true, date: d, + undef: undefined, num: 1234, nested: { foo: '{{ foo }}', @@ -294,13 +299,14 @@ describe('recursiveRender()', () => { } }; - const newObj = renderUtils.recursiveRender(obj, {foo: 'bar'}); + const newObj = await renderUtils.recursiveRender(obj, {foo: 'bar'}); expect(newObj).toEqual({ foo: 'bar', null: null, bool: true, date: d, + undef: undefined, num: 1234, nested: { foo: 'bar', @@ -314,13 +320,16 @@ describe('recursiveRender()', () => { expect(obj.nested.arr[2]).toBe('{{ foo }}'); }); - it('fails on bad template', () => { - const fn = () => renderUtils.recursiveRender({ - foo: '{{ foo }', - bar: 'bar', - baz: '{{ bad }}' - }, {foo: 'bar'}); - - expect(fn).toThrowError('expected variable end'); + it('fails on bad template', async () => { + try { + await renderUtils.recursiveRender({ + foo: '{{ foo }', + bar: 'bar', + baz: '{{ bad }}' + }, {foo: 'bar'}); + fail('Render should have failed'); + } catch (err) { + expect(err.message).toContain('expected variable end'); + } }) }); diff --git a/app/common/render.js b/app/common/render.js index b0823fcc8d..e9e3d01b40 100644 --- a/app/common/render.js +++ b/app/common/render.js @@ -1,65 +1,14 @@ -import nunjucks from 'nunjucks'; -import traverse from 'traverse'; -import uuid from 'uuid'; +import clone from 'clone'; import * as models from '../models'; import {getBasicAuthHeader, hasAuthHeader, setDefaultProtocol} from './misc'; import * as db from './database'; +import * as templating from '../templating'; -const nunjucksEnvironment = nunjucks.configure({ - autoescape: false -}); - -class NoArgsExtension { - parse (parser, nodes, lexer) { - const args = parser.parseSignature(null, true); - parser.skip(lexer.TOKEN_BLOCK_END); - return new nodes.CallExtension(this, 'run', args); - } +export async function render (template, context = {}) { + return templating.render(template, {context}); } -// class ArgsExtension { -// parse (parser, nodes, lexer) { -// const tok = parser.nextToken(); -// const args = parser.parseSignature(null, true); -// parser.advanceAfterBlockEnd(tok.value); -// return new nodes.CallExtension(this, 'run', args); -// } -// } - -class TimestampExtension extends NoArgsExtension { - constructor () { - super(); - this.tags = ['timestamp']; - } - - run (context) { - return Date.now(); - } -} - -class UuidExtension extends NoArgsExtension { - constructor () { - super(); - this.tags = ['uuid']; - } - - run (context) { - return uuid.v4(); - } -} - -nunjucksEnvironment.addExtension('uuid', new UuidExtension()); -nunjucksEnvironment.addExtension('timestamp', new TimestampExtension()); - -export function render (template, context = {}) { - try { - return nunjucksEnvironment.renderString(template, context); - } catch (e) { - throw new Error(e.message.replace(/\(unknown path\)\s*/, '')); - } -} - -export function buildRenderContext (ancestors, rootEnvironment, subEnvironment) { +export async function buildRenderContext (ancestors, rootEnvironment, subEnvironment) { if (!Array.isArray(ancestors)) { ancestors = []; } @@ -88,7 +37,7 @@ export function buildRenderContext (ancestors, rootEnvironment, subEnvironment) for (const environment of environments) { // Do an Object.assign, but render each property as it overwrites. This // way we can keep same-name variables from the parent context. - _objectDeepAssignRender(renderContext, environment); + await _objectDeepAssignRender(renderContext, environment); } // Render the context with itself to fill in the rest. @@ -96,30 +45,59 @@ export function buildRenderContext (ancestors, rootEnvironment, subEnvironment) // Render up to 5 levels of recursive references. for (let i = 0; i < 3; i++) { - finalRenderContext = recursiveRender(finalRenderContext, finalRenderContext); + finalRenderContext = await recursiveRender(finalRenderContext, finalRenderContext); } return finalRenderContext; } -export function recursiveRender (obj, context) { +/** + * Recursively render any JS object and return a new one + * @param {*} originalObj - object to render + * @param {object} context - context to render against + * @return {Promise.<*>} + */ +export async function recursiveRender (originalObj, context) { + const obj = clone(originalObj); + const toS = obj => Object.prototype.toString.call(obj); + // Make a copy so no one gets mad :) - const newObj = traverse.clone(obj); - - traverse(newObj).forEach(function (x) { - try { - if (typeof x === 'string') { - const str = render(x, context); - this.update(str); + async function next (x) { + // Leave these types alone + if ( + toS(x) === '[object Date]' || + toS(x) === '[object RegExp]' || + toS(x) === '[object Error]' || + toS(x) === '[object Boolean]' || + toS(x) === '[object Number]' || + toS(x) === '[object Null]' || + toS(x) === '[object Undefined]' + ) { + // Do nothing to these types + } else if (toS(x) === '[object String]') { + try { + x = render(x, context); + } catch (err) { + // Failed to render Request + // const path = this.path.join('.'); + const path = 'TODO"'; + throw new Error(`Failed to render Request.${path}: "${err.message}"`); + } + } else if (Array.isArray(x)) { + for (let i = 0; i < x.length; i++) { + x[i] = await next(x[i]); + } + } else if (typeof x === 'object') { + const keys = Object.keys(x); + for (const key of keys) { + x[key] = await next(x[key]); } - } catch (e) { - // Failed to render Request - const path = this.path.join('.'); - throw new Error(`Failed to render Request.${path}: "${e.message}"`); } - }); - return newObj; + return x; + } + + return next(obj); } export async function getRenderContext (request, environmentId, ancestors = null) { @@ -147,7 +125,7 @@ export async function getRenderedRequest (request, environmentId) { const renderContext = await getRenderContext(request, environmentId, ancestors); // Render all request properties - const renderedRequest = recursiveRender(request, renderContext); + const renderedRequest = await recursiveRender(request, renderContext); // Remove disabled params renderedRequest.parameters = renderedRequest.parameters.filter(p => !p.disabled); @@ -182,7 +160,7 @@ export async function getRenderedRequest (request, environmentId) { return renderedRequest; } -function _objectDeepAssignRender (base, obj) { +async function _objectDeepAssignRender (base, obj) { for (const key of Object.keys(obj)) { /* * If we're overwriting a string, try to render it first with the base as @@ -196,7 +174,7 @@ function _objectDeepAssignRender (base, obj) { * original base_url of google.com would be lost. */ if (typeof base[key] === 'string') { - base[key] = render(obj[key], base); + base[key] = await render(obj[key], base); } else { base[key] = obj[key]; } diff --git a/app/models/__mocks__/uuid.js b/app/models/__mocks__/uuid.js index 5dff924d42..dcb4e93c8f 100644 --- a/app/models/__mocks__/uuid.js +++ b/app/models/__mocks__/uuid.js @@ -1,6 +1,20 @@ -let counter = 0; +let v1Counter = 0; +let v4Counter = 0; -const uuids = [ +const v1UUIDs = [ + 'f7272c80-f493-11e6-bc64-92361f002671', + 'f7272f0a-f493-11e6-bc64-92361f002671', + 'f72733a6-f493-11e6-bc64-92361f002671', + 'f72735c2-f493-11e6-bc64-92361f002671', + 'f7273798-f493-11e6-bc64-92361f002671', + 'f7273c3e-f493-11e6-bc64-92361f002671', + 'f7273dba-f493-11e6-bc64-92361f002671', + 'f7273eaa-f493-11e6-bc64-92361f002671', + 'f7273f7c-f493-11e6-bc64-92361f002671', + 'f7274300-f493-11e6-bc64-92361f002671', +]; + +const v4UUIDs = [ 'dd2ccc1a-2745-477a-881a-9e8ef9d42403', 'e3e96e5f-dd68-4229-8b66-dee1f0940f3d', 'a262d22b-5fa8-491c-9bd9-58fba03e301e', @@ -77,10 +91,19 @@ const uuids = [ '185dc090-3fcc-44e3-8a17-4e2fa792f91a', ]; -function v4 () { - const uuid = uuids[counter++]; +function v1 () { + const uuid = v1UUIDs[v1Counter++]; if (!uuid) { - throw new Error('Not enough mocked UUIDs to go around'); + throw new Error('Not enough mocked v1 UUIDs to go around'); + } + + return uuid; +} + +function v4 () { + const uuid = v4UUIDs[v4Counter++]; + if (!uuid) { + throw new Error('Not enough mocked v4 UUIDs to go around'); } return uuid; @@ -88,3 +111,4 @@ function v4 () { module.exports = () => v4(); module.exports.v4 = () => v4(); +module.exports.v1 = () => v1(); diff --git a/app/models/response.js b/app/models/response.js index 168cd811cb..1b05426d0c 100644 --- a/app/models/response.js +++ b/app/models/response.js @@ -41,6 +41,11 @@ export function findRecentForRequest (requestId, limit) { return db.findMostRecentlyModified(type, {parentId: requestId}, limit); } +export async function getLatestForRequest (requestId) { + const responses = await findRecentForRequest(requestId, 1); + return responses[0] || null; +} + export async function create (patch = {}) { if (!patch.parentId) { throw new Error('New Response missing `parentId`'); diff --git a/app/package.json b/app/package.json index 47e053e43a..93824c5774 100644 --- a/app/package.json +++ b/app/package.json @@ -6,11 +6,12 @@ "longName": "Insomnia REST Client", "description": "A simple and beautiful REST API client", "homepage": "http://insomnia.rest", - "author": "Gregory Schier ", + "author": "Insomnia ", "main": "main.js", "dependencies": { "analytics-node": "^2.1.0", "classnames": "^2.2.3", + "clone": "^2.1.0", "electron-context-menu": "^0.4.0", "electron-squirrel-startup": "^1.0.0", "hkdf": "0.0.2", @@ -27,7 +28,6 @@ "request": "^2.71.0", "srp-js": "0.2.0", "tough-cookie": "^2.3.1", - "traverse": "^0.6.6", "uuid": "^3.0.0", "vkbeautify": "^0.99.1", "whatwg-fetch": "^2.0.1", diff --git a/app/sync/index.js b/app/sync/index.js index d1df7859ec..5430dfe2b6 100644 --- a/app/sync/index.js +++ b/app/sync/index.js @@ -740,7 +740,7 @@ export async function getOrCreateAllActiveResources (resourceGroupId = null) { activeResourceMap[doc._id] = await createResourceForDoc(doc); created++; } catch (e) { - logger.error(`Failed to create resource for ${doc._id}`, e, {doc}); + logger.error(`Failed to create resource for ${doc._id} ${e}`, {doc}); } } } diff --git a/app/templating/extensions/NowExtension.js b/app/templating/extensions/NowExtension.js new file mode 100644 index 0000000000..26965f61cf --- /dev/null +++ b/app/templating/extensions/NowExtension.js @@ -0,0 +1,26 @@ +import BaseExtension from './base/BaseExtension'; + +export default class NowExtension extends BaseExtension { + constructor () { + super(); + this.tags = ['now']; + } + + run (context, format = 'iso-8601') { + format = typeof format === 'string' ? format.toLowerCase() : 'unknown'; + const now = new Date(); + + switch (format) { + case 'millis': + case 'ms': + return now.getTime(); + case 'unix': + case 'seconds': + case 's': + return Math.round(now.getTime() / 1000); + case 'iso-8601': + default: + return now.toISOString(); + } + } +} diff --git a/app/templating/extensions/ResponseJsonPathExtension.js b/app/templating/extensions/ResponseJsonPathExtension.js new file mode 100644 index 0000000000..0afad41770 --- /dev/null +++ b/app/templating/extensions/ResponseJsonPathExtension.js @@ -0,0 +1,51 @@ +import jq from 'jsonpath'; +import * as models from '../../models'; + +import BaseExtension from './base/BaseExtension'; + +const TAG_NAME = 'JSONPath'; + +export default class ResponseJsonPathExtension extends BaseExtension { + constructor () { + super(); + this.tags = [TAG_NAME]; + } + + async run (context, id, query) { + const request = await models.request.getById(id); + if (!request) { + throw new Error(`[${TAG_NAME}] Could not find request ${id}`) + } + + const response = await models.response.getLatestForRequest(id); + + if (!response) { + throw new Error(`[${TAG_NAME}] No responses for request ${id}`); + } + + const bodyBuffer = new Buffer(response.body, response.encoding); + const bodyStr = bodyBuffer.toString(); + + let body; + try { + body = JSON.parse(bodyStr); + } catch (err) { + throw new Error(`[${TAG_NAME}] Invalid JSON: ${err.message}`) + } + + let results; + try { + results = jq.query(body, query); + } catch (err) { + throw new Error(`[${TAG_NAME}] Invalid JSONPath query: ${query}`) + } + + if (results.length === 0) { + throw new Error(`[${TAG_NAME}] Returned no results: ${query}`) + } else if (results.length > 1) { + throw new Error(`[${TAG_NAME}] Returned more than one result: ${query}`) + } + + return `${results[0] || ''}`; + } +} diff --git a/app/templating/extensions/TimestampExtension.js b/app/templating/extensions/TimestampExtension.js new file mode 100644 index 0000000000..a22a79cbba --- /dev/null +++ b/app/templating/extensions/TimestampExtension.js @@ -0,0 +1,12 @@ +import BaseExtension from './base/BaseExtension'; + +export default class TimestampExtension extends BaseExtension { + constructor () { + super(); + this.tags = ['timestamp']; + } + + run (context) { + return Date.now(); + } +} diff --git a/app/templating/extensions/UuidExtension.js b/app/templating/extensions/UuidExtension.js new file mode 100644 index 0000000000..5b3fb5c67a --- /dev/null +++ b/app/templating/extensions/UuidExtension.js @@ -0,0 +1,27 @@ +import uuid from 'uuid'; +import BaseExtension from './base/BaseExtension'; + +export default class UuidExtension extends BaseExtension { + constructor () { + super(); + this.tags = ['uuid']; + } + + run (context, version) { + if (typeof version === 'number') { + version += ''; + } else if (typeof version === 'string') { + version = version.toLowerCase(); + } + + switch (version) { + case '1': + case 'v1': + return uuid.v1(); + case '4': + case 'v4': + default: + return uuid.v4(); + } + } +} diff --git a/app/templating/extensions/__tests__/NowExtension.test.js b/app/templating/extensions/__tests__/NowExtension.test.js new file mode 100644 index 0000000000..27fc63dd06 --- /dev/null +++ b/app/templating/extensions/__tests__/NowExtension.test.js @@ -0,0 +1,23 @@ +import * as templating from '../../index'; + +function assertTemplate (txt, expected) { + return async function () { + const result = await templating.render(txt); + expect(result).toMatch(expected); + } +} + +const isoRe = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/; +const secondsRe = /^\d{10}$/; +const millisRe = /^\d{13}$/; + +describe('NowExtension', () => { + it('renders default ISO', assertTemplate('{% now %}', isoRe)); + it('renders ISO-8601', assertTemplate('{% now "ISO-8601" %}', isoRe)); + it('renders seconds', assertTemplate('{% now "seconds" %}', secondsRe)); + it('renders s', assertTemplate('{% now "s" %}', secondsRe)); + it('renders unix', assertTemplate('{% now "unix" %}', secondsRe)); + it('renders millis', assertTemplate('{% now "millis" %}', millisRe)); + it('renders ms', assertTemplate('{% now "ms" %}', millisRe)); + it('renders default fallback', assertTemplate('{% now "foo" %}', isoRe)); +}); diff --git a/app/templating/extensions/__tests__/ResponseJsonPathExtension.test.js b/app/templating/extensions/__tests__/ResponseJsonPathExtension.test.js new file mode 100644 index 0000000000..719fbd6758 --- /dev/null +++ b/app/templating/extensions/__tests__/ResponseJsonPathExtension.test.js @@ -0,0 +1,86 @@ +import * as templating from '../../index'; +import * as db from '../../../common/database'; +import * as models from '../../../models'; + +describe('ResponseJsonPathExtension', async () => { + beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true)); + + it('renders basic JSONPath query', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({parentId: request._id, body: '{"foo": "bar"}'}); + + const result = await templating.render(`{% JSONPath "${request._id}", "$.foo" %}`); + + expect(result).toBe('bar'); + }); + + it('fails on invalid JSON', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({parentId: request._id, body: '{"foo": "bar"'}); + + try { + await templating.render(`{% JSONPath "${request._id}", "$.foo" %}`); + fail('JSON should have failed to parse'); + } catch (err) { + expect(err.message).toContain('Invalid JSON: Unexpected end of JSON input'); + } + }); + + it('fails on no responses', async () => { + const request = await models.request.create({parentId: 'foo'}); + + try { + await templating.render(`{% JSONPath "${request._id}", "$.foo" %}`); + fail('JSON should have failed to parse'); + } catch (err) { + expect(err.message).toContain('No responses for request'); + } + }); + + it('fails on no request', async () => { + await models.response.create({parentId: 'req_test', body: '{"foo": "bar"}'}); + + try { + await templating.render(`{% JSONPath "req_test", "$.foo" %}`); + fail('JSON should have failed to parse') + } catch (err) { + expect(err.message).toContain('Could not find request req_test'); + } + }); + + it('fails on invalid query', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({parentId: request._id, body: '{"foo": "bar"}'}); + + try { + await templating.render(`{% JSONPath "${request._id}", "$$" %}`); + fail('JSON should have failed to parse') + } catch (err) { + expect(err.message).toContain('Invalid JSONPath query: $$'); + } + }); + + it('fails on no results', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({parentId: request._id, body: '{"foo": "bar"}'}); + + try { + await templating.render(`{% JSONPath "${request._id}", "$.missing" %}`); + fail('JSON should have failed to parse') + } catch (err) { + expect(err.message).toContain('Returned no results: $.missing'); + } + }); + + it('fails on more than 1 result', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({parentId: request._id, body: '{"array": [1,2,3]}'}); + + try { + await templating.render(`{% JSONPath "${request._id}", "$.array.*" %}`); + fail('JSON should have failed to parse') + } catch (err) { + expect(err.message).toContain('Returned more than one result: $.array.*'); + } + }); +}); diff --git a/app/templating/extensions/__tests__/TimestampExtension.test.js b/app/templating/extensions/__tests__/TimestampExtension.test.js new file mode 100644 index 0000000000..6fe7444d90 --- /dev/null +++ b/app/templating/extensions/__tests__/TimestampExtension.test.js @@ -0,0 +1,14 @@ +import * as templating from '../../index'; + +function assertTemplate (txt, expected) { + return async function () { + const result = await templating.render(txt); + expect(result).toMatch(expected); + } +} + +const millisRe = /^\d{13}$/; + +describe('TimestampExtension', () => { + it('renders basic', assertTemplate('{% timestamp %}', millisRe)); +}); diff --git a/app/templating/extensions/__tests__/UuidExtension.test.js b/app/templating/extensions/__tests__/UuidExtension.test.js new file mode 100644 index 0000000000..e1e4cfa901 --- /dev/null +++ b/app/templating/extensions/__tests__/UuidExtension.test.js @@ -0,0 +1,19 @@ +import * as templating from '../../index'; + +function assertTemplate (txt, expected) { + return async function () { + const result = await templating.render(txt); + expect(result).toMatch(expected); + } +} + +describe('UuidExtension', () => { + it('renders default v4', assertTemplate('{% uuid %}', 'dd2ccc1a-2745-477a-881a-9e8ef9d42403')); + it('renders 4', assertTemplate('{% uuid "4" %}', 'e3e96e5f-dd68-4229-8b66-dee1f0940f3d')); + it('renders 4 num', assertTemplate('{% uuid 4 %}', 'a262d22b-5fa8-491c-9bd9-58fba03e301e')); + it('renders v4', assertTemplate('{% uuid "v4" %}', '2e7c2688-09ee-44b8-900d-5cbbaa7d3a19')); + it('renders 1', assertTemplate('{% uuid "1" %}', 'f7272c80-f493-11e6-bc64-92361f002671')); + it('renders 1 num', assertTemplate('{% uuid 1 %}', 'f7272f0a-f493-11e6-bc64-92361f002671')); + it('renders v1', assertTemplate('{% uuid "v1" %}', 'f72733a6-f493-11e6-bc64-92361f002671')); + it('renders default fallback', assertTemplate('{% uuid "foo" %}', 'e7d698c4-c7d2-409c-90c6-22bcc94ba4ab')); +}); diff --git a/app/templating/extensions/base/BaseExtension.js b/app/templating/extensions/base/BaseExtension.js new file mode 100644 index 0000000000..7b8daf06c3 --- /dev/null +++ b/app/templating/extensions/base/BaseExtension.js @@ -0,0 +1,49 @@ +export default class NowExtension { + constructor () { + // TODO: Subclass should set this + // this.tags = ['now']; + } + + parse (parser, nodes, lexer) { + const tok = parser.nextToken(); + + let args; + if (parser.peekToken().type !== lexer.TOKEN_BLOCK_END) { + args = parser.parseSignature(null, true); + } else { + // Not sure why this is needed, but it fails without it + args = new nodes.NodeList(tok.lineno, tok.colno); + args.addChild(new nodes.Literal(0, 0, "")); + } + + parser.advanceAfterBlockEnd(tok.value); + return new nodes.CallExtensionAsync(this, 'asyncRun', args); + } + + asyncRun (...runArgs) { + // Pull the callback off the end + const callback = runArgs[runArgs.length - 1]; + const args = runArgs.slice(0, runArgs.length - 1); + + let result; + try { + result = this.run(...args); + } catch (err) { + // In case a synchronous render fails + callback(err); + return; + } + + // If the result is a promise, resolve it async + if (result instanceof Promise) { + result.then( + r => callback(null, r), + err => callback(err) + ); + return; + } + + // If the result is not a Promise, return it synchronously + callback(null, result); + } +} diff --git a/app/templating/extensions/index.js b/app/templating/extensions/index.js new file mode 100644 index 0000000000..15f1d8da07 --- /dev/null +++ b/app/templating/extensions/index.js @@ -0,0 +1,13 @@ +import TimestampExtension from './TimestampExtension'; +import UuidExtension from './UuidExtension'; +import NowExtension from './NowExtension'; +import ResponseJsonPathExtension from './ResponseJsonPathExtension'; + +export function all () { + return [ + TimestampExtension, + UuidExtension, + NowExtension, + ResponseJsonPathExtension, + ] +} diff --git a/app/templating/index.js b/app/templating/index.js new file mode 100644 index 0000000000..0f6864519e --- /dev/null +++ b/app/templating/index.js @@ -0,0 +1,42 @@ +import nunjucks from 'nunjucks'; +import * as extensions from './extensions'; + +/** + * Render text based on stuff + * @param {String} text - Nunjucks template in text form + * @param {Object} [config] - Config options for rendering + * @param {Object} [config.context] - Context to render with + */ +export async function render (text, config = {}) { + const context = config.context || {}; + + return new Promise((resolve, reject) => { + _getEnv().renderString(text, context, (err, result) => { + if (err) { + const sanitizedMsg = err.message.replace(/\(unknown path\)\s*/, ''); + reject(new Error(sanitizedMsg)); + } else { + resolve(result); + } + }); + }); +} + +// ~~~~~~~~~~~~~ // +// Private Stuff // +// ~~~~~~~~~~~~~ // + +let _nunjucksEnvironment = null; +function _getEnv () { + if (!_nunjucksEnvironment) { + _nunjucksEnvironment = nunjucks.configure({ + autoescape: false, + }); + + for (const Cls of extensions.all()) { + _nunjucksEnvironment.addExtension(Cls.name, new Cls()); + } + } + + return _nunjucksEnvironment; +} diff --git a/app/ui/components/Toast.js b/app/ui/components/Toast.js index ccc2c0bf6f..8854efefb8 100644 --- a/app/ui/components/Toast.js +++ b/app/ui/components/Toast.js @@ -4,7 +4,6 @@ import Link from './base/Link'; import * as fetch from '../../common/fetch'; import {trackEvent} from '../../analytics/index'; import * as models from '../../models/index'; -import * as querystring from '../../common/querystring'; import * as constants from '../../common/constants'; import * as db from '../../common/database'; diff --git a/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js b/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js index 553a43dd9b..33c1b28c2f 100644 --- a/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js +++ b/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js @@ -167,7 +167,9 @@ class WorkspaceEnvironmentsEditModal extends Component { Environment - + Private Environment @@ -184,7 +186,7 @@ class WorkspaceEnvironmentsEditModal extends Component {