From baef2807c0e375e312660feb4ad34c737e48b5db Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:50:36 +0200 Subject: [PATCH] fix(framework): Support json values in LiquidJS templates (#6714) --- packages/framework/src/client.test.ts | 380 ++++++++++++++++++ packages/framework/src/client.ts | 11 +- .../framework/src/utils/string.utils.test.ts | 60 ++- packages/framework/src/utils/string.utils.ts | 18 + 4 files changed, 466 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/client.test.ts b/packages/framework/src/client.test.ts index c9bebaa4205..513c17359d9 100644 --- a/packages/framework/src/client.test.ts +++ b/packages/framework/src/client.test.ts @@ -593,6 +593,386 @@ describe('Novu Client', () => { expect(body).toContain('dog'); }); + it('should compile array control variables to a string with single quotes', async () => { + const newWorkflow = workflow( + 'test-workflow', + async ({ step }) => { + await step.email( + 'send-email', + async (controls) => ({ + body: controls.body, + subject: controls.subject, + }), + { + controlSchema: { + type: 'object', + properties: { + body: { type: 'string' }, + subject: { type: 'string' }, + }, + required: ['body', 'subject'], + additionalProperties: false, + } as const, + } + ); + }, + { + payloadSchema: { + type: 'object', + properties: { + comments: { + type: 'array', + items: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, + subject: { type: 'string' }, + }, + required: ['comments', 'subject'], + additionalProperties: false, + } as const, + } + ); + + client.addWorkflows([newWorkflow]); + + const event: Event = { + action: PostActionEnum.EXECUTE, + data: {}, + payload: { comments: [{ text: 'cat' }, { text: 'dog' }], subject: 'Hello' }, + workflowId: 'test-workflow', + stepId: 'send-email', + subscriber: {}, + state: [], + inputs: {}, + controls: { + body: '{{payload.comments}}', + subject: '{{payload.subject}}', + }, + }; + + const emailExecutionResult = await client.executeWorkflow(event); + + expect(emailExecutionResult.outputs).toEqual({ + body: "[{'text':'cat'},{'text':'dog'}]", + subject: 'Hello', + }); + }); + + it('should compile array control variables to a string with single quotes when using json filter', async () => { + const newWorkflow = workflow( + 'test-workflow', + async ({ step }) => { + await step.email( + 'send-email', + async (controls) => ({ + body: controls.body, + subject: controls.subject, + }), + { + controlSchema: { + type: 'object', + properties: { + body: { type: 'string' }, + subject: { type: 'string' }, + }, + required: ['body', 'subject'], + additionalProperties: false, + } as const, + } + ); + }, + { + payloadSchema: { + type: 'object', + properties: { + comments: { + type: 'array', + items: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, + subject: { type: 'string' }, + }, + required: ['comments', 'subject'], + additionalProperties: false, + } as const, + } + ); + + client.addWorkflows([newWorkflow]); + + const event: Event = { + action: PostActionEnum.EXECUTE, + data: {}, + payload: { comments: [{ text: 'cat' }, { text: 'dog' }], subject: 'Hello' }, + workflowId: 'test-workflow', + stepId: 'send-email', + subscriber: {}, + state: [], + inputs: {}, + controls: { + body: '{{payload.comments | json}}', + subject: '{{payload.subject}}', + }, + }; + + const emailExecutionResult = await client.executeWorkflow(event); + + expect(emailExecutionResult.outputs).toEqual({ + body: "[{'text':'cat'},{'text':'dog'}]", + subject: 'Hello', + }); + }); + + it('should compile object control variables to a string with single quotes', async () => { + const newWorkflow = workflow( + 'test-workflow', + async ({ step }) => { + await step.email( + 'send-email', + async (controls) => ({ + body: controls.body, + subject: controls.subject, + }), + { + controlSchema: { + type: 'object', + properties: { + body: { type: 'string' }, + subject: { type: 'string' }, + }, + required: ['body', 'subject'], + additionalProperties: false, + } as const, + } + ); + }, + { + payloadSchema: { + type: 'object', + properties: { + comment: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + subject: { type: 'string' }, + }, + required: ['comment', 'subject'], + additionalProperties: false, + } as const, + } + ); + + client.addWorkflows([newWorkflow]); + + const event: Event = { + action: PostActionEnum.EXECUTE, + data: {}, + payload: { comment: { text: 'cat' }, subject: 'Hello' }, + workflowId: 'test-workflow', + stepId: 'send-email', + subscriber: {}, + state: [], + inputs: {}, + controls: { + body: '{{payload.comment}}', + subject: '{{payload.subject}}', + }, + }; + + const emailExecutionResult = await client.executeWorkflow(event); + + expect(emailExecutionResult.outputs).toEqual({ + body: "{'text':'cat'}", + subject: 'Hello', + }); + }); + + it('should compile object control variables to a string with single quotes when using json filter', async () => { + const newWorkflow = workflow( + 'test-workflow', + async ({ step }) => { + await step.email( + 'send-email', + async (controls) => ({ + body: controls.body, + subject: controls.subject, + }), + { + controlSchema: { + type: 'object', + properties: { + body: { type: 'string' }, + subject: { type: 'string' }, + }, + required: ['body', 'subject'], + additionalProperties: false, + } as const, + } + ); + }, + { + payloadSchema: { + type: 'object', + properties: { + comment: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + subject: { type: 'string' }, + }, + required: ['comment', 'subject'], + additionalProperties: false, + } as const, + } + ); + + client.addWorkflows([newWorkflow]); + + const event: Event = { + action: PostActionEnum.EXECUTE, + data: {}, + payload: { comment: { text: 'cat' }, subject: 'Hello' }, + workflowId: 'test-workflow', + stepId: 'send-email', + subscriber: {}, + state: [], + inputs: {}, + controls: { + body: '{{payload.comment | json}}', + subject: '{{payload.subject}}', + }, + }; + + const emailExecutionResult = await client.executeWorkflow(event); + + expect(emailExecutionResult.outputs).toEqual({ + body: "{'text':'cat'}", + subject: 'Hello', + }); + }); + + it('should respect the spaces option when using json filter', async () => { + const newWorkflow = workflow( + 'test-workflow', + async ({ step }) => { + await step.email( + 'send-email', + async (controls) => ({ + body: controls.body, + subject: controls.subject, + }), + { + controlSchema: { + type: 'object', + properties: { + body: { type: 'string' }, + subject: { type: 'string' }, + }, + required: ['body', 'subject'], + additionalProperties: false, + } as const, + } + ); + }, + { + payloadSchema: { + type: 'object', + properties: { + comment: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + subject: { type: 'string' }, + }, + required: ['comment', 'subject'], + additionalProperties: false, + } as const, + } + ); + + client.addWorkflows([newWorkflow]); + + const event: Event = { + action: PostActionEnum.EXECUTE, + data: {}, + payload: { comment: { text: 'cat' }, subject: 'Hello' }, + workflowId: 'test-workflow', + stepId: 'send-email', + subscriber: {}, + state: [], + inputs: {}, + controls: { + body: '{{payload.comment | json: 2}}', + subject: '{{payload.subject}}', + }, + }; + + const emailExecutionResult = await client.executeWorkflow(event); + + expect(emailExecutionResult.outputs).toEqual({ + body: `{ + 'text': 'cat' +}`, + subject: 'Hello', + }); + }); + + it('should gracefully compile control variables that are not present', async () => { + const newWorkflow = workflow('test-workflow', async ({ step }) => { + await step.email( + 'send-email', + async (controls) => ({ + body: controls.body, + subject: controls.subject, + }), + { + controlSchema: { + type: 'object', + properties: { + body: { type: 'string' }, + subject: { type: 'string' }, + }, + required: ['body', 'subject'], + additionalProperties: false, + } as const, + } + ); + }); + + client.addWorkflows([newWorkflow]); + + const event: Event = { + action: PostActionEnum.EXECUTE, + data: {}, + payload: {}, + workflowId: 'test-workflow', + stepId: 'send-email', + subscriber: {}, + state: [], + inputs: {}, + controls: { + body: 'Hi {{payload.does_not_exist}}', + subject: 'Test subject', + }, + }; + + const emailExecutionResult = await client.executeWorkflow(event); + + expect(emailExecutionResult.outputs).toEqual({ + body: 'Hi undefined', + subject: 'Test subject', + }); + }); + // skipped until we implement support for control variables https://linear.app/novu/issue/NV-4248/support-for-controls-in-autocomplete it.skip('should compile control variables used in other control variables', async () => { const newWorkflow = workflow('test-workflow', async ({ step }) => { diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index 3bb83b2a6b2..d88897da9cc 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -35,7 +35,7 @@ import type { Workflow, } from './types'; import { WithPassthrough } from './types/provider.types'; -import { EMOJI, log, sanitizeHtmlInObject } from './utils'; +import { EMOJI, log, sanitizeHtmlInObject, stringifyDataStructureWithSingleQuotes } from './utils'; import { transformSchema, validateData } from './validators'; /** @@ -57,7 +57,11 @@ function isRuntimeInDevelopment() { export class Client { private discoveredWorkflows: Array = []; - private templateEngine = new Liquid(); + private templateEngine = new Liquid({ + outputEscape: (output) => { + return stringifyDataStructureWithSingleQuotes(output); + }, + }); public secretKey?: string; @@ -69,6 +73,9 @@ export class Client { const builtOpts = this.buildOptions(options); this.secretKey = builtOpts.secretKey; this.strictAuthentication = builtOpts.strictAuthentication; + this.templateEngine.registerFilter('json', (value, spaces) => + stringifyDataStructureWithSingleQuotes(value, spaces) + ); } private buildOptions(providedOptions?: ClientOptions) { diff --git a/packages/framework/src/utils/string.utils.test.ts b/packages/framework/src/utils/string.utils.test.ts index 2f24621bda4..f351221a64f 100644 --- a/packages/framework/src/utils/string.utils.test.ts +++ b/packages/framework/src/utils/string.utils.test.ts @@ -1,5 +1,5 @@ import { expect, it, describe } from 'vitest'; -import { toConstantCase } from './string.utils'; +import { stringifyDataStructureWithSingleQuotes, toConstantCase } from './string.utils'; describe('convert to constant case', () => { it('converts properties correctly', () => { @@ -19,3 +19,61 @@ describe('convert to constant case', () => { }, ''); }); }); + +describe('stringifyDataStructureWithSingleQuotes', () => { + it('should convert a simple array to a string with single quotes', () => { + const myTestArray = ['a', 'b', 'c']; + const converted = stringifyDataStructureWithSingleQuotes(myTestArray); + expect(converted).toStrictEqual("['a','b','c']"); + }); + + it('should convert an array with nested objects to a string with single quotes', () => { + const myTestObject = [{ text: 'cat' }, { text: 'dog' }]; + const converted = stringifyDataStructureWithSingleQuotes(myTestObject); + expect(converted).toStrictEqual("[{'text':'cat'},{'text':'dog'}]"); + }); + + it('should convert an object with nested objects to a string with single quotes', () => { + const myTestObject = { comments: [{ text: 'cat' }, { text: 'dog' }] }; + const converted = stringifyDataStructureWithSingleQuotes(myTestObject); + expect(converted).toStrictEqual("{'comments':[{'text':'cat'},{'text':'dog'}]}"); + }); + + it('should convert an object with nested objects to a string with single quotes and spaces', () => { + const myTestObject = { comments: [{ text: 'cat' }, { text: 'dog' }] }; + const converted = stringifyDataStructureWithSingleQuotes(myTestObject, 2); + expect(converted).toStrictEqual( + `{\\n 'comments': [\\n {\\n 'text': 'cat'\\n },\\n {\\n 'text': 'dog'\\n }\\n ]\\n}` + ); + }); + + it('should convert a string to a string without single quotes', () => { + const myTestString = 'hello'; + const converted = stringifyDataStructureWithSingleQuotes(myTestString); + expect(converted).toStrictEqual('hello'); + }); + + it('should convert a number to a string without single quotes', () => { + const myTestNumber = 123; + const converted = stringifyDataStructureWithSingleQuotes(myTestNumber); + expect(converted).toStrictEqual('123'); + }); + + it('should convert a boolean to a string without single quotes', () => { + const myTestBoolean = true; + const converted = stringifyDataStructureWithSingleQuotes(myTestBoolean); + expect(converted).toStrictEqual('true'); + }); + + it('should convert null to a string without single quotes', () => { + const myTestNull = null; + const converted = stringifyDataStructureWithSingleQuotes(myTestNull); + expect(converted).toStrictEqual('null'); + }); + + it('should convert undefined to an empty string', () => { + const myTestUndefined = undefined; + const converted = stringifyDataStructureWithSingleQuotes(myTestUndefined); + expect(converted).toStrictEqual('undefined'); + }); +}); diff --git a/packages/framework/src/utils/string.utils.ts b/packages/framework/src/utils/string.utils.ts index 1e8365877fd..3326d4038a3 100644 --- a/packages/framework/src/utils/string.utils.ts +++ b/packages/framework/src/utils/string.utils.ts @@ -20,3 +20,21 @@ export const enumToPrettyString = (_enum: T): string => export const toPascalCase = (str: string): string => str.replaceAll(/(\w)(\w*)/g, (_, first, rest) => first.toUpperCase() + rest.toLowerCase()).replaceAll(/[\s-]+/g, ''); + +/** + * Converts a data structure to a string with single quotes, + * converting primitives to strings. + * @param value The value to convert + * @returns A string with single quotes around objects and arrays, and the stringified value itself if it's not an object or array + */ +export const stringifyDataStructureWithSingleQuotes = (value: unknown, spaces: number = 0): string => { + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + const valueStringified = JSON.stringify(value, null, spaces); + const valueSingleQuotes = valueStringified.replace(/"/g, "'"); + const valueEscapedNewLines = valueSingleQuotes.replace(/\n/g, '\\n'); + + return valueEscapedNewLines; + } else { + return String(value); + } +};