Skip to content

Commit

Permalink
Merge pull request #26 from bcgov/dev
Browse files Browse the repository at this point in the history
Releasing dev to production
  • Loading branch information
timwekkenbc authored Jul 30, 2024
2 parents 4c899af + 90cfcbb commit 417fdb3
Show file tree
Hide file tree
Showing 29 changed files with 855 additions and 422 deletions.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BRMS API/Backend

This project is the API/Backend for the SDPR Business Rules Engine (BRE) and Business Rules Engine Management System (BRMS). It will act as a middle layer between GoRules, CHEFS, and the [frontend](https://github.com/bcgov/brms-simulator-frontend) or any other integration.
This project is the API/Backend for the SDPR Business Rules Engine (BRE) and Business Rules Engine Management System (BRMS). It will act primarly as the backend for the [frontend simulator](https://github.com/bcgov/brms-simulator-frontend).

## Local Development Setup

Expand All @@ -14,7 +14,6 @@ Before running your application locally, you'll need some environment variables.

- MONGODB_URL: The URL for connecting to the MongoDB instance you created in the previous step. Set it to something like mongodb://localhost/nest.
- FRONTEND_URI: The URI for the frontend application. Set it to http://localhost:8080.
- CHEFS_API_URL: The URL for the Chefs API. Set it to https://submit.digital.gov.bc.ca/app/api/v1.

### Including Rules from the Rules Repository

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('DecisionsController', () => {
{
provide: DecisionsService,
useValue: {
runDecision: jest.fn(),
runDecisionByContent: jest.fn(),
runDecisionByFile: jest.fn(),
},
},
Expand All @@ -26,23 +26,23 @@ describe('DecisionsController', () => {
service = module.get<DecisionsService>(DecisionsService);
});

it('should call runDecision with correct parameters', async () => {
it('should call runDecisionByContent with correct parameters', async () => {
const dto: EvaluateDecisionWithContentDto = {
content: { value: 'content' },
ruleContent: { nodes: [], edges: [] },
context: { value: 'context' },
trace: false,
};
await controller.evaluateDecisionByContent(dto);
expect(service.runDecision).toHaveBeenCalledWith(dto.content, dto.context, { trace: dto.trace });
expect(service.runDecisionByContent).toHaveBeenCalledWith(dto.ruleContent, dto.context, { trace: dto.trace });
});

it('should throw an error when runDecision fails', async () => {
const dto: EvaluateDecisionWithContentDto = {
content: { value: 'content' },
ruleContent: { nodes: [], edges: [] },
context: { value: 'context' },
trace: false,
};
(service.runDecision as jest.Mock).mockRejectedValue(new Error('Error'));
(service.runDecisionByContent as jest.Mock).mockRejectedValue(new Error('Error'));
await expect(controller.evaluateDecisionByContent(dto)).rejects.toThrow(HttpException);
});

Expand Down
4 changes: 2 additions & 2 deletions src/api/decisions/decisions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ export class DecisionsController {
constructor(private readonly decisionsService: DecisionsService) {}

@Post('/evaluate')
async evaluateDecisionByContent(@Body() { content, context, trace }: EvaluateDecisionWithContentDto) {
async evaluateDecisionByContent(@Body() { ruleContent, context, trace }: EvaluateDecisionWithContentDto) {
try {
return await this.decisionsService.runDecision(content, context, { trace });
return await this.decisionsService.runDecisionByContent(ruleContent, context, { trace });
} catch (error) {
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
Expand Down
39 changes: 32 additions & 7 deletions src/api/decisions/decisions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,45 @@ describe('DecisionsService', () => {

describe('runDecision', () => {
it('should run a decision', async () => {
const content = {};
const ruleFileName = 'rule';
const ruleContent = { nodes: [], edges: [] };
const context = {};
const options: ZenEvaluateOptions = { trace: false };
await service.runDecision(content, context, options);
expect(mockEngine.createDecision).toHaveBeenCalledWith(content);
await service.runDecision(ruleContent, ruleFileName, context, options);
expect(mockEngine.createDecision).toHaveBeenCalledWith(ruleContent);
expect(mockDecision.evaluate).toHaveBeenCalledWith(context, options);
});

it('should throw an error if the decision fails', async () => {
const content = {};
const ruleFileName = 'rule';
const ruleContent = { nodes: [], edges: [] };
const context = {};
const options: ZenEvaluateOptions = { trace: false };
(mockDecision.evaluate as jest.Mock).mockRejectedValue(new Error('Error'));
await expect(service.runDecision(content, context, options)).rejects.toThrow('Failed to run decision: Error');
await expect(service.runDecision(ruleContent, ruleFileName, context, options)).rejects.toThrow(
'Failed to run decision: Error',
);
});
});

describe('runDecisionByContent', () => {
it('should run a decision by content', async () => {
const ruleContent = { nodes: [], edges: [] };
const context = {};
const options: ZenEvaluateOptions = { trace: false };
await service.runDecisionByContent(ruleContent, context, options);
expect(mockEngine.createDecision).toHaveBeenCalledWith(ruleContent);
expect(mockDecision.evaluate).toHaveBeenCalledWith(context, options);
});

it('should throw an error if the decision fails', async () => {
const ruleContent = { nodes: [], edges: [] };
const context = {};
const options: ZenEvaluateOptions = { trace: false };
(mockDecision.evaluate as jest.Mock).mockRejectedValue(new Error('Error'));
await expect(service.runDecisionByContent(ruleContent, context, options)).rejects.toThrow(
'Failed to run decision: Error',
);
});
});

Expand All @@ -54,8 +79,8 @@ describe('DecisionsService', () => {
const ruleFileName = 'rule';
const context = {};
const options: ZenEvaluateOptions = { trace: false };
const content = JSON.stringify({ rule: 'rule' });
(readFileSafely as jest.Mock).mockResolvedValue(content);
const content = { rule: 'rule' };
(readFileSafely as jest.Mock).mockResolvedValue(Buffer.from(JSON.stringify(content)));
await service.runDecisionByFile(ruleFileName, context, options);
expect(readFileSafely).toHaveBeenCalledWith(service.rulesDirectory, ruleFileName);
expect(mockEngine.createDecision).toHaveBeenCalledWith(content);
Expand Down
19 changes: 15 additions & 4 deletions src/api/decisions/decisions.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { ZenEngine, ZenEvaluateOptions } from '@gorules/zen-engine';
import { ConfigService } from '@nestjs/config';
import { RuleContent } from '../ruleMapping/ruleMapping.interface';
import { readFileSafely, FileNotFoundError } from '../../utils/readFile';

@Injectable()
Expand All @@ -14,9 +15,9 @@ export class DecisionsService {
this.engine = new ZenEngine({ loader });
}

async runDecision(content: object, context: object, options: ZenEvaluateOptions) {
async runDecisionByContent(ruleContent: RuleContent, context: object, options: ZenEvaluateOptions) {
try {
const decision = this.engine.createDecision(content);
const decision = this.engine.createDecision(ruleContent);
return await decision.evaluate(context, options);
} catch (error) {
console.error(error.message);
Expand All @@ -26,8 +27,9 @@ export class DecisionsService {

async runDecisionByFile(ruleFileName: string, context: object, options: ZenEvaluateOptions) {
try {
const content = await readFileSafely(this.rulesDirectory, ruleFileName);
return this.runDecision(content, context, options);
const decisionFile = await readFileSafely(this.rulesDirectory, ruleFileName);
const content: RuleContent = JSON.parse(decisionFile.toString()); // Convert file buffer to rulecontent
return this.runDecisionByContent(content, context, options);
} catch (error) {
if (error instanceof FileNotFoundError) {
throw new HttpException('Rule not found', HttpStatus.NOT_FOUND);
Expand All @@ -36,4 +38,13 @@ export class DecisionsService {
}
}
}

/** Run the decision by content if it exists, otherwise run by filename */
async runDecision(ruleContent: RuleContent, ruleFileName: string, context: object, options: ZenEvaluateOptions) {
if (ruleContent) {
return await this.runDecisionByContent(ruleContent, context, options);
} else {
return await this.runDecisionByFile(ruleFileName, context, options);
}
}
}
3 changes: 2 additions & 1 deletion src/api/decisions/dto/evaluate-decision.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IsBoolean, IsObject } from 'class-validator';
import { RuleContent } from 'src/api/ruleMapping/ruleMapping.interface';

export class EvaluateDecisionDto {
@IsObject()
Expand All @@ -10,5 +11,5 @@ export class EvaluateDecisionDto {

export class EvaluateDecisionWithContentDto extends EvaluateDecisionDto {
@IsObject()
content: object;
ruleContent: RuleContent;
}
4 changes: 2 additions & 2 deletions src/api/ruleData/ruleData.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class RuleDataController {
try {
return await this.ruleDataService.getAllRuleData();
} catch (error) {
throw new HttpException('Error getting submissions', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException('Error getting list of rule data', HttpStatus.INTERNAL_SERVER_ERROR);
}
}

Expand All @@ -20,7 +20,7 @@ export class RuleDataController {
try {
return await this.ruleDataService.getRuleData(ruleId);
} catch (error) {
throw new HttpException('Error getting submissions', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException('Error getting rule data', HttpStatus.INTERNAL_SERVER_ERROR);
}
}

Expand Down
6 changes: 0 additions & 6 deletions src/api/ruleData/ruleData.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,6 @@ export class RuleData {

@Prop({ required: true, description: 'The filename of the JSON file containing the rule' })
goRulesJSONFilename: string;

@Prop({ description: 'The ID of the form in Chefs that corresponds to this rule' })
chefsFormId: string;

@Prop({ description: 'The API key of the CHEFS form - needed to access submissions' })
chefsFormAPIKey: string;
}

export const RuleDataSchema = SchemaFactory.createForClass(RuleData);
2 changes: 0 additions & 2 deletions src/api/ruleData/ruleData.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ export const mockRuleData = {
_id: 'testId',
title: 'Title',
goRulesJSONFilename: 'filename.json',
chefsFormId: 'formId',
chefsFormAPIKey: '12345',
};

export const mockServiceProviders = [
Expand Down
8 changes: 0 additions & 8 deletions src/api/ruleData/ruleData.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,6 @@ export class RuleDataService {
}
}

async getFormAPIKeyForFormId(chefsFormId: string): Promise<string> {
const ruleData = await this.ruleDataModel.findOne({ chefsFormId }).exec();
if (!ruleData) {
throw new Error(`Rule data not found for CHEFS form id: ${chefsFormId}`);
}
return ruleData.chefsFormAPIKey;
}

/**
* Add rules to the db that exist in the repo, but not yet the db
*/
Expand Down
42 changes: 9 additions & 33 deletions src/api/ruleMapping/ruleMapping.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ describe('RuleMappingController', () => {
{
provide: RuleMappingService,
useValue: {
ruleSchemaFile: jest.fn(),
ruleSchema: jest.fn(),
evaluateRuleSchema: jest.fn(),
},
Expand All @@ -37,67 +36,44 @@ describe('RuleMappingController', () => {
service = module.get<RuleMappingService>(RuleMappingService);
});

describe('getRuleFile', () => {
it('should return the rule file with the correct headers', async () => {
describe('getRuleSchema', () => {
it('should return the rule schema with the correct headers', async () => {
const ruleFileName = 'test-rule.json';
const filePath = `${ruleFileName}`;
const rulemap = { inputs: [], outputs: [] };
jest.spyOn(service, 'ruleSchemaFile').mockResolvedValue(rulemap);
const ruleContent = { nodes: [], edges: [] };
const rulemap = { inputs: [], outputs: [], resultOutputs: [] };
jest.spyOn(service, 'ruleSchema').mockResolvedValue(rulemap);

const mockResponse = {
setHeader: jest.fn(),
send: jest.fn(),
} as unknown as Response;

await controller.getRuleFile(ruleFileName, mockResponse);
await controller.getRuleSchema(ruleFileName, ruleContent, mockResponse);

expect(service.ruleSchemaFile).toHaveBeenCalledWith(filePath);
expect(service.ruleSchema).toHaveBeenCalledWith(ruleContent);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json');
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
`attachment; filename=${ruleFileName}`,
);
expect(mockResponse.send).toHaveBeenCalledWith(rulemap);
});

it('should handle errors properly', async () => {
const ruleFileName = 'test-rule.json';
const error = new Error('File not found');
jest.spyOn(service, 'ruleSchemaFile').mockRejectedValue(error);

const mockResponse = {
setHeader: jest.fn(),
send: jest.fn(),
} as unknown as Response;

await expect(controller.getRuleFile(ruleFileName, mockResponse)).rejects.toThrow(
new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR),
);
});
});

describe('evaluateRuleMap', () => {
it('should return the evaluated rule map', async () => {
const nodes = [{ id: '1', type: 'someType', content: { inputs: [], outputs: [] } }];
const edges = [{ id: '2', type: 'someType', targetId: '1', sourceId: '1' }];
const result = { inputs: [], outputs: [], resultOutputs: [] };
jest.spyOn(service, 'ruleSchema').mockReturnValue(result);
jest.spyOn(service, 'ruleSchema').mockResolvedValue(result);

const dto: EvaluateRuleMappingDto = { nodes, edges };
const response = await controller.evaluateRuleMap(dto);

expect(service.ruleSchema).toHaveBeenCalledWith(nodes, edges);
expect(service.ruleSchema).toHaveBeenCalledWith({ nodes, edges });
expect(response).toEqual({ result });
});

it('should handle invalid request data', async () => {
const dto = { nodes: 'invalid' } as unknown as EvaluateRuleMappingDto;

await expect(controller.evaluateRuleMap(dto)).rejects.toThrow(
new HttpException('Invalid request data', HttpStatus.BAD_REQUEST),
);
});

it('should handle errors properly', async () => {
const nodes = [{ id: '1', type: 'someType', content: { inputs: [], outputs: [] } }];
const edges = [{ id: '2', type: 'someType', targetId: '1', sourceId: '1' }];
Expand Down
29 changes: 17 additions & 12 deletions src/api/ruleMapping/ruleMapping.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Controller, Query, Res, Post, Body, HttpException, HttpStatus } from '@nestjs/common';
import { RuleMappingService } from './ruleMapping.service';
import { Controller, Res, Post, Body, HttpException, HttpStatus } from '@nestjs/common';
import { RuleMappingService, InvalidRuleContent } from './ruleMapping.service';
import { Response } from 'express';
import { EvaluateRuleRunSchemaDto, EvaluateRuleMappingDto } from './dto/evaluate-rulemapping.dto';

Expand All @@ -9,30 +9,35 @@ export class RuleMappingController {

// Map a rule file to its unique inputs, and all outputs
@Post('/')
async getRuleFile(@Query('goRulesJSONFilename') goRulesJSONFilename: string, @Res() res: Response) {
const rulemap = await this.ruleMappingService.ruleSchemaFile(goRulesJSONFilename);
async getRuleSchema(
@Body('goRulesJSONFilename') goRulesJSONFilename: string,
@Body('ruleContent') ruleContent: EvaluateRuleMappingDto,
@Res() res: Response,
) {
const rulemap = await this.ruleMappingService.ruleSchema(ruleContent);

try {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=${goRulesJSONFilename}`);
res.send(rulemap);
} catch (error) {
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
if (error instanceof InvalidRuleContent) {
throw new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST);
} else {
throw new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}

// Map a rule to its unique inputs, and all outputs
@Post('/evaluate')
async evaluateRuleMap(@Body() { nodes, edges }: EvaluateRuleMappingDto) {
async evaluateRuleMap(@Body() ruleContent: EvaluateRuleMappingDto) {
try {
if (!nodes || !Array.isArray(nodes)) {
throw new HttpException('Invalid request data', HttpStatus.BAD_REQUEST);
}
const result = this.ruleMappingService.ruleSchema(nodes, edges);
const result = await this.ruleMappingService.ruleSchema(ruleContent);
return { result };
} catch (error) {
if (error instanceof HttpException && error.getStatus() === HttpStatus.BAD_REQUEST) {
throw error;
if (error instanceof InvalidRuleContent) {
throw new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST);
} else {
throw new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR);
}
Expand Down
Loading

0 comments on commit 417fdb3

Please sign in to comment.