diff --git a/src/PatientTestsApi/.eslintrc.json b/src/PatientTestsApi/.eslintrc.json index 90a83d4..57d7c5a 100644 --- a/src/PatientTestsApi/.eslintrc.json +++ b/src/PatientTestsApi/.eslintrc.json @@ -1,25 +1,51 @@ { "env": { - "browser": false, "commonjs": true, - "es6": true + "node": true, + "es2017": true, + "mocha": true }, "extends": [ - "standard" - ], + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ], "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" }, "parser": "@typescript-eslint/parser", "parserOptions": { - "ecmaVersion": 2018 + "ecmaVersion": 2019, + "project": "./tsconfig.json" }, "plugins": [ "@typescript-eslint" ], + "ignorePatterns": ["/Services/app-insights"], "rules": { "semi": "off", - "quotes": "off" - } + "@typescript-eslint/semi": ["error"], + "quotes": "off", + "@typescript-eslint/quotes": ["error", "double"], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error" + ], + "@typescript-eslint/interface-name-prefix": ["error", { "prefixWithI": "always" } ], + "@typescript-eslint/no-non-null-assertion": "off" + }, + "overrides": [ + { + "files": ["*.spec.ts"], + "rules": { + "no-unused-expressions": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/no-use-before-define": "off" + } + } + ] } \ No newline at end of file diff --git a/src/PatientTestsApi/.mocharc.json b/src/PatientTestsApi/.mocharc.json new file mode 100644 index 0000000..57a8375 --- /dev/null +++ b/src/PatientTestsApi/.mocharc.json @@ -0,0 +1,6 @@ +{ + "require": [ + "source-map-support/register" + ], + "recursive": true +} diff --git a/src/PatientTestsApi/.vscode/settings.json b/src/PatientTestsApi/.vscode/settings.json index 648dd77..2c7d3d2 100644 --- a/src/PatientTestsApi/.vscode/settings.json +++ b/src/PatientTestsApi/.vscode/settings.json @@ -6,24 +6,29 @@ "debug.internalConsoleOptions": "neverOpen", "azureFunctions.preDeployTask": "npm prune", "workbench.colorCustomizations": { - "activityBar.activeBackground": "#3399ff", - "activityBar.activeBorder": "#bf0060", - "activityBar.background": "#3399ff", - "activityBar.foreground": "#15202b", - "activityBar.inactiveForeground": "#15202b99", - "activityBarBadge.background": "#bf0060", - "activityBarBadge.foreground": "#e7e7e7", - "statusBar.background": "#007fff", - "statusBar.border": "#007fff", - "statusBar.foreground": "#e7e7e7", - "statusBarItem.hoverBackground": "#3399ff", - "titleBar.activeBackground": "#007fff", - "titleBar.activeForeground": "#e7e7e7", - "titleBar.border": "#007fff", - "titleBar.inactiveBackground": "#007fff99", - "titleBar.inactiveForeground": "#e7e7e799" + "activityBar.activeBackground": "#3399ff", + "activityBar.activeBorder": "#bf0060", + "activityBar.background": "#3399ff", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#bf0060", + "activityBarBadge.foreground": "#e7e7e7", + "statusBar.background": "#007fff", + "statusBar.border": "#007fff", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#3399ff", + "titleBar.activeBackground": "#007fff", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.border": "#007fff", + "titleBar.inactiveBackground": "#007fff99", + "titleBar.inactiveForeground": "#e7e7e799" }, "peacock.color": "#007fff", "eslint.lintTask.enable": true, - "eslint.lintTask.options": ". --ext .ts" + "eslint.lintTask.options": ". --ext .ts", + "mochaExplorer.files": "dist/Test/**/*.spec.js", + "mochaExplorer.require": "source-map-support/register", + "typescript.validate.enable": true, + "editor.tabSize": 2, + "editor.detectIndentation": false, } \ No newline at end of file diff --git a/src/PatientTestsApi/Controllers/ControllerFactory.ts b/src/PatientTestsApi/Controllers/ControllerFactory.ts new file mode 100644 index 0000000..8e4eaf1 --- /dev/null +++ b/src/PatientTestsApi/Controllers/ControllerFactory.ts @@ -0,0 +1,45 @@ +import { PatientController } from "./PatientController"; +import { Db, MongoClient } from "mongodb"; +import { ISettings } from "../Models/ISettings"; +import { ICollection } from "../Services/ICollection"; +import { PatientDataService } from "../Services/PatientDataService"; +import { EnvironmentSettings } from "../Models/EnvironmentSettings"; +import { RetryCollection } from "../Services/RetryCollection"; +import { LoggingCollection } from "../Services/LoggingCollection"; +import { AppInsightsService } from "../Services/app-insights/app-insights-service"; +import { TraceContext, HttpRequest } from "@azure/functions"; + +export class ControllerFactory { + + private static mongoDb: Promise; + private readonly settings: ISettings; + + constructor () { + this.settings = new EnvironmentSettings(); + } + + public async createPatientController(functionContext: TraceContext, request: HttpRequest): Promise { + const appInsightsService = new AppInsightsService(functionContext, request); + const collection = await this.CreateCollection(this.settings.patientCollection, appInsightsService); + const dataService: PatientDataService = new PatientDataService(collection); + return new PatientController(dataService); + } + + private async CreateCollection(collectionName: string, appInsightsService: AppInsightsService): Promise { + if (ControllerFactory.mongoDb == null) { + ControllerFactory.mongoDb = this.createMongoDb(); + } + const mongoCollection = (await ControllerFactory.mongoDb).collection(collectionName); + + const retryCollection = new RetryCollection(mongoCollection); + return new LoggingCollection(retryCollection, appInsightsService, collectionName, this.settings.patientTestDatabase); + } + + private async createMongoDb(): Promise { + // connect and select database + const mongoClient = await MongoClient.connect(this.settings.mongoConnectionString, + { useUnifiedTopology: true, useNewUrlParser: true, tlsAllowInvalidCertificates: this.settings.allowSelfSignedMongoCert }); + + return mongoClient.db(this.settings.patientTestDatabase); + } +} \ No newline at end of file diff --git a/src/PatientTestsApi/Controllers/PatientController.ts b/src/PatientTestsApi/Controllers/PatientController.ts new file mode 100644 index 0000000..dced8d6 --- /dev/null +++ b/src/PatientTestsApi/Controllers/PatientController.ts @@ -0,0 +1,39 @@ +import { HttpRequest } from "@azure/functions"; +import { IPatient, PatientSchema } from "../Models/IPatient"; +import { IResponse } from "../Models/IResponse"; +import { IPatientDataService } from "../Services/IPatientDataService"; +import { CreatedResponse} from "../Models/CreatedResponse"; +import { BadRequestResponse } from "../Models/BadRequestResponse"; +import { v4 as uuidv4 } from "uuid"; + +export class PatientController { + public constructor( + private readonly patientDataService: IPatientDataService + ) {} + + public async createPatient(req: HttpRequest): Promise { + const validationResult = PatientSchema.validate(req.body); + if (validationResult.error != null) { + return new BadRequestResponse(validationResult.error.message); + } + + if (req.body.id != null) { + return new BadRequestResponse("Id unexpected."); + } + if (req.body.lastUpdated != null) { + return new BadRequestResponse("lastUpdated unexpected."); + } + + req.body.lastUpdated = new Date(); + + + + const patient = req.body as IPatient || {}; + patient.id = uuidv4(); + await this.patientDataService.insertPatient(patient); + + return new CreatedResponse(patient); + } +} + + diff --git a/src/PatientTestsApi/CreatePatient/function.json b/src/PatientTestsApi/CreatePatient/function.json new file mode 100644 index 0000000..3fa229b --- /dev/null +++ b/src/PatientTestsApi/CreatePatient/function.json @@ -0,0 +1,20 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "post" + ], + "route": "patient" + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ], + "scriptFile": "../dist/CreatePatient/index.js" +} diff --git a/src/PatientTestsApi/CreatePatient/index.ts b/src/PatientTestsApi/CreatePatient/index.ts new file mode 100644 index 0000000..cd75525 --- /dev/null +++ b/src/PatientTestsApi/CreatePatient/index.ts @@ -0,0 +1,12 @@ +import { AzureFunction, Context, HttpRequest } from "@azure/functions"; +import { ControllerFactory } from "../Controllers/ControllerFactory"; + + +const controllerFactory = new ControllerFactory(); + +const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { + const controller = await controllerFactory.createPatientController(context.traceContext, req); + context.res = await controller.createPatient(req); +}; + +export default httpTrigger; \ No newline at end of file diff --git a/src/PatientTestsApi/Models/BadRequestResponse.ts b/src/PatientTestsApi/Models/BadRequestResponse.ts new file mode 100644 index 0000000..b8df147 --- /dev/null +++ b/src/PatientTestsApi/Models/BadRequestResponse.ts @@ -0,0 +1,10 @@ +import { IResponse } from "./IResponse"; +export class BadRequestResponse extends Error implements IResponse { + public constructor(message: string) { + super ("Bad request: " + message); + this.body = message; + } + body: string; + headers = { "Content-Type": "application/json" }; + status = 400; +} diff --git a/src/PatientTestsApi/Models/CreatedResponse.ts b/src/PatientTestsApi/Models/CreatedResponse.ts new file mode 100644 index 0000000..c082bcf --- /dev/null +++ b/src/PatientTestsApi/Models/CreatedResponse.ts @@ -0,0 +1,7 @@ +import { IResponse } from "./IResponse"; +export class CreatedResponse implements IResponse { + public constructor(public body: T) { + } + headers = { "Content-Type": "application/json" }; + status = 201; +} diff --git a/src/PatientTestsApi/Models/EnvironmentSettings.ts b/src/PatientTestsApi/Models/EnvironmentSettings.ts new file mode 100644 index 0000000..1a23657 --- /dev/null +++ b/src/PatientTestsApi/Models/EnvironmentSettings.ts @@ -0,0 +1,9 @@ +import { ISettings } from "./ISettings"; + +export class EnvironmentSettings implements ISettings { + public get patientCollection(): string { return "patients";} + public get patientTestDatabase(): string { return process.env.patient_tests_database || ""; } + public get mongoConnectionString(): string { return process.env.mongo_connection_string || "";} + public get allowSelfSignedMongoCert(): boolean { return JSON.parse(process.env.allow_self_signed_mongo_cert || "false");} + +} \ No newline at end of file diff --git a/src/PatientTestsApi/Models/Gender.ts b/src/PatientTestsApi/Models/Gender.ts new file mode 100644 index 0000000..825c830 --- /dev/null +++ b/src/PatientTestsApi/Models/Gender.ts @@ -0,0 +1,6 @@ +export enum Gender { + Male = "male", + Female = "female", + Other = "other", + Unknown = "unknown" +} \ No newline at end of file diff --git a/src/PatientTestsApi/Models/IHeaders.ts b/src/PatientTestsApi/Models/IHeaders.ts new file mode 100644 index 0000000..7e0c8cf --- /dev/null +++ b/src/PatientTestsApi/Models/IHeaders.ts @@ -0,0 +1,2 @@ +/** Interface for http request headers */ +export interface IHeaders { [key: string]: string } diff --git a/src/PatientTestsApi/Models/IPatient.ts b/src/PatientTestsApi/Models/IPatient.ts new file mode 100644 index 0000000..e604664 --- /dev/null +++ b/src/PatientTestsApi/Models/IPatient.ts @@ -0,0 +1,40 @@ +import { Gender } from "./Gender"; +import Joi from "@hapi/joi"; + +export interface IPatient { + [key: string]: unknown; + id?: string; + firstName?: string; + lastName: string; + fullName?: string; + gender: Gender; + dateOfBirth: string; + postCode: string; + insuranceNumber?: string; + preferredContactNumber?: string; + lastUpdated?: Date; +} + +const maxLengthNameField = 64; +const maxLengthFullNameField = 128; +const postcodeLength = 4; + +const dateRegexString = /^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1]))?)?$/; + +export const PatientSchema = Joi.object({ + id: Joi.string().guid().optional(), + firstName: Joi.string().max(maxLengthNameField).required(), + lastName: Joi.string().max(maxLengthNameField), + fullName: Joi.string().max(maxLengthFullNameField), + + gender: Joi.string() + .allow(Gender.Male, Gender.Female, Gender.Other, Gender.Unknown) + .only() + .required(), + + dateOfBirth: Joi.string().pattern(dateRegexString, "date").required(), + postCode: Joi.string().length(postcodeLength).required(), + insuranceNumber: Joi.string(), + preferredContactNumber: Joi.string(), + lastUpdated: Joi.date().optional(), +}); diff --git a/src/PatientTestsApi/Models/IResponse.ts b/src/PatientTestsApi/Models/IResponse.ts new file mode 100644 index 0000000..b0de3a7 --- /dev/null +++ b/src/PatientTestsApi/Models/IResponse.ts @@ -0,0 +1,7 @@ +export interface IResponse { + body: unknown; + headers: { + [key: string]: string; + }; + status: number; +} diff --git a/src/PatientTestsApi/Models/ISettings.ts b/src/PatientTestsApi/Models/ISettings.ts new file mode 100644 index 0000000..b23d19b --- /dev/null +++ b/src/PatientTestsApi/Models/ISettings.ts @@ -0,0 +1,6 @@ +export interface ISettings { + patientCollection: string; + patientTestDatabase: string; + mongoConnectionString: string; + allowSelfSignedMongoCert: boolean; +} \ No newline at end of file diff --git a/src/PatientTestsApi/Models/InsertFailedError.ts b/src/PatientTestsApi/Models/InsertFailedError.ts new file mode 100644 index 0000000..c4921dd --- /dev/null +++ b/src/PatientTestsApi/Models/InsertFailedError.ts @@ -0,0 +1,5 @@ +export class InsertFailedError extends Error { + constructor(){ + super("Error inserting data record."); + } +} \ No newline at end of file diff --git a/src/PatientTestsApi/Services/DefaultRetryPolicy.ts b/src/PatientTestsApi/Services/DefaultRetryPolicy.ts new file mode 100644 index 0000000..a66d3ee --- /dev/null +++ b/src/PatientTestsApi/Services/DefaultRetryPolicy.ts @@ -0,0 +1,19 @@ +import { IRetryPolicy } from "./IRetryPolicy"; +/** + * Default retry policy + */ +export class DefaultRetryPolicy implements IRetryPolicy { + private currentRetryCount = 0; + constructor(private readonly maxRetryCount: number = 10, public retryAfterMilliSec: number = 1000) { } + + /** + * Check if the operation should be retried + */ + public shouldRetry(): boolean { + if (this.currentRetryCount < this.maxRetryCount) { + this.currentRetryCount++; + return true; + } + return false; + } +} diff --git a/src/PatientTestsApi/Services/ICollection.ts b/src/PatientTestsApi/Services/ICollection.ts new file mode 100644 index 0000000..763e108 --- /dev/null +++ b/src/PatientTestsApi/Services/ICollection.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { CollectionInsertOneOptions, InsertOneWriteOpResult } from "mongodb"; + +export interface ICollection { + insertOne(docs: any, options?: CollectionInsertOneOptions): Promise>; +} + + diff --git a/src/PatientTestsApi/Services/IPatientDataService.ts b/src/PatientTestsApi/Services/IPatientDataService.ts new file mode 100644 index 0000000..e834546 --- /dev/null +++ b/src/PatientTestsApi/Services/IPatientDataService.ts @@ -0,0 +1,5 @@ +import { IPatient } from "../Models/IPatient"; + +export interface IPatientDataService { + insertPatient(patient: IPatient): Promise; +} diff --git a/src/PatientTestsApi/Services/IRetryPolicy.ts b/src/PatientTestsApi/Services/IRetryPolicy.ts new file mode 100644 index 0000000..5246e97 --- /dev/null +++ b/src/PatientTestsApi/Services/IRetryPolicy.ts @@ -0,0 +1,10 @@ +/** + * Interface for retry policy + */ +export interface IRetryPolicy { + retryAfterMilliSec: number; + /** + * Check if the operation should be retried + */ + shouldRetry(): boolean; +} diff --git a/src/PatientTestsApi/Services/LoggingCollection.ts b/src/PatientTestsApi/Services/LoggingCollection.ts new file mode 100644 index 0000000..618db27 --- /dev/null +++ b/src/PatientTestsApi/Services/LoggingCollection.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ICollection } from "./ICollection"; +import { CollectionInsertOneOptions, InsertOneWriteOpResult } from "mongodb"; +import { Timer } from "./app-insights/timer"; +import { IDependencyTelemetry, IAppInsightsService } from "./app-insights/app-insights-service"; + +export class LoggingCollection implements ICollection { + + constructor( + private readonly collection: ICollection, + private readonly appInsights: IAppInsightsService, + private readonly collectionName: string, + private readonly dbName: string) {} + + insertOne( + docs: any, + options?: CollectionInsertOneOptions | undefined + ): Promise> { + const mongoRequest = JSON.stringify({insertOne: {options}}); + return this.trackDependency(() => this.collection.insertOne(docs, options), mongoRequest); + } + + private async trackDependency(fn: () => Promise, query: string): Promise { + const timer = new Timer(); + + try { + const result = await fn(); + timer.stop(); + const dependency = this.createDependency(query, timer, 0, true); + this.appInsights.trackDependency(dependency); + return result; + + } catch (e) { + timer.stop(); + const dependency = this.createDependency(query, timer, JSON.stringify(e, Object.getOwnPropertyNames(e)), false); + this.appInsights.trackDependency(dependency); + throw e; + } + } + + private createDependency(query: string, timer: Timer, resultCode: number | string, success: boolean): IDependencyTelemetry { + return { data: query, + dependencyTypeName: "mongodb", + duration: timer.duration, + time: timer.endDate, + resultCode, + success, + name: this.dbName, + target: this.collectionName }; + } +} diff --git a/src/PatientTestsApi/Services/PatientDataService.ts b/src/PatientTestsApi/Services/PatientDataService.ts new file mode 100644 index 0000000..bcb08ae --- /dev/null +++ b/src/PatientTestsApi/Services/PatientDataService.ts @@ -0,0 +1,33 @@ +import { IPatientDataService } from "./IPatientDataService"; +import { IPatient } from "../Models/IPatient"; +import { ICollection } from "./ICollection"; +import { InsertFailedError } from "../Models/InsertFailedError"; + +export class PatientDataService implements IPatientDataService { + constructor (private readonly collection: ICollection) { + + } + + public async insertPatient (patient: IPatient): Promise { + const dbPatient: IDBPatient = { + ...patient, + _id: patient.id!, + _shardKey: patient.id! + }; + + const result = await this.collection.insertOne(dbPatient); + if (result.insertedCount > 0) { + return dbPatient._id; + } + else { + throw new InsertFailedError(); + } + } +} + +interface IDBPatient extends IPatient { + _id: string; + _shardKey: string; +} + + diff --git a/src/PatientTestsApi/Services/RetryCollection.ts b/src/PatientTestsApi/Services/RetryCollection.ts new file mode 100644 index 0000000..1827801 --- /dev/null +++ b/src/PatientTestsApi/Services/RetryCollection.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { CollectionInsertOneOptions, InsertOneWriteOpResult, MongoError } from "mongodb"; +import { ICollection } from "./ICollection"; +import { IRetryPolicy } from "./IRetryPolicy"; +import { DefaultRetryPolicy } from "./DefaultRetryPolicy"; + +export class RetryCollection implements ICollection { + constructor(private readonly collection: ICollection) {} + + insertOne( + docs: any, + options?: CollectionInsertOneOptions | undefined + ): Promise> { + return this.retryWrapper( + async (): Promise => this.collection.insertOne(docs, options) + ); + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Handle retries for MongoDB + */ + private async retryWrapper( + fn: () => Promise, + retryPolicy?: IRetryPolicy + ): Promise { + if (!retryPolicy) { + retryPolicy = new DefaultRetryPolicy(); + } + try { + const response = await fn(); + return response; + } catch (error) { + const mongoError = error as MongoError; + if (mongoError.code === 16500) { //16500 is the error code for tooManyRequests + const shouldRetry = retryPolicy.shouldRetry(); + if (shouldRetry) { + await this.delay(retryPolicy.retryAfterMilliSec); + return this.retryWrapper(fn, retryPolicy); + } else { + throw error; + } + } else { + throw error; + } + } + } +} + + diff --git a/src/PatientTestsApi/Services/app-insights/app-insights-factory.ts b/src/PatientTestsApi/Services/app-insights/app-insights-factory.ts new file mode 100644 index 0000000..316afcd --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/app-insights-factory.ts @@ -0,0 +1,18 @@ +import * as AppInsights from 'applicationinsights'; +import { TelemetryClient } from 'applicationinsights'; + +/** + * Factory class for creating an App Insights client instance + */ +export class AppInsightsFactory { + private static client: TelemetryClient; + /** + * Create and\or return a singleton instance of the App Insights client + */ + public create(): TelemetryClient { + if (!AppInsightsFactory.client) { + AppInsightsFactory.client = new AppInsights.TelemetryClient(); + } + return AppInsightsFactory.client; + } +} diff --git a/src/PatientTestsApi/Services/app-insights/app-insights-headers.ts b/src/PatientTestsApi/Services/app-insights/app-insights-headers.ts new file mode 100644 index 0000000..c2af699 --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/app-insights-headers.ts @@ -0,0 +1,53 @@ +/** Enumeration of Appp insights request headers for correlation */ +export enum AppInsightsHeaders { + + /** + * Request-Context header + */ + requestContextHeader = 'request-context', + + /** + * Source instrumentation header that is added by an application while making http + * requests and retrieved by the other application when processing incoming requests. + */ + requestContextSourceKey = 'appId', + + /** + * Target instrumentation header that is added to the response and retrieved by the + * calling application when processing incoming responses. + */ + requestContextTargetKey = 'appId', + + /** + * Request-Id header + */ + requestIdHeader = 'request-id', + + /** + * Legacy Header containing the id of the immidiate caller + */ + parentIdHeader = 'x-ms-request-id', + + /** + * Legacy Header containing the correlation id that kept the same for every telemetry item + * accross transactions + */ + rootIdHeader = 'x-ms-request-root-id', + + /** + * Correlation-Context header + * + * Not currently actively used, but the contents should be passed from incoming to outgoing requests + */ + correlationContextHeader = 'correlation-context', + + /** + * W3C distributed tracing protocol header + */ + traceparentHeader = 'traceparent', + + /** + * W3C distributed tracing protocol state header + */ + traceStateHeader = 'tracestate' +} diff --git a/src/PatientTestsApi/Services/app-insights/app-insights-service.ts b/src/PatientTestsApi/Services/app-insights/app-insights-service.ts new file mode 100644 index 0000000..0d77d91 --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/app-insights-service.ts @@ -0,0 +1,172 @@ +import { HttpRequest, TraceContext } from '@azure/functions'; +import { TelemetryClient } from 'applicationinsights'; +import { CorrelationContext } from 'applicationinsights/out/AutoCollection/CorrelationContextManager'; +import { DependencyTelemetry } from 'applicationinsights/out/Declarations/Contracts'; +import Traceparent from 'applicationinsights/out/Library/Traceparent'; +import { AppInsightsFactory } from './app-insights-factory'; +import { AppInsightsHeaders } from './app-insights-headers'; +import { CorrelationIdManager } from './correlation-id-manager'; +import { CustomPropertiesImpl, PrivateCustomProperties } from './custom-properties-impl'; +import { HttpRequestParser } from './http-request-parser'; +import { IHeaders } from '../../Models/IHeaders'; + +// tslint:disable-next-line: completed-docs +export interface IDependencyTelemetry { + data: string; + dependencyTypeName: string; + duration: number; + time: Date; + resultCode: string | number; + success: boolean; + name: string; + target: string; +} + +export interface IAppInsightsService { + trackDependency({ data, dependencyTypeName, duration, time, resultCode, success, name, target }: IDependencyTelemetry): void; + getHeadersForRequest(): IHeaders; +} + +/** + * Service wrapper for Azure App Insights + */ +export class AppInsightsService implements IAppInsightsService { + + private readonly correlationContext: CorrelationContext | null; + private readonly client?: TelemetryClient; + + constructor( + private readonly functionContext: TraceContext | undefined, + request: HttpRequest + ) { + try { + this.client = new AppInsightsFactory().create(); + // tslint:disable-next-line: no-empty + } catch { + // if we can't create a client we just leave it blank. Allows developers to work without an app insights instance. + } + this.correlationContext = this.initialiseCorrelationContext(request); + } + + /** + * Create an initialised App Insights Correlation Context using the inbound request + * to fetch HTTP headers + * @param req The inbound HTTP Request + */ + private initialiseCorrelationContext(req: HttpRequest): CorrelationContext | null { + // If function context trace exists, preference that before trying + // to parse headers because otherwise we will generate new traceparent + // if no header exists which will not match function context trace parent + if (this.client === undefined) { + return null; + } + + const requestParser = new HttpRequestParser(req, this.functionContext); + + const operationId = requestParser.getOperationId(this.client.context.tags); + const parentId = requestParser.getRequestId() || operationId; + const operationName = requestParser.getOperationName(this.client.context.tags); + const correlationContextHeader = requestParser.getCorrelationContextHeader(); + const traceparent = requestParser.getTraceparent(); + const tracestate = requestParser.getTracestate(); + + const context: CorrelationContext = { + operation: { + name: operationName || '', + id: operationId, + parentId: parentId || '', + traceparent, + tracestate + }, + customProperties: new CustomPropertiesImpl(correlationContextHeader ?? '') + }; + + return context; + } + + /** + * Tracks a dependency + */ + public trackDependency({ data, dependencyTypeName, duration, time, resultCode, success, name, target }: IDependencyTelemetry): void { + + if (!this.client) { return; } + + // https://github.com/MicrosoftDocs/azure-docs/pull/52838/files + // Use this with 'tagOverrides' to correlate custom telemetry to the parent function invocation. + const tagOverrides: {[key: string]: string} = {}; + if (this.correlationContext) { + tagOverrides['ai.operation.id'] = this.correlationContext.operation.id; + tagOverrides['ai.operation.name'] = this.correlationContext.operation.name; + tagOverrides['ai.operation.parentid'] = this.correlationContext.operation.parentId; + } + + const dependency: DependencyTelemetry = { + data, + dependencyTypeName, + duration, + resultCode, + success, + contextObjects: this.correlationContext ?? undefined, + tagOverrides, + name, + target, + time + }; + this.client.trackDependency(dependency); + } + + /** + * Taken from Core App Insights NodeJS SDK + */ + public getHeadersForRequest(): IHeaders { + + if (!this.correlationContext || !this.correlationContext.operation) { return {}; } + const currentContext = this.correlationContext; + const headers: IHeaders = {}; + + // TODO: Clean this up a bit + + let uniqueRequestId: string; + let uniqueTraceparent: string | undefined; + // tslint:disable-next-line: max-line-length + if (currentContext.operation.traceparent && Traceparent.isValidTraceId(currentContext.operation.traceparent.traceId)) { + currentContext.operation.traceparent.updateSpanId(); + uniqueRequestId = currentContext.operation.traceparent.getBackCompatRequestId(); + } else { + // Start an operation now so that we can include the w3c headers in the outgoing request + const traceparent = new Traceparent(); + uniqueTraceparent = traceparent.toString(); + uniqueRequestId = traceparent.getBackCompatRequestId(); + } + + headers[AppInsightsHeaders.requestIdHeader] = uniqueRequestId; + headers[AppInsightsHeaders.parentIdHeader] = currentContext.operation.id; + headers[AppInsightsHeaders.rootIdHeader] = uniqueRequestId; + + // Set W3C headers, if available + if (uniqueTraceparent || currentContext.operation.traceparent) { + headers[AppInsightsHeaders.traceparentHeader] = uniqueTraceparent || currentContext?.operation?.traceparent?.toString() || ''; + } else if (CorrelationIdManager.w3cEnabled) { + // should never get here since we set uniqueTraceparent above for the w3cEnabled scenario + const traceparent = new Traceparent().toString(); + headers[AppInsightsHeaders.traceparentHeader] = traceparent; + } + + if (currentContext?.operation?.tracestate) { + const tracestate = currentContext.operation.tracestate.toString(); + if (tracestate) { + headers[AppInsightsHeaders.traceStateHeader] = tracestate; + } + } + + if (currentContext?.customProperties) { + // tslint:disable-next-line: whitespace + const correlationContextHeader = (currentContext.customProperties).serializeToHeader(); + if (correlationContextHeader) { + headers[AppInsightsHeaders.correlationContextHeader] = correlationContextHeader; + } + } + + return headers; + } +} diff --git a/src/PatientTestsApi/Services/app-insights/correlation-id-manager.ts b/src/PatientTestsApi/Services/app-insights/correlation-id-manager.ts new file mode 100644 index 0000000..9115a4b --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/correlation-id-manager.ts @@ -0,0 +1,92 @@ +import { Util } from './util'; + +/** + * Stripped down version of NodeJS SDK for App Insights Correlation ID Manager + */ +export class CorrelationIdManager { + public static correlationIdPrefix = 'cid-v1:'; + + public static w3cEnabled = true; + + /** + * Generate a request Id according to https://github.com/lmolkova/correlation/blob/master/hierarchical_request_id.md + * @param parentId The Trace Parent ID + */ + public static generateRequestId(parentId: string): string { + if (parentId) { + parentId = parentId[0] === '|' ? parentId : '|' + parentId; + if (parentId[parentId.length - 1] !== '.') { + parentId += '.'; + } + + const suffix = (CorrelationIdManager.currentRootId++).toString(16); + + return CorrelationIdManager.appendSuffix(parentId, suffix, '_'); + } else { + return CorrelationIdManager.generateRootId(); + } + } + + /** + * Given a hierarchical identifier of the form |X.* + * return the root identifier X + * @param id The request-id header of the trace + */ + public static getRootId(id: string): string { + let endIndex = id.indexOf('.'); + if (endIndex < 0) { + endIndex = id.length; + } + + const startIndex = id[0] === '|' ? 1 : 0; + return id.substring(startIndex, endIndex); + } + + private static readonly requestIdMaxLength = 1024; + private static currentRootId = Util.randomu32(); + + /** Generate a new rootId */ + private static generateRootId(): string { + return `|${Util.w3cTraceId()}.`; + } + + /** + * Append the new span suffix to the parent request id + * @param parentId parent request id + * @param suffix span suffix + * @param delimiter trailing character delimiter + */ + private static appendSuffix(parentId: string, suffix: string, delimiter: string): string { + if (parentId.length + suffix.length < CorrelationIdManager.requestIdMaxLength) { + return parentId + suffix + delimiter; + } + + // Combined identifier would be too long, so we must truncate it. + // We need 9 characters of space: 8 for the overflow ID, 1 for the + // overflow delimiter '#' + const identifierLength = 9; + const overFlowLength = 8; + + let trimPosition = CorrelationIdManager.requestIdMaxLength - identifierLength; + if (parentId.length > trimPosition) { + for (; trimPosition > 1; --trimPosition) { + const c = parentId[trimPosition - 1]; + if (c === '.' || c === '_') { + break; + } + } + } + + if (trimPosition <= 1) { + // parentId is not a valid ID + return CorrelationIdManager.generateRootId(); + } + + suffix = Util.randomu32().toString(16); + while (suffix.length < overFlowLength) { + suffix = '0' + suffix; + } + + return parentId.substring(0, trimPosition) + suffix + '#'; + } +} diff --git a/src/PatientTestsApi/Services/app-insights/custom-properties-impl.ts b/src/PatientTestsApi/Services/app-insights/custom-properties-impl.ts new file mode 100644 index 0000000..010c8d9 --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/custom-properties-impl.ts @@ -0,0 +1,66 @@ +export class CustomPropertiesImpl implements PrivateCustomProperties { + private props: {key: string, value:string}[] = []; + + public constructor(header: string) { + this.addHeaderData(header); + } + + + public addHeaderData(header?: string) { + const keyvals = header ? header.split(", ") : []; + this.props = keyvals.map((keyval) => { + const parts = keyval.split("="); + return {key: parts[0], value: parts[1]}; + }).concat(this.props); + } + + public serializeToHeader() { + return this.props.map((keyval) => { + return `${keyval.key}=${keyval.value}` + }).join(", "); + } + + public getProperty(prop: string): string { + for(let i = 0; i < this.props.length; ++i) { + const keyval = this.props[i] + if (keyval.key === prop) { + return keyval.value; + } + } + return ''; + } + + // TODO: Strictly according to the spec, properties which are recieved from + // an incoming request should be left untouched, while we may add our own new + // properties. The logic here will need to change to track that. + public setProperty(prop: string, val: string) { + + for (let i = 0; i < this.props.length; ++i) { + const keyval = this.props[i]; + if (keyval.key === prop) { + keyval.value = val; + return; + } + } + this.props.push({key: prop, value: val}); + } +} + +export interface CustomProperties { + /** + * Get a custom property from the correlation context + */ + getProperty(key: string): string; + /** + * Store a custom property in the correlation context. + * Do not store sensitive information here. + * Properties stored here are exposed via outgoing HTTP headers for correlating data cross-component. + * The characters ',' and '=' are disallowed within keys or values. + */ + setProperty(key: string, value: string): void; +} + +export interface PrivateCustomProperties extends CustomProperties { + addHeaderData(header: string): void; + serializeToHeader(): string; +} diff --git a/src/PatientTestsApi/Services/app-insights/http-request-parser.ts b/src/PatientTestsApi/Services/app-insights/http-request-parser.ts new file mode 100644 index 0000000..720668f --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/http-request-parser.ts @@ -0,0 +1,218 @@ +import { HttpRequest, TraceContext } from '@azure/functions'; +import { Contracts } from 'applicationinsights'; +import Traceparent from 'applicationinsights/out/Library/Traceparent'; +import Tracestate from 'applicationinsights/out/Library/Tracestate'; +import * as url from 'url'; + +import { AppInsightsHeaders } from './app-insights-headers'; +import { CorrelationIdManager } from './correlation-id-manager'; +import { RequestParser } from './request-parser'; +import { Util } from './util'; +import { IHeaders } from '../../Models/IHeaders'; + +/** Tag for app insights context */ +export interface ITags { [key: string]: string; } + +/** + * Helper class to read data from the requst/response objects and convert them into the telemetry contract + */ +export class HttpRequestParser extends RequestParser { + + private static readonly keys = new Contracts.ContextTagKeys(); + + private rawHeaders: IHeaders = {}; + private parentId: string = ''; + private operationId: string = ''; + private requestId: string = ''; + private traceparent: Traceparent = new Traceparent(); + private tracestate: Tracestate | undefined; + + private correlationContextHeader: string | undefined; + + constructor(request: HttpRequest, functionContext?: TraceContext) { + super(); + + if (request) { + this.method = request.method || ''; + this.url = request.url; + this.parseHeaders(request, functionContext); + } + } + + /** + * Get a new set of tags for the app insights context using + * values derived from the request + * @param tags Existing app insights context tags + */ + public getRequestTags(tags: ITags): ITags { + // create a copy of the context for requests since client info will be used here + + const newTags: ITags = {}; + + for (const key in tags) { + newTags[key] = tags[key]; + } + + // don't override tags if they are already set + newTags[HttpRequestParser.keys.locationIp] = tags[HttpRequestParser.keys.locationIp] || this.getIp() || ''; + newTags[HttpRequestParser.keys.sessionId] = tags[HttpRequestParser.keys.sessionId] || this.getId('ai_session'); + newTags[HttpRequestParser.keys.userId] = tags[HttpRequestParser.keys.userId] || this.getId('ai_user'); + newTags[HttpRequestParser.keys.userAuthUserId] = tags[HttpRequestParser.keys.userAuthUserId] || this.getId('ai_authUser'); + newTags[HttpRequestParser.keys.operationName] = this.getOperationName(tags); + newTags[HttpRequestParser.keys.operationParentId] = this.getOperationParentId(tags); + newTags[HttpRequestParser.keys.operationId] = this.getOperationId(tags); + + return newTags; + } + + /** Returns the Operation ID for the request */ + public getOperationId(tags: ITags): string { + return tags[HttpRequestParser.keys.operationId] || this.operationId; + } + + /** Returns the Parent Operation ID for the request */ + public getOperationParentId(tags: ITags): string { + return tags[HttpRequestParser.keys.operationParentId] || this.parentId || this.getOperationId(tags); + } + + /** Returns the Operation name of the request */ + public getOperationName(tags: ITags): string { + // tslint:disable-next-line: prefer-template + return tags[HttpRequestParser.keys.operationName] || this.method + ' ' + url.parse(this.url).pathname; + } + + /** Returns the root request id */ + public getRequestId(): string { return this.requestId; } + + /** returns the correlation context value */ + public getCorrelationContextHeader(): string | undefined { return this.correlationContextHeader; } + + /** Returns the trace parent */ + public getTraceparent(): Traceparent { return this.traceparent; } + + /** Returns rthe trace state */ + public getTracestate(): Tracestate | undefined { return this.tracestate; } + + /** Returns the IP Address of the request client */ + private getIp(): string | undefined { + + // regex to match ipv4 without port + // Note: including the port would cause the payload to be rejected by the data collector + const ipMatch = /[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/; + + const check = (str: string): string | undefined => { + const results = ipMatch.exec(str); + if (results) { + return results[0]; + } + }; + + const ip = check(this.rawHeaders['x-forwarded-for']) + || check(this.rawHeaders['x-client-ip']) + || check(this.rawHeaders['x-real-ip']); + + return ip; + } + + /** + * Get the ID of a header cookie + * @param name the name of the header cookie + */ + private getId(name: string): string { + const cookie = (this.rawHeaders && this.rawHeaders.cookie && + typeof this.rawHeaders.cookie === 'string' && this.rawHeaders.cookie) || ''; + + const value = this.parseId(Util.getCookie(name, cookie)); + return value; + } + + /** Extract the ID from the cookie */ + private parseId(cookieValue: string): string { + const cookieParts = cookieValue.split('|'); + + if (cookieParts.length > 0) { + return cookieParts[0]; + } + + return ''; // old behavior was to return "" for incorrect parsing + } + + /** + * Sets this operation's operationId, parentId, requestId (and legacyRootId, if necessary) based on this operation's traceparent + */ + private setBackCompatFromThisTraceContext(): void { + // Set operationId + this.operationId = this.traceparent.traceId; + + // Set parentId with existing spanId + this.parentId = this.traceparent.parentId; + + // Update the spanId and set the current requestId + this.traceparent.updateSpanId(); + this.requestId = this.traceparent.getBackCompatRequestId(); + } + + /** + * Parse the request object and set the trace settings from the headers. + * Use the function context trace object by default as this will link the function host context + * to the function worker context + * @param request The inbound HttpRequest + * @param functionContext The Azure Function Context + */ + private parseHeaders(request: HttpRequest, functionContext?: TraceContext): void { + + this.rawHeaders = request.headers; + + if (functionContext && functionContext.traceparent) { + this.traceparent = new Traceparent(functionContext.traceparent); + this.tracestate = functionContext.tracestate && new Tracestate(functionContext.tracestate) || undefined; + this.setBackCompatFromThisTraceContext(); + return; + } + + if (!request.headers) { return; } + + const tracestateHeader = request.headers[AppInsightsHeaders.traceStateHeader]; // w3c header + const traceparentHeader = request.headers[AppInsightsHeaders.traceparentHeader]; // w3c header + const requestIdHeader = request.headers[AppInsightsHeaders.requestIdHeader]; // default AI header + const legacyParentId = request.headers[AppInsightsHeaders.parentIdHeader]; // legacy AI header + const legacyRootId = request.headers[AppInsightsHeaders.rootIdHeader]; // legacy AI header + + this.correlationContextHeader = request.headers[AppInsightsHeaders.correlationContextHeader]; + + if (CorrelationIdManager.w3cEnabled && (traceparentHeader || tracestateHeader)) { + // Parse W3C Trace Context headers + this.traceparent = new Traceparent(traceparentHeader); + this.tracestate = traceparentHeader && tracestateHeader && new Tracestate(tracestateHeader) || undefined; + this.setBackCompatFromThisTraceContext(); + return; + } + + if (requestIdHeader) { + // Parse AI headers + if (CorrelationIdManager.w3cEnabled) { + this.traceparent = new Traceparent(undefined, requestIdHeader); + this.setBackCompatFromThisTraceContext(); + } else { + this.parentId = requestIdHeader; + this.requestId = CorrelationIdManager.generateRequestId(this.parentId); + this.operationId = CorrelationIdManager.getRootId(this.requestId); + } + + return; + } + + // Legacy fallback + if (CorrelationIdManager.w3cEnabled) { + this.traceparent = new Traceparent(); + this.traceparent.parentId = legacyParentId; + this.traceparent.legacyRootId = legacyRootId || legacyParentId; + this.setBackCompatFromThisTraceContext(); + } else { + this.parentId = legacyParentId; + this.requestId = CorrelationIdManager.generateRequestId(legacyRootId || this.parentId); + this.correlationContextHeader = undefined; + this.operationId = CorrelationIdManager.getRootId(this.requestId); + } + } +} diff --git a/src/PatientTestsApi/Services/app-insights/request-parser.ts b/src/PatientTestsApi/Services/app-insights/request-parser.ts new file mode 100644 index 0000000..ebeafdf --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/request-parser.ts @@ -0,0 +1,18 @@ +/** + * Base class for helpers that read data from HTTP requst/response objects and convert them + * into the telemetry contract objects. + */ +export abstract class RequestParser { + + protected method: string = ''; + protected url: string = ''; + protected properties: { [key: string]: string } = {}; + + /** + * Gets a url parsed out from request options + */ + public getUrl(): string { + return this.url; + } + +} diff --git a/src/PatientTestsApi/Services/app-insights/timer.ts b/src/PatientTestsApi/Services/app-insights/timer.ts new file mode 100644 index 0000000..8cc377e --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/timer.ts @@ -0,0 +1,27 @@ +/** Timer class to track times between downstream api calls */ +export class Timer { + private _startDate: number = 0; + private _endDate: number = 0; + + /** Starts the timer */ + public constructor() { + this.start(); + } + + /** Starts the Timer */ + public start(): void { + this._startDate = Date.now(); + this._endDate = -1; + } + + /** Stops the Timer */ + public stop(): void { + this._endDate = Date.now(); + } + + /** Gets the duration of the timer */ + public get duration(): number { return this._endDate - this._startDate; } + + public get startDate(): Date { return new Date(this._startDate); } + public get endDate(): Date { return new Date(this._endDate); } +} diff --git a/src/PatientTestsApi/Services/app-insights/util.ts b/src/PatientTestsApi/Services/app-insights/util.ts new file mode 100644 index 0000000..8448e8c --- /dev/null +++ b/src/PatientTestsApi/Services/app-insights/util.ts @@ -0,0 +1,80 @@ +/** + * Stripped down utility class from NodeJS SDK for App Insights + */ +export class Util { + public static MAX_PROPERTY_LENGTH = 8192; + + /** + * helper method to access userId and sessionId cookie + */ + public static getCookie(name: string, cookie: string): string { + let value = ''; + if (name && name.length && typeof cookie === 'string') { + const cookieName = name + '='; + const cookies = cookie.split(';'); + + for (let c of cookies) { + c = Util.trim(c); + if (c && c.indexOf(cookieName) === 0) { + value = c.substring(cookieName.length, c.length); + break; + } + } + } + + return value; + } + + /** + * helper method to trim strings (IE8 does not implement String.prototype.trim) + */ + public static trim(str: string): string { + if (typeof str === 'string') { + return str.replace(/^\s+|\s+$/g, ''); + } else { + return ''; + } + } + + /** + * generate a random 32bit number (-0x80000000..0x7FFFFFFF). + */ + public static random32(): number { + return (0x100000000 * Math.random()) | 0; + } + + /** + * generate a random 32bit number (0x00000000..0xFFFFFFFF). + */ + public static randomu32(): number { + return Util.random32() + 0x80000000; + } + + /** + * generate W3C-compatible trace id + * https://github.com/w3c/distributed-tracing/blob/master/trace_context/HTTP_HEADER_FORMAT.md#trace-id + */ + public static w3cTraceId() { + const hexValues = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; + + // rfc4122 version 4 UUID without dashes and with lowercase letters + let oct = ''; + let tmp; + for (let a = 0; a < 4; a++) { + tmp = Util.random32(); + oct += + hexValues[tmp & 0xF] + + hexValues[tmp >> 4 & 0xF] + + hexValues[tmp >> 8 & 0xF] + + hexValues[tmp >> 12 & 0xF] + + hexValues[tmp >> 16 & 0xF] + + hexValues[tmp >> 20 & 0xF] + + hexValues[tmp >> 24 & 0xF] + + hexValues[tmp >> 28 & 0xF]; + } + + // "Set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively" + const clockSequenceHi = hexValues[8 + (Math.random() * 4) | 0]; + return oct.substr(0, 8) + oct.substr(9, 4) + '4' + oct.substr(13, 3) + clockSequenceHi + oct.substr(16, 3) + oct.substr(19, 12); + } +} diff --git a/src/PatientTestsApi/Test/Controllers/PatientController.spec.ts b/src/PatientTestsApi/Test/Controllers/PatientController.spec.ts new file mode 100644 index 0000000..e931461 --- /dev/null +++ b/src/PatientTestsApi/Test/Controllers/PatientController.spec.ts @@ -0,0 +1,80 @@ +import { anything, capture, mock, verify, instance } from "ts-mockito"; +import { PatientController } from "../../Controllers/PatientController"; +import { HttpRequest } from "@azure/functions"; +import { IPatientDataService } from "../../Services/IPatientDataService"; +import { expect } from "chai"; +import { BadRequestResponse } from "../../Models/BadRequestResponse"; +import { v4 as uuidv4 } from "uuid"; +import { PatientFixture } from "../Fixtures/PatientFixture"; + + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function createPatientRequest (body: any = PatientFixture.createPatientForCreatingInDb()): HttpRequest { + return { + body, + headers: {}, + method: "POST", + url: "", + query: {}, + params: {} + }; +} + +function createController (dataService?: IPatientDataService): PatientController { + if (!dataService){ + dataService = mock(); + } + return new PatientController(dataService); +} + +describe("PatientController", async function (): Promise { + it("Parses the patient and creates it using the dataservice.", async function (): Promise { + const dataServiceMock = mock(); + const controller = createController(instance(dataServiceMock)); + const request = createPatientRequest(); + + const response = await controller.createPatient(request); + + verify(dataServiceMock.insertPatient(anything())).once(); + const [argument] = capture(dataServiceMock.insertPatient).first(); + expect(argument.id).is.not.null; + expect(response.body).is.not.null; + expect(response.status).is.equal(201); + }); + + it("Returns Bad request if patient request has an id set.", async function (): Promise { + const dataServiceMock = mock(); + const controller = createController(instance(dataServiceMock)); + const request = createPatientRequest(); + request.body.id = uuidv4(); + + const response = await controller.createPatient(request); + + expect(response).to.be.instanceOf(BadRequestResponse); + expect(response.body).to.equal("Id unexpected."); + }); + + it("Returns Bad request if patient request has lastUpdated.", async function (): Promise { + const dataServiceMock = mock(); + const controller = createController(instance(dataServiceMock)); + const request = createPatientRequest(); + request.body.lastUpdated = new Date(); + + const response = await controller.createPatient(request); + + expect(response).to.be.instanceOf(BadRequestResponse); + expect(response.body).to.equal("lastUpdated unexpected."); + }); + + it("Returns Bad request if patient request ca not be parsed.", async function (): Promise { + const dataServiceMock = mock(); + const controller = createController(instance(dataServiceMock)); + const request = createPatientRequest({}); + + + const response = await controller.createPatient(request); + + expect(response).to.be.instanceOf(BadRequestResponse); + expect(response.body).to.equal("\"firstName\" is required"); + }); +}); diff --git a/src/PatientTestsApi/Test/Fixtures/DBFixture.ts b/src/PatientTestsApi/Test/Fixtures/DBFixture.ts new file mode 100644 index 0000000..3f502c1 --- /dev/null +++ b/src/PatientTestsApi/Test/Fixtures/DBFixture.ts @@ -0,0 +1,46 @@ +import { PatientFixture } from "./PatientFixture"; +import { Db, MongoClient } from "mongodb"; +import { ISettings } from "../../Models/ISettings"; +import { ICollection } from "../../Services/ICollection"; +import { FileSettings } from "./FileSettings"; + +export class DBFixture { + + public mongoDb: Db; + public mongoClient: MongoClient; + public settings: ISettings; + + constructor(){ + this.mongoDb = {} as Db; + this.mongoClient = {} as MongoClient; + this.settings = new FileSettings(); + } + + public async init(): Promise { + + // connect and select database + this.mongoClient = await MongoClient.connect(this.settings.mongoConnectionString, + { useUnifiedTopology: true, useNewUrlParser: true, tlsAllowInvalidCertificates: true }); + + this.mongoDb = this.mongoClient.db(this.settings.patientTestDatabase); + } + + public createPatientCollection(): ICollection { + return this.mongoDb.collection(this.settings.patientCollection); + } + + public async cleanPatients(): Promise { + await this.mongoDb.collection(this.settings.patientCollection) + .deleteOne({ _id: PatientFixture.CreatePatientId, _shardKey: PatientFixture.CreatePatientId }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async loadPatient(id: string): Promise { + return await this.mongoDb.collection(this.settings.patientCollection).findOne({_id: id}); + } + + public async close(): Promise { + // close the connection + await this.mongoClient.close(true); + } +} \ No newline at end of file diff --git a/src/PatientTestsApi/Test/Fixtures/FileSettings.ts b/src/PatientTestsApi/Test/Fixtures/FileSettings.ts new file mode 100644 index 0000000..6992191 --- /dev/null +++ b/src/PatientTestsApi/Test/Fixtures/FileSettings.ts @@ -0,0 +1,20 @@ +import { ISettings } from "../../Models/ISettings"; +import fs from "fs"; + +export class FileSettings implements ISettings { + public get patientCollection(): string { return "patients";} + public get patientTestDatabase(): string { return this.localSettings.patient_tests_database;} + public get mongoConnectionString(): string { return this.localSettings.mongo_connection_string;} + public get allowSelfSignedMongoCert(): boolean { return JSON.parse(process.env.allow_self_signed_mongo_cert || "true");} + + private readonly localSettings: { + patient_tests_database: string; + mongo_connection_string: string; + }; + + public constructor(filePath = "local.settings.json") { + const localSettingsContent = fs.readFileSync(filePath).toString(); + this.localSettings = JSON.parse(localSettingsContent).Values; + } + +} \ No newline at end of file diff --git a/src/PatientTestsApi/Test/Fixtures/PatientFixture.ts b/src/PatientTestsApi/Test/Fixtures/PatientFixture.ts new file mode 100644 index 0000000..493175d --- /dev/null +++ b/src/PatientTestsApi/Test/Fixtures/PatientFixture.ts @@ -0,0 +1,26 @@ +import { IPatient } from "../../Models/IPatient"; +import { Gender } from "../../Models/Gender"; + +export class PatientFixture { + public static readonly CreatePatientId = "df5ad95e-05e9-4a22-aac0-f74164c623ac"; + + public static createPatientForCreatingInDb(): IPatient { + return { + firstName: "FirstName", + lastName: "LastName", + fullName: "FullName", + gender: Gender.Male, + dateOfBirth: "1908-05-23", + postCode: "0001", + insuranceNumber: "ins0001", + preferredContactNumber: "01012345567" + }; + } + + public static createPatient(): IPatient { + const patient: IPatient = PatientFixture.createPatientForCreatingInDb(); + patient.id = PatientFixture.CreatePatientId; + patient.lastUpdated = new Date("2020-05-07T04:20:44.454Z"); + return patient; + } +} \ No newline at end of file diff --git a/src/PatientTestsApi/Test/LocalSettings.spec.ts b/src/PatientTestsApi/Test/LocalSettings.spec.ts new file mode 100644 index 0000000..ae5a2ac --- /dev/null +++ b/src/PatientTestsApi/Test/LocalSettings.spec.ts @@ -0,0 +1,21 @@ +import chai, { expect } from "chai"; +import chaiAsPromised from "chai-as-promised"; +import fs from "fs"; + +chai.use(chaiAsPromised); + +describe("LocalSettings", function (): void { + it("should not contain settings that are not in template settings file", function (): void { + const localSettingsPath = "local.settings.json"; + let localSettingsContent; + try { + localSettingsContent = fs.readFileSync(localSettingsPath).toString(); + } catch (e) { + this.skip(); + } + const templateSettings = JSON.parse(fs.readFileSync("template.settings.json").toString()); + const localSettings = JSON.parse(localSettingsContent); + // tslint:disable-next-line: no-unsafe-any + expect(Object.keys(templateSettings.Values)).to.include.members(Object.keys(localSettings.Values)); + }); +}); diff --git a/src/PatientTestsApi/Test/Services/LoggingCollection.spec.ts b/src/PatientTestsApi/Test/Services/LoggingCollection.spec.ts new file mode 100644 index 0000000..66b6d14 --- /dev/null +++ b/src/PatientTestsApi/Test/Services/LoggingCollection.spec.ts @@ -0,0 +1,69 @@ +import { ICollection } from "../../Services/ICollection"; +import { mock, anything, instance, when, capture } from "ts-mockito"; +import { InsertOneWriteOpResult } from "mongodb"; +import { IAppInsightsService } from "../../Services/app-insights/app-insights-service"; +import { LoggingCollection } from "../../Services/LoggingCollection"; +import { expect } from "chai"; + +describe("LoggingCollection", async function (): Promise { + it("Tracks dependencies for succesful database calls", async function(): Promise { + const mockCollection = mock(); + const expectedResult = createOneInsertResult(); + const expectedDoc = {key:"value"}; + when(mockCollection.insertOne(expectedDoc, anything())).thenResolve(expectedResult); + const mockAppInsightsService = mock(); + const expectedCollectionName = "collectionName"; + const expectedDbName = "dbName"; + const appInsightsService = instance(mockAppInsightsService); + const collection = new LoggingCollection(instance(mockCollection), appInsightsService, expectedCollectionName, expectedDbName); + + const result = await collection.insertOne(expectedDoc, {}); + + expect(result).is.equal(expectedResult); + + const [actualTelemetry] = capture(mockAppInsightsService.trackDependency).first(); + expect(actualTelemetry.data).is.equal("{\"insertOne\":{\"options\":{}}}"); + expect(actualTelemetry.dependencyTypeName).is.equal("mongodb"); + expect(actualTelemetry.resultCode).is.equal(0); + expect(actualTelemetry.success).is.equal(true); + expect(actualTelemetry.name).is.equal(expectedDbName); + expect(actualTelemetry.target).is.equal(expectedCollectionName); + + }); + + it("Tracks dependencies for failed database calls", async function(): Promise { + const mockCollection = mock(); + const expectedDoc = {key:"value"}; + const expectedError = new Error("expectedError"); + const expectedErrorString = JSON.stringify(expectedError, Object.getOwnPropertyNames(expectedError)); + when(mockCollection.insertOne(expectedDoc, anything())) + .thenThrow(expectedError); + const mockAppInsightsService = mock(); + const expectedCollectionName = "collectionName"; + const expectedDbName = "dbName"; + const appInsightsService = instance(mockAppInsightsService); + const collection = new LoggingCollection(instance(mockCollection), appInsightsService, expectedCollectionName, expectedDbName); + + await expect(collection.insertOne(expectedDoc, {})).to.be.rejectedWith(expectedError); + + const [actualTelemetry] = capture(mockAppInsightsService.trackDependency).first(); + expect(actualTelemetry.data).is.equal("{\"insertOne\":{\"options\":{}}}"); + expect(actualTelemetry.dependencyTypeName).is.equal("mongodb"); + expect(actualTelemetry.resultCode).is.equal(expectedErrorString); + expect(actualTelemetry.success).is.equal(false); + expect(actualTelemetry.name).is.equal(expectedDbName); + expect(actualTelemetry.target).is.equal(expectedCollectionName); + + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function createOneInsertResult(): InsertOneWriteOpResult { + return { + insertedCount: 1, + ops: [], + insertedId: {}, + connection: {}, + result: { ok: 1, n: 1 } + }; + } +}); diff --git a/src/PatientTestsApi/Test/Services/PatientDataService.spec.ts b/src/PatientTestsApi/Test/Services/PatientDataService.spec.ts new file mode 100644 index 0000000..a71ffd0 --- /dev/null +++ b/src/PatientTestsApi/Test/Services/PatientDataService.spec.ts @@ -0,0 +1,36 @@ +import { DBFixture } from "../Fixtures/DBFixture"; +import { PatientDataService } from "../../Services/PatientDataService"; +import { expect } from "chai"; +import { PatientFixture } from "../Fixtures/PatientFixture"; + +const db = new DBFixture(); + +describe("PatientDataService #integaration", async function (): Promise { + before(async function (): Promise { + await db.init(); + await db.cleanPatients(); + }); + + it("Can create a patient", async function (): Promise { + const dataService: PatientDataService = createPatientDataService(); + const expectedPatient = PatientFixture.createPatient(); + + const id = await dataService.insertPatient(expectedPatient); + + const createdPatient = await db.loadPatient(id); + Object.keys(expectedPatient).forEach(key => { + expect(createdPatient[key]).deep.equal(expectedPatient[key]); + }); + expect(createdPatient._id).is.equal(id); + expect(createdPatient._shardKey).is.equal(id); + }); + + after(async function (): Promise { + await db.cleanPatients(); + await db.close(); + }); +}); + +const createPatientDataService = function (): PatientDataService { + return new PatientDataService(db.createPatientCollection()); +}; \ No newline at end of file diff --git a/src/PatientTestsApi/Test/Services/RetryCollection.spec.ts b/src/PatientTestsApi/Test/Services/RetryCollection.spec.ts new file mode 100644 index 0000000..dc37d8f --- /dev/null +++ b/src/PatientTestsApi/Test/Services/RetryCollection.spec.ts @@ -0,0 +1,38 @@ +import { RetryCollection } from "../../Services/RetryCollection"; +import { ICollection } from "../../Services/ICollection"; +import { mock, anything, when, instance, verify } from "ts-mockito"; +import { MongoError, InsertOneWriteOpResult } from "mongodb"; +import { expect } from "chai"; + +describe("RetryCollection", async function (): Promise { + it("Retries an insert with a delay when the collection throws 16500",async function () { + this.timeout(5000); + const mockCollection = mock(); + const expectedResult = createOneInsertResult(); + const expectedDoc = {key:"value"}; + when(mockCollection.insertOne(expectedDoc, anything())) + .thenThrow(new MongoError({code: 16500})) + .thenThrow(new MongoError({code: 16500})) + .thenResolve(expectedResult); + const collection = new RetryCollection(instance(mockCollection)); + + const startTime = Date.now(); + const result = await collection.insertOne(expectedDoc); + const elapsedTime = Date.now() - startTime; + + expect(result).is.equal(expectedResult); + verify(mockCollection.insertOne(expectedDoc, anything())).thrice(); + expect(elapsedTime).is.greaterThan(2000); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function createOneInsertResult(): InsertOneWriteOpResult { + return { + insertedCount: 1, + ops: [], + insertedId: {}, + connection: {}, + result: { ok: 1, n: 1 } + }; + } +}); \ No newline at end of file diff --git a/src/PatientTestsApi/package.json b/src/PatientTestsApi/package.json index 892304a..b462876 100644 --- a/src/PatientTestsApi/package.json +++ b/src/PatientTestsApi/package.json @@ -3,21 +3,40 @@ "version": "", "description": "", "scripts": { - "test": "echo \"No tests yet...\"", - "build": "tsc" + "test": "mocha --timeout 30000 --allow-uncaught --config .mocharc.json 'dist/Test/**/*.spec.js'", + "build": "tsc", + "clean": "npx rimraf ./dist ./node_modules", + "lint": "npx eslint . --ext .ts" }, "author": "", "devDependencies": { "@azure/functions": "^1.2.0", + "@types/chai": "^4.2.3", + "@types/chai-as-promised": "^7.1.2", + "@types/hapi__joi": "^17.1.0", + "@types/mocha": "^5.2.7", + "@types/mongodb": "^3.5.16", "@types/node": "^13.13.5", - "@typescript-eslint/eslint-plugin": "^2.31.0", - "@typescript-eslint/parser": "^2.31.0", + "@types/uuid": "^7.0.3", + "@typescript-eslint/eslint-plugin": "2.31.0", + "@typescript-eslint/parser": "2.31.0", + "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", "eslint": "^6.8.0", - "eslint-config-standard": "^14.1.1", + "eslint-config-recommended": "^4.0.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", + "mocha": "^6.2.3", + "source-map-support": "^0.5.19", + "ts-mockito": "^2.5.0", "typescript": "^3.8.3" + }, + "dependencies": { + "@hapi/joi": "^17.1.1", + "applicationinsights": "^1.7.5", + "mongodb": "^3.5.7", + "uuid": "^8.0.0" } } diff --git a/src/PatientTestsApi/readme.md b/src/PatientTestsApi/readme.md new file mode 100644 index 0000000..a58a29b --- /dev/null +++ b/src/PatientTestsApi/readme.md @@ -0,0 +1,57 @@ +# Getting started + +## Prerequisites +- Node 12 +- Azure functions CLI v3 +- Azure CLI + +This project includes a [dev container](https://code.visualstudio.com/docs/remote/containers), with the prerequisites installed. + +Copy the contents of `template.settings.json` to a file in the root folder called `local.settings.json`. This is where you will keep all the settings used for running the app locally. + +## Mongodb +You'll need a mongodb to use to store data: +### Create a CosmosDB with a Mongo API +Follow the steps [here](https://docs.microsoft.com/en-us/azure/cosmos-db/connect-mongodb-account) to get the connection string. +### Use the CosmosDB emulator with a Mongo API +Follow the instructions [here](https://docs.microsoft.com/en-us/azure/cosmos-db/local-emulator#installation) to install the emulator and obtain the connection string. +Start the emulator using `C:\Program Files\Azure Cosmos DB Emulator\Microsoft.Azure.Cosmos.Emulator.exe /EnableMongoDbEndpoint=3.6` +### Host your own MongoDB +Instructions for hosting your own mongodb isntance can be found [here](https://docs.mongodb.com/manual/installation/) + +Now update the `mongo_connection_string` setting in your `local.settings.json` file with the connection string for your chosen mongodb host. + +Connect to your mongodb instance using the Azure portal, or a client application such as robo3t or mongo shell, and create a database called `newcastle` and a collection called `patients`. + +## Running the code +Start by opening a terminal and running `npm install`. +You can run the code by running `func host start` in teh terminal or by using VS Code: + +### VS Code +The .vscode contains all the tasks you need to run and debug the code. You can press f5 to run the application and debug it, as described [here](https://docs.microsoft.com/en-us/azure/azure-functions/functions-develop-vs-code?tabs=csharp#debugging-functions-locally) + +Try it out by doing an HTPP POST to `http://localhost:7071/api/patient` with the following body: + +```json +{ + "firstName": "FirstName", + "lastName": "LastName", + "fullName": "FullName", + "gender": "male", + "dateOfBirth": "1908-05-23", + "postCode": "0001", + "insuranceNumber": "ins0001", + "preferredContactNumber": "01012345567" +} +``` + +If your settings and MongoDB instance is configured correctly, you should receive a 201 response. The `patient` collection in your db should now contain a new record. + +## Running the tests +This project contains unit and integration tests written using Mocha. You can run the code using npm by running `npm test` in the terminal. You can also use the [Mocha Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-mocha-test-adapter) extension for VS Code to run the tests. + +## Linting +This project is configured to use ESLint for linting. Run the liniting from the terminal using `npm run lint`, or using the 'lint whole folder' task for the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for VSCode. + +## Publishing to Azure +To publish this code to Azure and obtain a public url, follow the instructions [here](https://docs.microsoft.com/en-us/azure/azure-functions/functions-develop-vs-code?tabs=csharp#publish-to-azure) diff --git a/src/PatientTestsApi/template.settings.json b/src/PatientTestsApi/template.settings.json new file mode 100644 index 0000000..9e35c89 --- /dev/null +++ b/src/PatientTestsApi/template.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "node", + "APPINSIGHTS_INSTRUMENTATIONKEY": "", + "mongo_connection_string": "mongo_connection_string", + "patient_tests_database": "newcastle", + "allow_self_signed_mongo_cert": true + } + } \ No newline at end of file diff --git a/src/PatientTestsApi/tsconfig.json b/src/PatientTestsApi/tsconfig.json index 9dd6153..d6a0ba5 100644 --- a/src/PatientTestsApi/tsconfig.json +++ b/src/PatientTestsApi/tsconfig.json @@ -11,7 +11,8 @@ "moduleResolution": "node", "typeRoots": ["./node_modules/@types"], "types": [ - "node" + "node", + "mocha" ] } } diff --git a/src/local.settings.json b/src/local.settings.json new file mode 100644 index 0000000..1085282 --- /dev/null +++ b/src/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": true, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "CfDJ8LhdzvCiXw9IpIRM4WTX3x+VVs9bRfOrFBP4BP62QhgfKDc50mq3dQuNa3h3QVwIkk5TA4ODPn3UFdhmy25C2u84vF1dKxDGRiJ7pEdnyQZUpgexzg54M5nlMqSxHMzY3Q==" + }, + "ConnectionStrings": {} +} \ No newline at end of file