Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(root): refactor control compilation #7590

Open
wants to merge 3 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/app/environments-v1/novu-bridge-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class NovuBridgeClient {
this.novuRequestHandler = new NovuRequestHandler({
frameworkName,
workflows,
client: new Client({ secretKey, strictAuthentication: true }),
client: new Client({ secretKey, strictAuthentication: true, compileControls: false }),
handler: this.novuHandler.handler,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DelayOutputRendererUsecase } from '../output-renderers/delay-output-ren
import { DigestOutputRendererUsecase } from '../output-renderers/digest-output-renderer.usecase';
import { evaluateRules } from '../../../shared/services/query-parser/query-parser.service';
import { isMatchingJsonSchema } from '../../../workflows-v2/util/jsonToSchema';
import { parseLiquid, parseLiquidString } from '../../../shared/helpers/liquid';

const LOG_CONTEXT = 'ConstructFrameworkWorkflow';

Expand Down Expand Up @@ -239,8 +240,8 @@ export class ConstructFrameworkWorkflow {
if (_.isEmpty(skipRules)) {
return false;
}

const { result, error } = evaluateRules(skipRules, variables);
const compiledSkipRules = await parseLiquid(skipRules, variables);
const { result, error } = evaluateRules(compiledSkipRules, variables);

if (error) {
this.logger.error({ err: error }, 'Failed to evaluate skip rule', LOG_CONTEXT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { ChatRenderOutput } from '@novu/shared';
import { Injectable } from '@nestjs/common';
import { InstrumentUsecase } from '@novu/application-generic';
import { RenderCommand } from './render-command';
import { parseLiquid } from '../../../shared/helpers/liquid';

@Injectable()
export class ChatOutputRendererUsecase {
@InstrumentUsecase()
execute(renderCommand: RenderCommand): ChatRenderOutput {
async execute(renderCommand: RenderCommand): Promise<ChatRenderOutput> {
const { skip, ...outputControls } = renderCommand.controlValues ?? {};
const parsedOutputControls = await parseLiquid(outputControls, renderCommand.fullPayloadForRender);

return outputControls as any;
return parsedOutputControls as any;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Injectable } from '@nestjs/common';
import { DelayRenderOutput } from '@novu/shared';
import { InstrumentUsecase } from '@novu/application-generic';
import { RenderCommand } from './render-command';
import { parseLiquid } from '../../../shared/helpers/liquid';

@Injectable()
export class DelayOutputRendererUsecase {
@InstrumentUsecase()
execute(renderCommand: RenderCommand): DelayRenderOutput {
async execute(renderCommand: RenderCommand): Promise<DelayRenderOutput> {
const { skip, ...outputControls } = renderCommand.controlValues ?? {};
const parsedOutputControls = await parseLiquid(outputControls, renderCommand.fullPayloadForRender);

return outputControls as any;
return parsedOutputControls as any;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { DigestRenderOutput } from '@novu/shared';
import { InstrumentUsecase } from '@novu/application-generic';
import { RenderCommand } from './render-command';
import { parseLiquid } from '../../../shared/helpers/liquid';

@Injectable()
export class DigestOutputRendererUsecase {
@InstrumentUsecase()
execute(renderCommand: RenderCommand): DigestRenderOutput {
async execute(renderCommand: RenderCommand): Promise<DigestRenderOutput> {
const { skip, ...outputControls } = renderCommand.controlValues ?? {};
const parsedOutputControls = await parseLiquid(outputControls, renderCommand.fullPayloadForRender);

return outputControls as any;
return parsedOutputControls as any;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { InstrumentUsecase } from '@novu/application-generic';
import { FullPayloadForRender, RenderCommand } from './render-command';
import { WrapMailyInLiquidUseCase } from './maily-to-liquid/wrap-maily-in-liquid.usecase';
import { MAILY_ITERABLE_MARK, MailyAttrsEnum, MailyContentTypeEnum } from './maily-to-liquid/maily.types';
import { parseLiquid } from '../../../shared/helpers/liquid';
import { parseLiquid, parseLiquidString } from '../../../shared/helpers/liquid';

export class EmailOutputRendererCommand extends RenderCommand {}

Expand All @@ -17,7 +17,8 @@ export class EmailOutputRendererUsecase {

@InstrumentUsecase()
async execute(renderCommand: EmailOutputRendererCommand): Promise<EmailRenderOutput> {
const { body, subject } = renderCommand.controlValues;
const { body, subject: controlSubject } = renderCommand.controlValues;
const subject = await parseLiquidString(controlSubject as string, renderCommand.fullPayloadForRender);

if (!body || typeof body !== 'string') {
/**
Expand All @@ -33,7 +34,7 @@ export class EmailOutputRendererUsecase {

const liquifiedMaily = this.wrapMailyInLiquidUsecase.execute({ emailEditor: body });
const transformedMaily = await this.transformMailyContent(liquifiedMaily, renderCommand.fullPayloadForRender);
const parsedMaily = await this.parseMailyContentByLiquid(transformedMaily, renderCommand.fullPayloadForRender);
const parsedMaily = await parseLiquid(transformedMaily, renderCommand.fullPayloadForRender);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main purpose of this PR:

Previously, the framework would parse controls once before execution, and then parse them again during execution.

With this update, controls are parsed only once, improving efficiency.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the compileControls flag required for this improvement?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is driven by two main reasons:

  1. Performance Improvement: The most obvious benefit is improving performance, as we no longer compile multiple times.
  2. Flexibility and Bug Prevention: The second reason is increased flexibility and the potential to address bugs, which I will elaborate on.

From the beginning, we relied on maily.variable.type, but there came a point when we considered decoupling from maily.variable.type and switching to maily.text.type using the simple text with {{...}} syntax. This approach would allow us to manage variables independently using Liquid, a change I support because it grants us greater control and simplifies our codebase.

Why is this important? If we do not control the content rendering process and the framework renders the controls before we execute the "render" step, it could lead to bugs like the one mentioned. Taking control of the rendering process helps prevent such issues.

const strippedMaily = this.removeTrailingEmptyLines(parsedMaily);
const renderedHtml = await mailyRender(strippedMaily);

Expand All @@ -42,7 +43,7 @@ export class EmailOutputRendererUsecase {
* This passes responsibility to framework to throw type validation exceptions
* rather than handling invalid types here.
*/
return { subject: subject as string, body: renderedHtml };
return { subject, body: renderedHtml };
}

private removeTrailingEmptyLines(node: MailyJSONContent): MailyJSONContent {
Expand All @@ -69,15 +70,6 @@ export class EmailOutputRendererUsecase {
return { ...node, content: filteredContent };
}

private async parseMailyContentByLiquid(
mailyContent: MailyJSONContent,
variables: FullPayloadForRender
): Promise<MailyJSONContent> {
const parsedString = await parseLiquid(JSON.stringify(mailyContent), variables);

return JSON.parse(parsedString);
}

private async transformMailyContent(
node: MailyJSONContent,
variables: FullPayloadForRender,
Expand Down Expand Up @@ -146,7 +138,7 @@ export class EmailOutputRendererUsecase {
node: MailyJSONContent & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } }
): Promise<boolean> {
const { [MailyAttrsEnum.SHOW_IF_KEY]: showIfKey } = node.attrs;
const parsedShowIfValue = await parseLiquid(showIfKey, variables);
const parsedShowIfValue = await parseLiquidString(showIfKey, variables);

return this.stringToBoolean(parsedShowIfValue);
}
Expand Down Expand Up @@ -209,7 +201,7 @@ export class EmailOutputRendererUsecase {
}

private async getIterableArray(iterablePath: string, variables: FullPayloadForRender): Promise<unknown[]> {
const iterableArrayString = await parseLiquid(iterablePath, variables);
const iterableArrayString = await parseLiquidString(iterablePath, variables);

try {
const parsedArray = JSON.parse(iterableArrayString.replace(/'/g, '"'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { InAppRenderOutput } from '@novu/shared';
import { Injectable } from '@nestjs/common';
import { InstrumentUsecase } from '@novu/application-generic';
import { RenderCommand } from './render-command';
import { parseLiquid } from '../../../shared/helpers/liquid';

@Injectable()
export class InAppOutputRendererUsecase {
@InstrumentUsecase()
execute(renderCommand: RenderCommand): InAppRenderOutput {
async execute(renderCommand: RenderCommand): Promise<InAppRenderOutput> {
const { skip, disableOutputSanitization, ...outputControls } = renderCommand.controlValues ?? {};
const parsedOutputControls = await parseLiquid(outputControls, renderCommand.fullPayloadForRender);

return outputControls as any;
return parsedOutputControls as any;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Injectable } from '@nestjs/common';
import { PushRenderOutput } from '@novu/shared';
import { InstrumentUsecase } from '@novu/application-generic';
import { RenderCommand } from './render-command';
import { parseLiquid } from '../../../shared/helpers/liquid';

@Injectable()
export class PushOutputRendererUsecase {
@InstrumentUsecase()
execute(renderCommand: RenderCommand): PushRenderOutput {
async execute(renderCommand: RenderCommand): Promise<PushRenderOutput> {
const { skip, ...outputControls } = renderCommand.controlValues ?? {};
const parsedOutputControls = await parseLiquid(outputControls, renderCommand.fullPayloadForRender);

return outputControls as any;
return parsedOutputControls as any;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Injectable } from '@nestjs/common';
import { SmsRenderOutput } from '@novu/shared';
import { InstrumentUsecase } from '@novu/application-generic';
import { RenderCommand } from './render-command';
import { parseLiquid } from '../../../shared/helpers/liquid';

@Injectable()
export class SmsOutputRendererUsecase {
@InstrumentUsecase()
execute(renderCommand: RenderCommand): SmsRenderOutput {
async execute(renderCommand: RenderCommand): Promise<SmsRenderOutput> {
const { skip, ...outputControls } = renderCommand.controlValues ?? {};
const parsedOutputControls = await parseLiquid(outputControls, renderCommand.fullPayloadForRender);

return outputControls as any;
return parsedOutputControls as any;
}
}
11 changes: 9 additions & 2 deletions apps/api/src/app/shared/helpers/liquid.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Liquid } from 'liquidjs';

export const parseLiquid = async (value: string, variables: object): Promise<string> => {
export async function parseLiquid<T>(value: T, variables: object): Promise<T> {
const valueStringified = JSON.stringify(value);
const renderedString = await parseLiquidString(valueStringified, variables);

return JSON.parse(renderedString);
}

export async function parseLiquidString(value: string, variables: object): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small question, is it possible some how to reuse the same liquid instance from the framework SDK? The reason I'm asking is because we might want to add custom liquid filters to the system, and in this case we will have to duplicate them twice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, i thought about it as well, we need to find a good way to export it from @novu/framework.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can export it from @novu/framework/internals.

const client = new Liquid({
outputEscape: (output) => {
return stringifyDataStructureWithSingleQuotes(output);
Expand All @@ -10,7 +17,7 @@ export const parseLiquid = async (value: string, variables: object): Promise<str
const template = client.parse(value);

return await client.render(template, variables);
};
}

const stringifyDataStructureWithSingleQuotes = (value: unknown, spaces: number = 0): string => {
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
Expand Down
78 changes: 46 additions & 32 deletions packages/framework/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,14 @@ export class Client {

public strictAuthentication: boolean;

public compileControls: boolean;

constructor(options?: ClientOptions) {
const builtOpts = this.buildOptions(options);
this.apiUrl = builtOpts.apiUrl;
this.secretKey = builtOpts.secretKey;
this.strictAuthentication = builtOpts.strictAuthentication;
this.compileControls = builtOpts.compileControls;
this.templateEngine.registerFilter('json', (value, spaces) =>
stringifyDataStructureWithSingleQuotes(value, spaces)
);
Expand All @@ -86,6 +89,7 @@ export class Client {
apiUrl: resolveApiUrl(providedOptions?.apiUrl),
secretKey: resolveSecretKey(providedOptions?.secretKey),
strictAuthentication: !isRuntimeInDevelopment(),
compileControls: providedOptions?.compileControls ?? true,
djabarovgeorge marked this conversation as resolved.
Show resolved Hide resolved
};

if (providedOptions?.strictAuthentication !== undefined) {
Expand Down Expand Up @@ -309,11 +313,20 @@ export class Client {

const step = this.getStep(event.workflowId, stepId);
const isPreview = event.action === PostActionEnum.PREVIEW;
let controls = {};

if (stepId === event.stepId) {
const templateControls = await this.createStepControls(step, event);

if (this.compileControls) {
controls = await this.renderTemplateControls(templateControls, event);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the framework compiles the controls by default. However, if the user prefers to compile the controls themselves, they have the option to do so.

} else {
controls = templateControls;
}
}

// Only evaluate a skip condition when the step is the current step and not in preview mode.
if (!isPreview && stepId === event.stepId) {
const templateControls = await this.createStepControls(step, event);
const controls = await this.compileControls(templateControls, event);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the second (for all client users) or third (for email Novu users) duplicate control compilation, simplifying the process.

const shouldSkip = await this.shouldSkip(options?.skip as typeof step.options.skip, controls);

if (shouldSkip) {
Expand All @@ -336,24 +349,28 @@ export class Client {
const executeStepHandler = this.executeStep.bind(this);
const handler = isPreview ? previewStepHandler : executeStepHandler;

let stepResult = await handler(event, {
...step,
providers: step.providers.map((provider) => {
// TODO: Update return type to include ChannelStep and fix typings
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const providerResolve = (options as any)?.providers?.[provider.type] as typeof provider.resolve;

if (!providerResolve) {
throw new ProviderNotFoundError(provider.type);
}

return {
...provider,
resolve: providerResolve,
};
}),
resolve: stepResolve as typeof step.resolve,
});
let stepResult = await handler(
event,
{
...step,
providers: step.providers.map((provider) => {
// TODO: Update return type to include ChannelStep and fix typings
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const providerResolve = (options as any)?.providers?.[provider.type] as typeof provider.resolve;

if (!providerResolve) {
throw new ProviderNotFoundError(provider.type);
}

return {
...provider,
resolve: providerResolve,
};
}),
resolve: stepResolve as typeof step.resolve,
},
controls
);

if (
Object.values(ChannelStepEnum).includes(step.type as ChannelStepEnum) &&
Expand Down Expand Up @@ -634,12 +651,11 @@ export class Client {

private async executeStep(
event: Event,
step: DiscoverStepOutput
step: DiscoverStepOutput,
controls: Record<string, unknown>
): Promise<Pick<ExecuteOutput, 'outputs' | 'providers'>> {
if (event.stepId === step.stepId) {
try {
const templateControls = await this.createStepControls(step, event);
const controls = await this.compileControls(templateControls, event);
const output = await step.resolve(controls);
const validatedOutput = await this.validate(
output,
Expand Down Expand Up @@ -696,7 +712,7 @@ export class Client {
}
}

private async compileControls(templateControls: Record<string, unknown>, event: Event) {
private async renderTemplateControls(templateControls: Record<string, unknown>, event: Event) {
try {
const templateString = this.templateEngine.parse(JSON.stringify(templateControls));

Expand Down Expand Up @@ -734,10 +750,11 @@ export class Client {

private async previewStep(
event: Event,
step: DiscoverStepOutput
step: DiscoverStepOutput,
controls: Record<string, unknown>
): Promise<Pick<ExecuteOutput, 'outputs' | 'providers'>> {
try {
return await this.constructStepForPreview(event, step);
return await this.constructStepForPreview(event, step, controls);
} catch (error) {
console.log(` ${EMOJI.ERROR} Failed to preview stepId: \`${step.stepId}\``);

Expand All @@ -749,9 +766,9 @@ export class Client {
}
}

private async constructStepForPreview(event: Event, step: DiscoverStepOutput) {
private async constructStepForPreview(event: Event, step: DiscoverStepOutput, controls: Record<string, unknown>) {
if (event.stepId === step.stepId) {
return await this.previewRequiredStep(step, event);
return await this.previewRequiredStep(step, event, controls);
} else {
return await this.extractMockDataForPreviousSteps(event, step);
}
Expand All @@ -770,10 +787,7 @@ export class Client {
};
}

private async previewRequiredStep(step: DiscoverStepOutput, event: Event) {
const templateControls = await this.createStepControls(step, event);
const controls = await this.compileControls(templateControls, event);

private async previewRequiredStep(step: DiscoverStepOutput, event: Event, controls: Record<string, unknown>) {
const previewOutput = await step.resolve(controls);
const validatedOutput = await this.validate(
previewOutput,
Expand Down
6 changes: 6 additions & 0 deletions packages/framework/src/types/config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ export type ClientOptions = {
* Defaults to true.
*/
strictAuthentication?: boolean;

/**
* Whether to compile controls.
* Defaults to true.
*/
compileControls?: boolean;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, this is a global option. However, we could make it more granular in the future, for example, configurable on a per-step basis.

};
Loading