Skip to content

Commit

Permalink
feat(api): Add Novu-managed Bridge endpoint per environment (#6451)
Browse files Browse the repository at this point in the history
  • Loading branch information
rifont authored Oct 15, 2024
1 parent 2e87ab7 commit d454d17
Show file tree
Hide file tree
Showing 41 changed files with 800 additions and 265 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@
"mailin",
"Mailjet",
"mailparser",
"Maily",
"Maizzle",
"mansagroup",
"mantine",
Expand Down
32 changes: 13 additions & 19 deletions apps/api/src/app/bridge/bridge.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { CreateBridgeResponseDto } from './dtos/create-bridge-response.dto';
export class BridgeController {
constructor(
private syncUsecase: Sync,
private validateBridgeUrlUsecase: GetBridgeStatus,
private getBridgeStatus: GetBridgeStatus,
private environmentRepository: EnvironmentRepository,
private notificationTemplateRepository: NotificationTemplateRepository,
private controlValuesRepository: ControlValuesRepository,
Expand All @@ -49,21 +49,12 @@ export class BridgeController {
@Get('/status')
@UseGuards(UserAuthGuard)
async health(@UserSession() user: UserSessionData) {
const environment = await this.environmentRepository.findOne({ _id: user.environmentId });
if (!environment?.echo?.url) {
throw new BadRequestException('Bridge URL not found');
}

const result = await this.validateBridgeUrlUsecase.execute(
const result = await this.getBridgeStatus.execute(
GetBridgeStatusCommand.create({
bridgeUrl: environment.echo.url,
environmentId: user.environmentId,
})
);

if (result.status !== 'ok') {
throw new Error('Bridge URL is not accessible');
}

return result;
}

Expand All @@ -79,10 +70,8 @@ export class BridgeController {
PreviewStepCommand.create({
workflowId,
stepId,
inputs: data.controls || data.inputs,
controls: data.controls || data.inputs,
data: data.payload,
bridgeUrl: data.bridgeUrl,
payload: data.payload,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
Expand Down Expand Up @@ -214,17 +203,22 @@ export class BridgeController {

@Post('/validate')
@ExternalApiAccessible()
async validateBridgeUrl(@Body() body: ValidateBridgeUrlRequestDto): Promise<ValidateBridgeUrlResponseDto> {
@UseGuards(UserAuthGuard)
async validateBridgeUrl(
@UserSession() user: UserSessionData,
@Body() body: ValidateBridgeUrlRequestDto
): Promise<ValidateBridgeUrlResponseDto> {
try {
const result = await this.validateBridgeUrlUsecase.execute(
const result = await this.getBridgeStatus.execute(
GetBridgeStatusCommand.create({
bridgeUrl: body.bridgeUrl,
environmentId: user.environmentId,
statelessBridgeUrl: body.bridgeUrl,
})
);

return { isValid: result.status === 'ok' };
} catch (err: any) {
return { isValid: false };
return { isValid: false, error: err.message };
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class ValidateBridgeUrlResponseDto {
@ApiProperty()
isValid: boolean;

@ApiPropertyOptional()
error?: string;
}
5 changes: 0 additions & 5 deletions apps/api/src/app/bridge/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,3 @@ export interface IWorkflowDefineStep {

code: string;
}

export enum BridgeErrorCodeEnum {
BRIDGE_UNEXPECTED_RESPONSE = 'BRIDGE_UNEXPECTED_RESPONSE',
BRIDGE_ENDPOINT_NOT_FOUND = 'BRIDGE_ENDPOINT_NOT_FOUND',
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { IsUrl } from 'class-validator';
import { BaseCommand } from '@novu/application-generic';
import { EnvironmentLevelCommand } from '@novu/application-generic';

export class GetBridgeStatusCommand extends BaseCommand {
@IsUrl({
require_protocol: true,
require_tld: false,
})
bridgeUrl: string;
export class GetBridgeStatusCommand extends EnvironmentLevelCommand {
statelessBridgeUrl?: string;
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,35 @@
import { BadRequestException, Logger, Injectable } from '@nestjs/common';
import axios from 'axios';
import { HealthCheck, GetActionEnum, HttpQueryKeysEnum } from '@novu/framework';
import { Logger, Injectable } from '@nestjs/common';
import { HealthCheck, GetActionEnum } from '@novu/framework';
import { ExecuteBridgeRequest, ExecuteBridgeRequestCommand, ExecuteBridgeRequestDto } from '@novu/application-generic';
import { WorkflowOriginEnum } from '@novu/shared';
import { GetBridgeStatusCommand } from './get-bridge-status.command';

const axiosInstance = axios.create();

export const LOG_CONTEXT = 'GetBridgeStatusUsecase';

@Injectable()
export class GetBridgeStatus {
constructor(private executeBridgeRequest: ExecuteBridgeRequest) {}

async execute(command: GetBridgeStatusCommand): Promise<HealthCheck> {
try {
const bridgeActionUrl = new URL(command.bridgeUrl);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.ACTION, GetActionEnum.HEALTH_CHECK);

const response = await axiosInstance.get<HealthCheck>(bridgeActionUrl.toString(), {
headers: {
'Bypass-Tunnel-Reminder': 'true',
'content-type': 'application/json',
},
});
const response = (await this.executeBridgeRequest.execute(
ExecuteBridgeRequestCommand.create({
environmentId: command.environmentId,
action: GetActionEnum.HEALTH_CHECK,
workflowOrigin: WorkflowOriginEnum.EXTERNAL,
statelessBridgeUrl: command.statelessBridgeUrl,
retriesLimit: 1,
})
)) as ExecuteBridgeRequestDto<GetActionEnum.HEALTH_CHECK>;

return response.data;
return response;
} catch (err: any) {
Logger.error(
`Failed to verify Bridge endpoint ${command.bridgeUrl} with error: ${(err as Error).message || err}`,
`Failed to verify Bridge endpoint for environment ${command.environmentId} with error: ${(err as Error).message || err}`,
(err as Error).stack,
LOG_CONTEXT
);
throw new BadRequestException(`Bridge is not accessible. ${err.message}`);
throw err;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { EnvironmentWithUserCommand } from '@novu/application-generic';
export class PreviewStepCommand extends EnvironmentWithUserCommand {
workflowId: string;
stepId: string;
inputs: any;
controls: any;
data: any;
bridgeUrl?: string;
controls: Record<string, unknown>;
payload: Record<string, unknown>;
}
127 changes: 33 additions & 94 deletions apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,53 @@
import { createHmac } from 'crypto';
import axios from 'axios';
import { BadRequestException, Injectable } from '@nestjs/common';
import { PostActionEnum, HttpQueryKeysEnum } from '@novu/framework';

import { EnvironmentRepository } from '@novu/dal';
import { decryptApiKey } from '@novu/application-generic';
import { Injectable } from '@nestjs/common';
import { PostActionEnum, HttpQueryKeysEnum, Event, JobStatusEnum, ExecuteOutput } from '@novu/framework';
import { ExecuteBridgeRequest, ExecuteBridgeRequestCommand } from '@novu/application-generic';
import { WorkflowOriginEnum } from '@novu/shared';

import { PreviewStepCommand } from './preview-step.command';
import { BridgeErrorCodeEnum } from '../../shared';

@Injectable()
export class PreviewStep {
constructor(private environmentRepository: EnvironmentRepository) {}

async execute(command: PreviewStepCommand) {
const environment = await this.environmentRepository.findOne({ _id: command.environmentId });
const bridgeUrl = command.bridgeUrl || environment?.echo.url;
if (!bridgeUrl) {
throw new BadRequestException('Bridge URL not found');
}

const axiosInstance = axios.create();
try {
const payload = this.mapPayload(command);
const novuSignatureHeader = this.buildNovuSignature(environment, payload);
const bridgeActionUrl = new URL(bridgeUrl);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.ACTION, PostActionEnum.PREVIEW);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.WORKFLOW_ID, command.workflowId);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.STEP_ID, command.stepId);

const response = await axiosInstance.post(bridgeActionUrl.toString(), payload, {
headers: {
'content-type': 'application/json',
'x-novu-signature': novuSignatureHeader,
'novu-signature': novuSignatureHeader,
constructor(private executeBridgeRequest: ExecuteBridgeRequest) {}

async execute(command: PreviewStepCommand): Promise<ExecuteOutput> {
const event = this.mapEvent(command);

const response = (await this.executeBridgeRequest.execute(
ExecuteBridgeRequestCommand.create({
environmentId: command.environmentId,
action: PostActionEnum.PREVIEW,
event,
searchParams: {
[HttpQueryKeysEnum.WORKFLOW_ID]: command.workflowId,
[HttpQueryKeysEnum.STEP_ID]: command.stepId,
},
});

if (!response.data?.outputs || !response.data?.metadata) {
throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE,
message: JSON.stringify(response.data),
});
}

return response.data;
} catch (e: any) {
if (e?.response?.status === 404) {
throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_ENDPOINT_NOT_FOUND,
message: `Bridge Endpoint Was not found or not accessible. Endpoint: ${bridgeUrl}`,
});
}

if (e?.response?.status === 405) {
throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_ENDPOINT_NOT_FOUND,
message: `Bridge Endpoint is not properly configured. : ${bridgeUrl}`,
});
}

if (e.code === BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE) {
throw e;
}
// TODO: pass the origin from the command
workflowOrigin: WorkflowOriginEnum.EXTERNAL,
retriesLimit: 1,
})
)) as ExecuteOutput;

// todo add status indication - check if e?.response?.status === 400 here
if (e?.response?.data) {
throw new BadRequestException(e.response.data);
}

throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE,
message: `Un-expected Bridge response: ${e.message}`,
});
}
return response;
}

private mapPayload(command: PreviewStepCommand) {
private mapEvent(command: PreviewStepCommand): Omit<Event, 'workflowId' | 'stepId' | 'action' | 'source'> {
const payload = {
inputs: command.controls || command.inputs || {},
controls: command.controls || command.inputs || {},
data: command.data || {},
/** @deprecated - use controls instead */
inputs: command.controls || {},
controls: command.controls || {},
/** @deprecated - use payload instead */
data: command.payload || {},
payload: command.payload || {},
state: [
{
stepId: 'trigger',
outputs: command.data || {},
outputs: command.payload || {},
state: { status: JobStatusEnum.COMPLETED },
},
],
subscriber: {},
};

return payload;
}

private buildNovuSignature(
environment,
payload: { data: any; inputs: any; controls: any; state: { outputs: any; stepId: string }[] }
) {
const timestamp = Date.now();
const xNovuSignature = `t=${timestamp},v1=${this.createHmacByApiKey(
environment.apiKeys[0].key,
timestamp,
payload
)}`;

return xNovuSignature;
}

private createHmacByApiKey(secret: string, timestamp: number, payload) {
const publicKey = `${timestamp}.${JSON.stringify(payload)}`;

return createHmac('sha256', decryptApiKey(secret)).update(publicKey).digest('hex');
}
}
5 changes: 3 additions & 2 deletions apps/api/src/app/bridge/usecases/sync/sync.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ export class Sync {
let discover: DiscoverOutput | undefined;
try {
discover = (await this.executeBridgeRequest.execute({
bridgeUrl: command.bridgeUrl,
apiKey: environment.apiKeys[0].key,
statelessBridgeUrl: command.bridgeUrl,
environmentId: command.environmentId,
action: GetActionEnum.DISCOVER,
retriesLimit: 1,
workflowOrigin: WorkflowOriginEnum.EXTERNAL,
})) as DiscoverOutput;
} catch (error: any) {
throw new BadRequestException(`Bridge URL is not valid. ${error.message}`);
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/app/environments/environments.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { forwardRef, Module } from '@nestjs/common';

import { SharedModule } from '../shared/shared.module';
import { USE_CASES } from './usecases';
import { EnvironmentsController } from './environments.controller';
import { NotificationGroupsModule } from '../notification-groups/notification-groups.module';
import { AuthModule } from '../auth/auth.module';
import { LayoutsModule } from '../layouts/layouts.module';
import { NovuBridgeModule } from './novu-bridge.module';

@Module({
imports: [SharedModule, NotificationGroupsModule, forwardRef(() => AuthModule), forwardRef(() => LayoutsModule)],
imports: [
SharedModule,
NotificationGroupsModule,
forwardRef(() => AuthModule),
forwardRef(() => LayoutsModule),
NovuBridgeModule,
],
controllers: [EnvironmentsController],
providers: [...USE_CASES],
exports: [...USE_CASES],
Expand Down
Loading

0 comments on commit d454d17

Please sign in to comment.