From 41cb3f12cdc4aee9cbe914239308e70935efd1dc Mon Sep 17 00:00:00 2001 From: maaaNu Date: Tue, 9 Apr 2024 19:21:19 +0200 Subject: [PATCH] [3.0.0-alpha.0] First try to upgrade to Programming Model V4 --- example/.funcignore | 10 + example/host.json | 2 +- example/package-lock.json | 82 ++++- example/package.json | 13 +- example/src/invocationHook.ts | 17 ++ .../test-header-authentication-function.ts | 15 + .../src/test-jwt-authorization-function.ts | 27 ++ example/src/test-validation-function.ts | 29 ++ .../function.json | 19 -- .../test-header-authentication-function.ts | 11 - .../function.json | 19 -- .../test-jwt-authorization-function.ts | 22 -- .../test-validation-function/function.json | 19 -- .../test-validation-function.ts | 22 -- example/tsconfig.json | 11 +- .../validation-test.integration.ts | 2 +- package-lock.json | 118 ++++++-- package.json | 5 +- src/appInsights/Logger.ts | 119 ++++---- src/appInsights/appInsightsWrapper.ts | 140 +++++---- src/error/errorHandler.test.ts | 6 +- src/error/errorHandler.ts | 12 +- src/headerAuthentication.test.ts | 66 ++++- src/headerAuthentication.ts | 70 +++-- src/jwtAuthorization.test.ts | 31 +- src/jwtAuthorization.ts | 33 ++- src/middleware.test.ts | 279 +++++++++--------- src/middleware.ts | 103 ++++--- src/validation.test.ts | 107 +++---- src/validation.ts | 102 +++++-- 30 files changed, 908 insertions(+), 603 deletions(-) create mode 100644 example/.funcignore create mode 100644 example/src/invocationHook.ts create mode 100644 example/src/test-header-authentication-function.ts create mode 100644 example/src/test-jwt-authorization-function.ts create mode 100644 example/src/test-validation-function.ts delete mode 100644 example/test-header-authentication-function/function.json delete mode 100644 example/test-header-authentication-function/test-header-authentication-function.ts delete mode 100644 example/test-jwt-authorization-function/function.json delete mode 100644 example/test-jwt-authorization-function/test-jwt-authorization-function.ts delete mode 100644 example/test-validation-function/function.json delete mode 100644 example/test-validation-function/test-validation-function.ts diff --git a/example/.funcignore b/example/.funcignore new file mode 100644 index 0000000..d5b3b4a --- /dev/null +++ b/example/.funcignore @@ -0,0 +1,10 @@ +*.js.map +*.ts +.git* +.vscode +__azurite_db*__.json +__blobstorage__ +__queuestorage__ +local.settings.json +test +tsconfig.json \ No newline at end of file diff --git a/example/host.json b/example/host.json index 291065f..9df9136 100644 --- a/example/host.json +++ b/example/host.json @@ -10,6 +10,6 @@ }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[2.*, 3.0.0)" + "version": "[4.*, 5.0.0)" } } \ No newline at end of file diff --git a/example/package-lock.json b/example/package-lock.json index 74a2a38..aa312b2 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@azure/functions": "^3.0.0", + "@azure/functions": "^4.2.0", "@types/node": "^18.11.18", "joi": "^17.9.1" }, @@ -22,9 +22,17 @@ } }, "node_modules/@azure/functions": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-3.0.0.tgz", - "integrity": "sha512-nxOdQdYoy9JEdAPsQlBWavsRvbH5qT2fpwMcY64s1sLIT8QwtW7ebh/MJNgzeAac+JaC6IED7plDiizq8oZUNw==" + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.4.0.tgz", + "integrity": "sha512-debidWolFTsfapsK53ftzLtXJc3dbYYPc9UqJoEm1GAj1lS7jFMARQnbfTQPDqBIhuJxLZ9D8WVvhIEV7Hifzw==", + "dependencies": { + "cookie": "^0.6.0", + "long": "^4.0.0", + "undici": "^5.13.0" + }, + "engines": { + "node": ">=18.0" + } }, "node_modules/@babel/code-frame": { "version": "7.22.13", @@ -580,6 +588,14 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "engines": { + "node": ">=14" + } + }, "node_modules/@hapi/hoek": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", @@ -1492,6 +1508,14 @@ "safe-buffer": "~5.1.1" } }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3218,6 +3242,11 @@ "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -4415,6 +4444,17 @@ "which-boxed-primitive": "^1.0.2" } }, + "node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -4670,9 +4710,14 @@ }, "dependencies": { "@azure/functions": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-3.0.0.tgz", - "integrity": "sha512-nxOdQdYoy9JEdAPsQlBWavsRvbH5qT2fpwMcY64s1sLIT8QwtW7ebh/MJNgzeAac+JaC6IED7plDiizq8oZUNw==" + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.4.0.tgz", + "integrity": "sha512-debidWolFTsfapsK53ftzLtXJc3dbYYPc9UqJoEm1GAj1lS7jFMARQnbfTQPDqBIhuJxLZ9D8WVvhIEV7Hifzw==", + "requires": { + "cookie": "^0.6.0", + "long": "^4.0.0", + "undici": "^5.13.0" + } }, "@babel/code-frame": { "version": "7.22.13", @@ -5132,6 +5177,11 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==" + }, "@hapi/hoek": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", @@ -5918,6 +5968,11 @@ "safe-buffer": "~5.1.1" } }, + "cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7311,6 +7366,11 @@ "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -8266,6 +8326,14 @@ "which-boxed-primitive": "^1.0.2" } }, + "undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "requires": { + "@fastify/busboy": "^2.0.0" + } + }, "universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/example/package.json b/example/package.json index 2383f0e..8b339c8 100644 --- a/example/package.json +++ b/example/package.json @@ -3,11 +3,11 @@ "version": "1.0.0", "description": "Test project to demonstrate the azure-function-middleware", "scripts": { - "test": "jest", "build": "tsc", - "start:host": "func start --port 8080 --javascript", - "start": "npm-run-all --parallel start:host watch", - "watch": "tsc --w" + "watch": "tsc -w", + "clean": "rimraf dist", + "prestart": "npm run clean && npm run build", + "start": "func start" }, "keywords": [ "azure", @@ -25,8 +25,9 @@ "typescript": "^4.4.3" }, "dependencies": { - "@azure/functions": "^3.0.0", + "@azure/functions": "^4.2.0", "@types/node": "^18.11.18", "joi": "^17.9.1" - } + }, + "main": "dist/example/src/*.js" } diff --git a/example/src/invocationHook.ts b/example/src/invocationHook.ts new file mode 100644 index 0000000..2e44aa0 --- /dev/null +++ b/example/src/invocationHook.ts @@ -0,0 +1,17 @@ +import { PostInvocationContext, PreInvocationContext, app } from '@azure/functions'; + +app.hook.preInvocation((context: PreInvocationContext) => { + if (context.invocationContext.options.trigger.type === 'httpTrigger') { + context.invocationContext.log( + `preInvocation hook executed for http function ${context.invocationContext.functionName}`, + ); + } +}); + +app.hook.postInvocation((context: PostInvocationContext) => { + if (context.invocationContext.options.trigger.type === 'httpTrigger') { + context.invocationContext.log( + `postInvocation hook executed for http function ${context.invocationContext.functionName}`, + ); + } +}); diff --git a/example/src/test-header-authentication-function.ts b/example/src/test-header-authentication-function.ts new file mode 100644 index 0000000..41ceb03 --- /dev/null +++ b/example/src/test-header-authentication-function.ts @@ -0,0 +1,15 @@ +import { HttpRequest, InvocationContext, app } from '@azure/functions'; + +import { middleware } from '../../src'; +import headerAuthentication from '../../src/headerAuthentication'; + +export const handler = async (request: HttpRequest, context: InvocationContext) => { + context.info('Function called'); + return { status: 204 }; +}; +app.http('test-header-authentication-function', { + methods: ['POST'], + authLevel: 'anonymous', + route: 'authentication', + handler: middleware([headerAuthentication()], handler, []), +}); diff --git a/example/src/test-jwt-authorization-function.ts b/example/src/test-jwt-authorization-function.ts new file mode 100644 index 0000000..5576409 --- /dev/null +++ b/example/src/test-jwt-authorization-function.ts @@ -0,0 +1,27 @@ +import { HttpHandler, HttpRequestParams, app } from '@azure/functions'; + +import { middleware } from '../../src'; +import authorization from '../../src/jwtAuthorization'; + +export const handler: HttpHandler = async (req, context) => { + context.log('Function called'); + return { status: 204 }; +}; + +app.http('test-jwt-authorization-function', { + methods: ['POST'], + authLevel: 'anonymous', + route: 'authorization/{id}', + handler: middleware( + [ + authorization([ + { + parameterExtractor: (parameters: HttpRequestParams) => parameters.id, + jwtExtractor: (jwt: { userId: string }) => jwt.userId, + }, + ]), + ], + handler, + [], + ), +}); diff --git a/example/src/test-validation-function.ts b/example/src/test-validation-function.ts new file mode 100644 index 0000000..385776a --- /dev/null +++ b/example/src/test-validation-function.ts @@ -0,0 +1,29 @@ +import { HttpHandler, app } from '@azure/functions'; +import * as Joi from 'joi'; +import { ObjectSchema } from 'joi'; + +import { PostExecutionFunction, middleware } from '../../src'; +import { requestValidation as validation } from '../../src/validation'; + +const schema: ObjectSchema = Joi.object({ + name: Joi.string().min(3).max(30).required(), +}).required(); + +export const functionHandler: HttpHandler = async (req, context) => { + context.info('Function called'); + const body = (await req.json()) as { name: string }; + + return { status: 200, jsonBody: { text: `Hallo ${body.name}` } }; +}; + +const postFunction: PostExecutionFunction = (_, context) => { + context.log('Called after function'); + return; +}; + +app.http('test-validation-function', { + methods: ['POST'], + authLevel: 'anonymous', + route: 'validation', + handler: middleware([validation(schema, { printRequest: true })], functionHandler, [postFunction]), +}); diff --git a/example/test-header-authentication-function/function.json b/example/test-header-authentication-function/function.json deleted file mode 100644 index d3fa7c5..0000000 --- a/example/test-header-authentication-function/function.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bindings": [{ - "authLevel": "Anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "post" - ], - "route": "authentication" - }, - { - "type": "http", - "direction": "out", - "name": "res" - } - ], - "scriptFile": "../dist/example/test-header-authentication-function/test-header-authentication-function.js" -} \ No newline at end of file diff --git a/example/test-header-authentication-function/test-header-authentication-function.ts b/example/test-header-authentication-function/test-header-authentication-function.ts deleted file mode 100644 index 623eac8..0000000 --- a/example/test-header-authentication-function/test-header-authentication-function.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Context } from '@azure/functions'; - -import { middleware } from '../../src'; -import headerAuthentication from '../../src/headerAuthentication'; - -const functionHandler = async (context: Context): Promise => { - context.log.info('Function called'); - context.res = { status: 204 }; -}; - -export default middleware([headerAuthentication()], functionHandler, []); diff --git a/example/test-jwt-authorization-function/function.json b/example/test-jwt-authorization-function/function.json deleted file mode 100644 index 97f891d..0000000 --- a/example/test-jwt-authorization-function/function.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bindings": [{ - "authLevel": "Anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "post" - ], - "route": "authorization/{id}" - }, - { - "type": "http", - "direction": "out", - "name": "res" - } - ], - "scriptFile": "../dist/example/test-jwt-authorization-function/test-jwt-authorization-function.js" -} \ No newline at end of file diff --git a/example/test-jwt-authorization-function/test-jwt-authorization-function.ts b/example/test-jwt-authorization-function/test-jwt-authorization-function.ts deleted file mode 100644 index 23f1760..0000000 --- a/example/test-jwt-authorization-function/test-jwt-authorization-function.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Context, ContextBindingData } from '@azure/functions'; - -import { middleware } from '../../src'; -import authorization from '../../src/jwtAuthorization'; - -const functionHandler = async (context: Context): Promise => { - context.log.info('Function called'); - context.res = { status: 204 }; -}; - -export default middleware( - [ - authorization([ - { - parameterExtractor: (parameters: ContextBindingData) => parameters.id, - jwtExtractor: (jwt: { userId: string }) => jwt.userId, - }, - ]), - ], - functionHandler, - [], -); diff --git a/example/test-validation-function/function.json b/example/test-validation-function/function.json deleted file mode 100644 index 7afb478..0000000 --- a/example/test-validation-function/function.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bindings": [{ - "authLevel": "Anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "post" - ], - "route": "validation" - }, - { - "type": "http", - "direction": "out", - "name": "res" - } - ], - "scriptFile": "../dist/example/test-validation-function/test-validation-function.js" -} \ No newline at end of file diff --git a/example/test-validation-function/test-validation-function.ts b/example/test-validation-function/test-validation-function.ts deleted file mode 100644 index 54062d8..0000000 --- a/example/test-validation-function/test-validation-function.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Context, HttpRequest } from '@azure/functions'; -import * as Joi from 'joi'; -import { ObjectSchema } from 'joi'; - -import { middleware } from '../../src'; -import { requestValidation as validation } from '../../src/validation'; - -const schema: ObjectSchema = Joi.object({ - name: Joi.string().min(3).max(30).required(), -}).required(); - -const functionHandler = async (context: Context, req: HttpRequest): Promise => { - context.log.info('Function called'); - context.res = { status: 200, body: { text: `Hallo ${req.body.name}` } }; -}; - -const postFunction = (context: Context): Promise => { - context.log('Called after function'); - return; -}; - -export default middleware([validation(schema)], functionHandler, [postFunction]); diff --git a/example/tsconfig.json b/example/tsconfig.json index 2486e98..d4c8d9f 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { "module": "commonjs", - "target": "es2015", - "declaration": true, - "outDir": "./dist" - }, -} + "target": "es6", + "outDir": "dist", + "sourceMap": true, + "strict": false + } +} \ No newline at end of file diff --git a/integration-test/validation-test.integration.ts b/integration-test/validation-test.integration.ts index 15b5c27..28d7eea 100644 --- a/integration-test/validation-test.integration.ts +++ b/integration-test/validation-test.integration.ts @@ -38,6 +38,6 @@ describe('The example azure function is started and the JOI validation should', expect(responseEmptyBody.data).toEqual({ message: '"name" is required' }); expect(responseUndefinedBody.status).toEqual(400); - expect(responseUndefinedBody.data).toEqual({ message: '"value" is required' }); + expect(responseUndefinedBody.data).toEqual({ message: 'Unexpected end of JSON input' }); }); }); diff --git a/package-lock.json b/package-lock.json index 97f7186..6e7698c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@senacor/azure-function-middleware", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@senacor/azure-function-middleware", - "version": "2.3.0", + "version": "2.3.1", "license": "MIT", "dependencies": { - "@azure/functions": "^3.0.0", + "@azure/functions": "^4.0.0", "@types/node": "^20.8.10", "axios": "^1.1.3", + "durable-functions": "^3.1.0", "jwt-decode": "^4.0.0" }, "devDependencies": { @@ -179,12 +180,15 @@ "peer": true }, "node_modules/@azure/functions": { - "version": "3.5.0", - "license": "MIT", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.2.0.tgz", + "integrity": "sha512-RSECLoje4jGVpsVRjEzkna9KvmQOVeB96cg8J5J2g41QQpMWCzD1QTPI5+yI0uvOidGRLYElV1zHZjdvsGf9Nw==", "dependencies": { - "iconv-lite": "^0.6.3", "long": "^4.0.0", - "uuid": "^8.3.0" + "undici": "^5.13.0" + }, + "engines": { + "node": ">=18.0" } }, "node_modules/@azure/logger": { @@ -899,6 +903,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", + "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "engines": { + "node": ">=14" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "license": "BSD-3-Clause", @@ -2226,11 +2238,11 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", - "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -2745,6 +2757,45 @@ "node": ">=8" } }, + "node_modules/durable-functions": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/durable-functions/-/durable-functions-3.1.0.tgz", + "integrity": "sha512-baSkW45/VrVvl1e27qvxEZX2z5vApiINaGUTIxx0c4H5E2Lr22QnogZrebqIhyfspZ466XKhp245nyS0HSkAYg==", + "dependencies": { + "@azure/functions": "^4.0.0", + "axios": "^1.6.1", + "debug": "~2.6.9", + "lodash": "^4.17.15", + "moment": "^2.29.2", + "uuid": "~3.3.2", + "validator": "~13.7.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/durable-functions/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/durable-functions/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/durable-functions/node_modules/uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.335", "dev": true, @@ -3259,14 +3310,15 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.2", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -3475,16 +3527,6 @@ "node": ">=10.17.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.2.4", "dev": true, @@ -4497,7 +4539,6 @@ }, "node_modules/lodash": { "version": "4.17.21", - "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { @@ -4622,6 +4663,14 @@ "node": "*" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.2", "license": "MIT" @@ -5127,6 +5176,7 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", + "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -5555,6 +5605,17 @@ "node": ">=4.2.0" } }, + "node_modules/undici": { + "version": "5.28.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", + "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -5614,6 +5675,7 @@ "node_modules/uuid": { "version": "8.3.2", "license": "MIT", + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -5639,6 +5701,14 @@ "node": ">= 8" } }, + "node_modules/validator": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "dev": true, diff --git a/package.json b/package.json index 058c03e..09d384b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@senacor/azure-function-middleware", - "version": "2.3.1", + "version": "3.0.0-alpha.0", "description": "Middleware for azure functions to handle authentication, authorization, error handling and logging", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -39,13 +39,14 @@ "typescript": "^4.5.4" }, "dependencies": { - "@azure/functions": "^3.0.0", + "@azure/functions": "^4.0.0", "@types/node": "^20.8.10", "axios": "^1.1.3", "jwt-decode": "^4.0.0" }, "peerDependencies": { "applicationinsights": "^2.5.0", + "durable-functions": "^3.1.0", "joi": "^17.9.1" } } diff --git a/src/appInsights/Logger.ts b/src/appInsights/Logger.ts index d96536d..cac5dae 100644 --- a/src/appInsights/Logger.ts +++ b/src/appInsights/Logger.ts @@ -1,66 +1,75 @@ -import { Logger } from '@azure/functions'; import { TelemetryClient } from 'applicationinsights'; import { SeverityLevel } from 'applicationinsights/out/Declarations/Contracts'; import { stringify } from '../util/stringify'; -const consoleDefaultLog = (...args: unknown[]): void => { - console.log(args); -}; - -consoleDefaultLog.error = (...args: unknown[]): void => { - console.error(args); -}; - -consoleDefaultLog.warn = (...args: unknown[]): void => { - console.warn(args); -}; +interface Logger { + log(...args: any[]): void; + trace(...args: any[]): void; + debug(...args: any[]): void; + info(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} -consoleDefaultLog.info = (...args: unknown[]): void => { - console.info(args); +export const consoleLogger: Logger = { + debug(...args: any[]): void { + console.debug(args); + }, + error(...args: any[]): void { + console.error(args); + }, + info(...args: any[]): void { + console.info(args); + }, + log(...args: any[]): void { + console.log(args); + }, + trace(...args: any[]): void { + console.trace(args); + }, + warn(...args: any[]): void { + console.warn(args); + }, }; -consoleDefaultLog.verbose = (...args: unknown[]): void => { - console.debug(args); -}; - -export const consoleLogger: Logger = consoleDefaultLog; - export const createAppInsightsLogger = (telemetryClient: TelemetryClient): Logger => { - const appInsightsLogger = (...args: unknown[]): void => { - telemetryClient.trackTrace({ - message: stringify(args), - severity: SeverityLevel.Information, - }); + return { + debug(...args: any[]): void { + telemetryClient.trackTrace({ + message: stringify(args), + severity: SeverityLevel.Information, + }); + }, + error(...args: any[]): void { + telemetryClient.trackTrace({ + message: stringify(args), + severity: SeverityLevel.Error, + }); + }, + info(...args: any[]): void { + telemetryClient.trackTrace({ + message: stringify(args), + severity: SeverityLevel.Information, + }); + }, + log(...args: any[]): void { + telemetryClient.trackTrace({ + message: stringify(args), + severity: SeverityLevel.Information, + }); + }, + trace(...args: any[]): void { + telemetryClient.trackTrace({ + message: stringify(args), + severity: SeverityLevel.Information, + }); + }, + warn(...args: any[]): void { + telemetryClient.trackTrace({ + message: stringify(args), + severity: SeverityLevel.Warning, + }); + }, }; - - appInsightsLogger.error = (...args: unknown[]): void => { - telemetryClient.trackTrace({ - message: stringify(args), - severity: SeverityLevel.Error, - }); - }; - - appInsightsLogger.warn = (...args: unknown[]): void => { - telemetryClient.trackTrace({ - message: stringify(args), - severity: SeverityLevel.Warning, - }); - }; - - appInsightsLogger.info = (...args: unknown[]): void => { - telemetryClient.trackTrace({ - message: stringify(args), - severity: SeverityLevel.Information, - }); - }; - - appInsightsLogger.verbose = (...args: unknown[]): void => { - telemetryClient.trackTrace({ - message: stringify(args), - severity: SeverityLevel.Verbose, - }); - }; - - return appInsightsLogger; }; diff --git a/src/appInsights/appInsightsWrapper.ts b/src/appInsights/appInsightsWrapper.ts index 169b711..de756cc 100644 --- a/src/appInsights/appInsightsWrapper.ts +++ b/src/appInsights/appInsightsWrapper.ts @@ -1,12 +1,10 @@ -import { Context, HttpRequest } from '@azure/functions'; +import { FunctionHandler, HttpHandler, InvocationContext } from '@azure/functions'; import * as appInsights from 'applicationinsights'; import { TelemetryClient } from 'applicationinsights'; +import { ActivityHandler } from 'durable-functions'; -import { consoleLogger, createAppInsightsLogger } from './Logger'; - -type LogBehavior = 'always' | 'on_error' | 'on_success' | 'never'; -type LogDataSanitizer = (data: unknown) => unknown; -const noOpLogDataSanitizer: LogDataSanitizer = (data) => data; +import { BeforeExecutionFunction, PostExecutionFunction, isErrorResult } from '../middleware'; +import { createAppInsightsLogger } from './Logger'; const telemetryClients: { [key: string]: TelemetryClient } = {}; @@ -34,25 +32,32 @@ if (!isDisabled) { environment: process.env.ENVIRONMENT ?? 'UNDEFINED', ...appInsights.defaultClient.commonProperties, }; + appInsights.defaultClient.config.disableAppInsights = isDisabled; appInsights.start(); console.log('Started appInsights'); } -const setupTelemetryClient = (context: Context, additionalProperties: object) => { +const setupTelemetryClient = ( + req: unknown, + context: InvocationContext, + additionalProperties?: { + [key: string]: string; + }, +) => { context.log('Setting up AppInsights'); const telemetryClient = new TelemetryClient(); telemetryClient.setAutoPopulateAzureProperties(true); telemetryClients[context.invocationId] = telemetryClient; - context.log = createAppInsightsLogger(telemetryClient); + context = { ...context, ...createAppInsightsLogger(telemetryClient) }; - const { invocationId, sys } = context.bindingData; + const { invocationId, triggerMetadata } = context; telemetryClient.commonProperties = { invocationId, - sys, + ...(triggerMetadata !== undefined && triggerMetadata), ...additionalProperties, ...appInsights.defaultClient.commonProperties, }; @@ -60,23 +65,27 @@ const setupTelemetryClient = (context: Context, additionalProperties: object) => context.log('Set up AppInsights'); }; -const setupAppInsightForHttpTrigger = async (context: Context): Promise => { - if (isDisabled) { - context.log = consoleLogger; - return; - } +const setupAppInsightForHttpTrigger: BeforeExecutionFunction = async (req, context) => { + setupTelemetryClient(req, context); +}; - setupTelemetryClient(context, { params: context.req?.params }); +const setupAppInsightForNonHttpTrigger: BeforeExecutionFunction = async (req, context) => { + setupTelemetryClient(req, context); }; -const setupAppInsightForNonHttpTrigger = async (context: Context): Promise => { - if (isDisabled) { - context.log = consoleLogger; - return; - } +type FinalizeAppInsightWithConfig> = T extends ( + ...a: infer U +) => infer R + ? (...a: [...U, LogBehavior, LogDataSanitizer]) => R + : never; +type FinalizeAppInsightWithCurriedConfiguration = ( + logBodyBehavior: LogBehavior, + bodySanitizer: LogDataSanitizer, +) => PostExecutionFunction; - setupTelemetryClient(context, { workflowData: context.bindingData.workflowData }); -}; +type LogBehavior = 'always' | 'on_error' | 'on_success' | 'never'; +type LogDataSanitizer = (data: unknown) => unknown; +const noOpLogDataSanitizer: LogDataSanitizer = (data) => data; const shouldLog = (logBehavior: LogBehavior, isError: boolean) => { switch (logBehavior) { @@ -91,22 +100,21 @@ const shouldLog = (logBehavior: LogBehavior, isError: boolean) => { } }; -const finalizeAppInsightForHttpTrigger = async ( - context: Context, - req: HttpRequest, - logBodyBehavior: LogBehavior = 'on_error', - bodySanitizer: LogDataSanitizer = noOpLogDataSanitizer, +const finalizeAppInsightForHttpTrigger: PostExecutionFunction = async (req, context, result) => + finalizeAppInsightForHttpTriggerWithConfig(req, context, result, 'on_error', noOpLogDataSanitizer); +const finalizeAppInsightForHttpTriggerWithConfig: FinalizeAppInsightWithConfig = async ( + req, + context, + res, + logBodyBehavior, + bodySanitizer, ): Promise => { - if (isDisabled) { - return; - } - context.log('Finalizing AppInsights'); const telemetryClient = telemetryClients[context.invocationId]; if (telemetryClient === undefined) { - context.log.error(`No telemetry client could be found for invocationId ${context.invocationId}`); + context.error(`No telemetry client could be found for invocationId ${context.invocationId}`); return; } @@ -116,20 +124,31 @@ const finalizeAppInsightForHttpTrigger = async ( ...telemetryClient.commonProperties, }; - if (context.res === undefined) { - context.log.warn("context.res is empty and properly shouldn't be"); + if (res === undefined) { + context.warn("res is empty and properly shouldn't be"); + } + + if (isErrorResult(res)) { + return; + } + + const result = res.$result; + + if (result == undefined) { + context.warn("res is empty and properly shouldn't be"); + return; } - if (shouldLog(logBodyBehavior, context?.res?.status >= 400)) { - context.log('Request body:', context.req?.body ? bodySanitizer(context.req?.body) : 'NO_REQ_BODY'); - context.log('Response body:', context.res?.body ? bodySanitizer(context.res?.body) : 'NO_RES_BODY'); + if (shouldLog(logBodyBehavior, result.status ? result.status >= 400 : true)) { + context.log('Request body:', result.body ? bodySanitizer(result.body) : 'NO_REQ_BODY'); + context.log('Response body:', result.body ? bodySanitizer(result.body) : 'NO_RES_BODY'); } telemetryClient.trackRequest({ - name: context.executionContext.functionName, - resultCode: context.res?.status ?? '0', + name: context.functionName, + resultCode: result.status ?? '0', // important so that requests with a non-OK response show up as failed - success: context.res?.status < 400 ?? 'true', + success: result.status ? result.status < 400 : true, url: req.url, duration: 0, id: correlationContext?.operation?.id ?? 'UNDEFINED', @@ -140,14 +159,22 @@ const finalizeAppInsightForHttpTrigger = async ( context.log('Finalized AppInsights'); }; -const finalizeAppInsightForNonHttpTrigger = async (context: Context): Promise => { +const finalizeAppInsightForNonHttpTrigger: PostExecutionFunction = async (req, context, result) => + finalizeAppInsightForNonHttpTriggerWithConfig(req, context, result, 'on_error', noOpLogDataSanitizer); +const finalizeAppInsightForNonHttpTriggerWithConfig: FinalizeAppInsightWithConfig = async ( + req, + context, + result, + logBodyBehavior: LogBehavior = 'on_error', + bodySanitizer: LogDataSanitizer = noOpLogDataSanitizer, +): Promise => { if (isDisabled) { return; } const telemetryClient = telemetryClients[context.invocationId]; if (telemetryClient === undefined) { - context.log.error(`No telemetry client could be found for invocationId ${context.invocationId}`); + context.error(`No telemetry client could be found for invocationId ${context.invocationId}`); return; } @@ -158,11 +185,11 @@ const finalizeAppInsightForNonHttpTrigger = async (context: Context): Promise = { + setup: BeforeExecutionFunction; + finalize: PostExecutionFunction; + finalizeWithConfig: FinalizeAppInsightWithCurriedConfiguration; +}; + +const AppInsightForHttpTrigger: AppInsightsObject = { setup: setupAppInsightForHttpTrigger, - finalizeAppInsight: finalizeAppInsightForHttpTrigger, - finalizeWithConfig: - (logBodyBehavior: LogBehavior, bodySanitizer: LogDataSanitizer) => (context: Context, req: HttpRequest) => - finalizeAppInsightForHttpTrigger(context, req, logBodyBehavior, bodySanitizer), + finalize: finalizeAppInsightForHttpTrigger, + finalizeWithConfig: (logBodyBehavior: LogBehavior, bodySanitizer: LogDataSanitizer) => (req, context, res) => + finalizeAppInsightForHttpTriggerWithConfig(req, context, res, logBodyBehavior, bodySanitizer), }; -const AppInsightForNoNHttpTrigger = { +const AppInsightForNoNHttpTrigger: AppInsightsObject = { setup: setupAppInsightForNonHttpTrigger, - finalizeAppInsight: finalizeAppInsightForNonHttpTrigger, + finalize: finalizeAppInsightForNonHttpTrigger, + finalizeWithConfig: (logBodyBehavior: LogBehavior, bodySanitizer: LogDataSanitizer) => (req, context, res) => + finalizeAppInsightForHttpTriggerWithConfig(req, context, res, logBodyBehavior, bodySanitizer), }; export { AppInsightForHttpTrigger, AppInsightForNoNHttpTrigger, LogBehavior, LogDataSanitizer }; diff --git a/src/error/errorHandler.test.ts b/src/error/errorHandler.test.ts index b2e6bef..fd5c44c 100644 --- a/src/error/errorHandler.test.ts +++ b/src/error/errorHandler.test.ts @@ -1,16 +1,14 @@ -import { Context } from '@azure/functions'; +import { InvocationContext } from '@azure/functions'; import { mock } from 'jest-mock-extended'; import { ApplicationError } from './ApplicationError'; import { errorHandler as sut } from './errorHandler'; describe('Error-Handler should', () => { - const contextMock = mock(); + const contextMock = mock(); beforeEach(() => { jest.restoreAllMocks(); - - contextMock.log.error = jest.fn(); }); test('return an provided ApplicationError', () => { diff --git a/src/error/errorHandler.ts b/src/error/errorHandler.ts index cc1b492..0d431d2 100644 --- a/src/error/errorHandler.ts +++ b/src/error/errorHandler.ts @@ -1,4 +1,4 @@ -import { Context } from '@azure/functions'; +import { InvocationContext } from '@azure/functions'; import { Options } from '../middleware'; import { stringify } from '../util/stringify'; @@ -6,26 +6,26 @@ import { ApplicationError } from './ApplicationError'; export const errorHandler = ( error: unknown, - context: Context, + context: InvocationContext, opts?: Options, ): { [key: string]: unknown; } => { if (error instanceof ApplicationError) { - context.log.error(`Received application error with message ${error.message}`); + context.error(`Received application error with message ${error.message}`); return { status: error.status, - body: error.body, + jsonBody: error.body, }; } const errorAsString = stringify(error); - context.log.error(errorAsString); + context.error(errorAsString); if (opts?.errorResponseHandler === undefined) { return { status: 500, - body: { + jsonBody: { message: 'Internal server error', }, }; diff --git a/src/headerAuthentication.test.ts b/src/headerAuthentication.test.ts index 7573b47..f833262 100644 --- a/src/headerAuthentication.test.ts +++ b/src/headerAuthentication.test.ts @@ -1,12 +1,14 @@ -import { Context, HttpRequest } from '@azure/functions'; -import { mock } from 'jest-mock-extended'; +import { HttpHandler, HttpRequest, InvocationContext } from '@azure/functions'; +import { mock, mockDeep } from 'jest-mock-extended'; import { ApplicationError } from './error'; import sut from './headerAuthentication'; +import { MiddlewareResult } from './middleware'; describe('The header authentication middleware should', () => { - const contextMock = mock(); - const requestMock = mock(); + const contextMock = mock(); + const requestMock = mockDeep(); + const initialMiddlewareResult: MiddlewareResult> = { $failed: false, $result: undefined }; beforeEach(() => { jest.restoreAllMocks(); @@ -14,13 +16,17 @@ describe('The header authentication middleware should', () => { }); test('successfully resolves when the default "x-ms-client-principal" header is present', async () => { - requestMock.headers['x-ms-client-principal-id'] = 'Test Principal'; + requestMock.headers.get.mockImplementationOnce((name) => + name === 'x-ms-client-principal-id' ? 'Test Principal' : null, + ); - await expect(sut()(contextMock, requestMock)).resolves.not.toThrow(); + await expect(sut()(requestMock, contextMock, initialMiddlewareResult)).resolves.not.toThrow(); }); test('successfully resolves when the passed header validation function returns true', async () => { - await expect(sut(() => true)(contextMock, requestMock)).resolves.not.toThrow(); + await expect( + sut({ validateUsingHeaderFn: () => true })(requestMock, contextMock, initialMiddlewareResult), + ).resolves.not.toThrow(); }); test('fail caused by missing default "x-ms-client-principal" header', async () => { @@ -29,7 +35,7 @@ describe('The header authentication middleware should', () => { // @ts-ignore requestMock.headers['x-ms-client-principal-id'] = undefined; - await expect(sut()(contextMock, requestMock)).rejects.toEqual( + await expect(sut()(requestMock, contextMock, initialMiddlewareResult)).rejects.toEqual( new ApplicationError('Authentication error', 403, 'No sophisticated credentials provided'), ); }); @@ -41,19 +47,53 @@ describe('The header authentication middleware should', () => { requestMock.headers['x-ms-client-principal-id'] = undefined; await expect( - sut(undefined, { error: 'Please authenticate properly' })(contextMock, requestMock), + sut({ errorResponseBody: { error: 'Please authenticate properly' } })( + requestMock, + contextMock, + initialMiddlewareResult, + ), ).rejects.toEqual(new ApplicationError('Authentication error', 403, { error: 'Please authenticate properly' })); }); test('fail caused by passed header validation function returns false', async () => { - await expect(sut(() => false)(contextMock, requestMock)).rejects.toEqual( - new ApplicationError('Authentication error', 403, 'No sophisticated credentials provided'), - ); + await expect( + sut({ validateUsingHeaderFn: () => false })(requestMock, contextMock, initialMiddlewareResult), + ).rejects.toEqual(new ApplicationError('Authentication error', 403, 'No sophisticated credentials provided')); }); test('fail caused by passed header validation function returns false and use the provided error body', async () => { await expect( - sut(() => false, { error: 'Please authenticate properly' })(contextMock, requestMock), + sut({ validateUsingHeaderFn: () => false, errorResponseBody: { error: 'Please authenticate properly' } })( + requestMock, + contextMock, + initialMiddlewareResult, + ), + ).rejects.toEqual(new ApplicationError('Authentication error', 403, { error: 'Please authenticate properly' })); + }); + + test('do nothing if the passed result indicates an error in a prev function and skipIfResultIsFaulty is true', async () => { + await expect( + sut({ + validateUsingHeaderFn: () => false, + errorResponseBody: { error: 'Please authenticate properly' }, + skipIfResultIsFaulty: true, + })(requestMock, contextMock, { + $failed: true, + $error: Error(), + }), + ).resolves.not.toThrow(); + }); + + test('execute the function even if the passed result indicates an error in a prev function, but skipIfResultIsFaulty is false', async () => { + await expect( + sut({ + validateUsingHeaderFn: () => false, + errorResponseBody: { error: 'Please authenticate properly' }, + skipIfResultIsFaulty: false, + })(requestMock, contextMock, { + $failed: true, + $error: Error(), + }), ).rejects.toEqual(new ApplicationError('Authentication error', 403, { error: 'Please authenticate properly' })); }); }); diff --git a/src/headerAuthentication.ts b/src/headerAuthentication.ts index 9d78171..23d8ca8 100644 --- a/src/headerAuthentication.ts +++ b/src/headerAuthentication.ts @@ -1,39 +1,45 @@ -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; -import { HttpRequestHeaders } from '@azure/functions/types/http'; +import { HttpHandler } from '@azure/functions'; +import { Headers } from 'undici'; import { ApplicationError } from './error'; +import { BeforeExecutionFunction } from './middleware'; -export default ( - validateUsingHeaderFn?: (headers: HttpRequestHeaders) => boolean, - errorResponseBody?: unknown, -): AzureFunction => { - return (context: Context, req: HttpRequest): Promise => { - if (validateUsingHeaderFn) { - const validationResult = validateUsingHeaderFn(req.headers); - if (validationResult) { - return Promise.resolve(); - } else { - return Promise.reject( - new ApplicationError( - 'Authentication error', - 403, - errorResponseBody ?? 'No sophisticated credentials provided', - ), - ); - } +type ValidationFunction = (headers: Headers) => boolean; + +const defaultHeaderValidation: ValidationFunction = (headers) => !!headers.get('x-ms-client-principal-id'); +const defaultErrorResponseBody = 'No sophisticated credentials provided'; + +export type HeaderAuthenticationOptions = { + validateUsingHeaderFn: ValidationFunction; + errorResponseBody: unknown; + skipIfResultIsFaulty: boolean; +}; + +export default (opts?: Partial): BeforeExecutionFunction => { + const validateUsingHeaderFn = opts?.validateUsingHeaderFn ?? defaultHeaderValidation; + const errorResponseBody = opts?.errorResponseBody ?? defaultErrorResponseBody; + const skipIfResultIsFaulty = opts?.skipIfResultIsFaulty ?? true; + + return (req, context, result): Promise => { + if (skipIfResultIsFaulty && result.$failed) { + context.info('Skipping header-authentication because the result is faulty.'); + return Promise.resolve(); + } + + context.info('Executing header authentication.'); + const validationResult = validateUsingHeaderFn(req.headers); + if (validationResult) { + context.info('Header authentication was successful.'); + return Promise.resolve(); } else { - const authenticationHeader = req.headers['x-ms-client-principal-id']; - if (!!authenticationHeader) { - return Promise.resolve(); - } else { - return Promise.reject( - new ApplicationError( - 'Authentication error', - 403, - errorResponseBody ?? 'No sophisticated credentials provided', - ), - ); - } + context.info('Header authentication was NOT successful.'); + return Promise.reject( + new ApplicationError( + 'Authentication error', + 403, + errorResponseBody ?? 'No sophisticated credentials provided', + ), + ); } }; }; diff --git a/src/jwtAuthorization.test.ts b/src/jwtAuthorization.test.ts index 35fd997..09f4c7e 100644 --- a/src/jwtAuthorization.test.ts +++ b/src/jwtAuthorization.test.ts @@ -1,15 +1,17 @@ -import { Context, HttpRequest } from '@azure/functions'; -import { mock } from 'jest-mock-extended'; +import { HttpHandler, HttpRequest, InvocationContext } from '@azure/functions'; +import { mock, mockDeep } from 'jest-mock-extended'; import * as JWTDecoder from 'jwt-decode'; import { ApplicationError } from './error'; import sut from './jwtAuthorization'; +import { MiddlewareResult } from './middleware'; jest.mock('jwt-decode'); const jwtMock = JWTDecoder as jest.Mocked; describe('The authorization middleware should', () => { - const contextMock = mock(); - const requestMock = mock(); + const contextMock = mock(); + const requestMock = mockDeep(); + const initialMiddlewareResult: MiddlewareResult> = { $failed: false, $result: undefined }; beforeEach(() => { jest.restoreAllMocks(); @@ -17,7 +19,7 @@ describe('The authorization middleware should', () => { }); test('successfully validate the passed authorization token', async () => { - requestMock.headers.authorization = 'Bearer token'; + requestMock.headers.get.mockImplementationOnce((name) => (name === 'authorization' ? 'Bearer token' : null)); jwtMock.jwtDecode.mockReturnValue('JWT-TEST'); await sut([ @@ -28,20 +30,21 @@ describe('The authorization middleware should', () => { }, parameterExtractor: () => 'test', }, - ])(contextMock, requestMock); + ])(requestMock, contextMock, initialMiddlewareResult); - expect(contextMock.bindingData).toHaveProperty('jwt', 'JWT-TEST'); + expect(contextMock.extraInputs).toHaveProperty('jwt', 'JWT-TEST'); }); test('fail caused by missing authorization header', async () => { - requestMock.headers.authorization = ''; + requestMock.headers.get.mockReturnValue(null); const jwtExtractorMock = jest.fn(); const parameterExtractorMock = jest.fn(); await expect( sut([{ jwtExtractor: jwtExtractorMock, parameterExtractor: parameterExtractorMock }])( - contextMock, requestMock, + contextMock, + initialMiddlewareResult, ), ).rejects.toEqual(new ApplicationError('Authorization error', 401)); @@ -49,14 +52,16 @@ describe('The authorization middleware should', () => { }); test('fail caused by a incorrectly formatted authorization header', async () => { - requestMock.headers.authorization = 'Bearer'; + requestMock.headers.get.mockImplementationOnce((name) => (name === 'authorization' ? 'Bearer' : null)); + const jwtExtractorMock = jest.fn(); const parameterExtractorMock = jest.fn(); await expect( sut([{ jwtExtractor: jwtExtractorMock, parameterExtractor: parameterExtractorMock }])( - contextMock, requestMock, + contextMock, + initialMiddlewareResult, ), ).rejects.toEqual(new ApplicationError('Authorization error', 401)); @@ -64,7 +69,7 @@ describe('The authorization middleware should', () => { }); test('fail caused by second rule failing and therefore chaining failed', async () => { - requestMock.headers.authorization = 'Bearer token'; + requestMock.headers.get.mockImplementationOnce((name) => (name === 'authorization' ? 'Bearer' : null)); jwtMock.jwtDecode.mockReturnValue('JWT-TEST'); await expect( @@ -77,7 +82,7 @@ describe('The authorization middleware should', () => { jwtExtractor: () => 'failed', parameterExtractor: () => 'test', }, - ])(contextMock, requestMock), + ])(requestMock, contextMock, initialMiddlewareResult), ).rejects.toEqual(new ApplicationError('Authorization error', 401, 'Unauthorized')); }); }); diff --git a/src/jwtAuthorization.ts b/src/jwtAuthorization.ts index b135eb2..aa51e3d 100644 --- a/src/jwtAuthorization.ts +++ b/src/jwtAuthorization.ts @@ -1,23 +1,40 @@ -import { AzureFunction, Context, ContextBindingData, HttpRequest } from '@azure/functions'; +import { HttpHandler } from '@azure/functions'; +import { HttpRequestParams } from '@azure/functions/types/http'; import { jwtDecode } from 'jwt-decode'; import { ApplicationError } from './error'; +import { BeforeExecutionFunction } from './middleware'; -const evaluate = (rule: Rule, parameters: ContextBindingData, jwt: T) => { +const evaluate = (rule: Rule, parameters: HttpRequestParams, jwt: T) => { const pathParameter = rule.parameterExtractor(parameters); const jwtParameter = rule.jwtExtractor(jwt); return pathParameter === jwtParameter; }; export type Rule = { - parameterExtractor: (parameters: ContextBindingData) => string; + parameterExtractor: (parameters: HttpRequestParams) => string; jwtExtractor: (jwt: T) => string; }; -export default (rules: Rule[], errorResponseBody?: unknown): AzureFunction => { - return (context: Context, req: HttpRequest): Promise => { - const authorizationHeader = req.headers.authorization; - const parameters = context.bindingData; +export type JwtAuthorizationOptions = { + skipIfResultIsFaulty: boolean; +}; + +export default ( + rules: Rule[], + errorResponseBody?: unknown, + opts?: Partial, +): BeforeExecutionFunction => { + const skipIfResultIsFaulty = opts?.skipIfResultIsFaulty ?? true; + + return (req, context, result) => { + if (skipIfResultIsFaulty && result.$failed) { + context.info('Skipping jwt-authorization because the result is faulty.'); + return Promise.resolve(); + } + + const authorizationHeader = req.headers.get('authorization'); + const parameters = req.params; if (authorizationHeader) { const token = authorizationHeader.split(' ')[1]; if (token) { @@ -30,7 +47,7 @@ export default (rules: Rule[], errorResponseBody?: unknown): AzureFunction new ApplicationError('Authorization error', 401, errorResponseBody ?? 'Unauthorized'), ); } else { - context.bindingData = { ...context.bindingData, ...{ jwt } }; + context.extraInputs = { ...context.extraInputs, ...{ jwt } }; return Promise.resolve(); } } diff --git a/src/middleware.test.ts b/src/middleware.test.ts index f97e056..7b8c0aa 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,25 +1,23 @@ -import { Context, HttpRequest } from '@azure/functions'; +import { HttpRequest, InvocationContext } from '@azure/functions'; import { mock } from 'jest-mock-extended'; import { ApplicationError } from './error'; import { middleware as sut } from './middleware'; describe('The middleware layer should', () => { - const contextMock = mock(); + const contextMock = mock(); const requestMock = mock(); beforeEach(() => { jest.restoreAllMocks(); - - contextMock.log.error = jest.fn(); }); test('successfully call the passed functions without any middleware passed', async () => { const handlerMock = jest.fn(); - await sut([], handlerMock, [])(contextMock, requestMock); + await sut([], handlerMock, [])(requestMock, contextMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); }); test('successfully call the middleware and the passed functions', async () => { @@ -27,11 +25,11 @@ describe('The middleware layer should', () => { const middlewareOneMock = jest.fn(); const middlewareTwoMock = jest.fn(); - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(contextMock, requestMock); + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(requestMock, contextMock); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); }); test('successfully call the pre middleware and post middleware and the passed functions', async () => { @@ -40,110 +38,110 @@ describe('The middleware layer should', () => { const middlewareTwoMock = jest.fn(); const middlewarePostMock = jest.fn(); - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [middlewarePostMock])(contextMock, requestMock); + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [middlewarePostMock])(requestMock, contextMock); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewarePostMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewarePostMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); }); - test('fail when one middleware is failing', async () => { + test('return an error when one middleware is failing', async () => { const handlerMock = jest.fn(); const middlewareOneMock = jest.fn(); const middlewareTwoMock = jest.fn(); middlewareOneMock.mockRejectedValue(Error()); - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(contextMock, requestMock); + const res = await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(requestMock, contextMock); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).not.toBeCalled(); + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $error: Error(), $failed: true }); expect(handlerMock).not.toBeCalled(); - expect(contextMock.res).toEqual({ + expect(res).toEqual({ status: 500, body: { message: 'Internal server error', }, }); - expect(contextMock.log.error).toBeCalled(); + expect(contextMock.error).toBeCalled(); }); - test('fail when the second middleware is failing', async () => { + test('return an error when second middleware is failing', async () => { const handlerMock = jest.fn(); const middlewareOneMock = jest.fn(); const middlewareTwoMock = jest.fn(); middlewareTwoMock.mockRejectedValue(Error()); - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(contextMock, requestMock); + const res = await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(requestMock, contextMock); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); expect(handlerMock).not.toBeCalled(); - expect(contextMock.res).toEqual({ + expect(res).toEqual({ status: 500, body: { message: 'Internal server error', }, }); - expect(contextMock.log.error).toBeCalled(); + expect(contextMock.error).toBeCalled(); }); - test('fail when the handler function is failing', async () => { + test('return an error when the handler function is failing', async () => { const handlerMock = jest.fn(); const middlewareOneMock = jest.fn(); const middlewareTwoMock = jest.fn(); handlerMock.mockRejectedValue(Error()); - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(contextMock, requestMock); + const res = await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(requestMock, contextMock); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(contextMock.res).toEqual({ + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); + expect(res).toEqual({ status: 500, body: { message: 'Internal server error', }, }); - expect(contextMock.log.error).toBeCalled(); + expect(contextMock.error).toBeCalled(); }); - test('fail when the handler function is failing and a error with status is returned', async () => { + test('return an error when the handler function is failing and a error with status is returned', async () => { const handlerMock = jest.fn(); const middlewareOneMock = jest.fn(); const middlewareTwoMock = jest.fn(); handlerMock.mockRejectedValue(new ApplicationError('Validation Error', 401, 'test-body')); - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(contextMock, requestMock); + const res = await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(requestMock, contextMock); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(contextMock.res).toEqual({ + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); + expect(res).toEqual({ status: 401, body: 'test-body', }); - expect(contextMock.log.error).toBeCalled(); + expect(contextMock.error).toBeCalled(); }); - test('fail when the handler function is failing, but execute the post-execution-functions', async () => { + test('return an error if the handler function is failing, but execute the post-execution-functions', async () => { const handlerMock = jest.fn(); const middlewareOneMock = jest.fn(); const middlewareTwoMock = jest.fn(); handlerMock.mockRejectedValue(Error()); - await sut([middlewareOneMock], handlerMock, [middlewareTwoMock])(contextMock, requestMock); + const res = await sut([middlewareOneMock], handlerMock, [middlewareTwoMock])(requestMock, contextMock); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(contextMock.res).toEqual({ + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); + expect(res).toEqual({ status: 500, body: { message: 'Internal server error', }, }); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(contextMock.log.error).toBeCalled(); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $error: Error(), $failed: true }); + expect(contextMock.error).toBeCalled(); }); test('use the provided error-handle to create a response', async () => { @@ -160,92 +158,20 @@ describe('The middleware layer should', () => { }; }; - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [middlewarePostFunction], { + const res = await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [middlewarePostFunction], { errorResponseHandler, - })(contextMock, requestMock); + })(requestMock, contextMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(contextMock.res).toEqual({ + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); + expect(res).toEqual({ status: 1337, body: 'My custom error response', }); - expect(contextMock.log.error).toBeCalled(); - // expect(middlewarePostFunction).toBeCalled(); TODO: Fix later - }); - - test('fail when the first middleware (beforeExecution) is failing', async () => { - const handlerMock = jest.fn(); - const middlewareOneMock = jest.fn(); - const middlewareTwoMock = jest.fn(); - middlewareOneMock.mockRejectedValue(Error()); - - await expect( - async () => - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( - contextMock, - requestMock, - ), - ).rejects.toThrow(); - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).not.toBeCalled(); - expect(handlerMock).not.toBeCalled(); - }); - - test('fail when the second middleware (beforeExecution) is failing', async () => { - const handlerMock = jest.fn(); - const middlewareOneMock = jest.fn(); - const middlewareTwoMock = jest.fn(); - middlewareTwoMock.mockRejectedValue(Error()); - - await expect( - async () => - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( - contextMock, - requestMock, - ), - ).rejects.toThrow(); - - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).not.toBeCalled(); - }); - - test('fail when the handler function is failing', async () => { - const handlerMock = jest.fn(); - const middlewareOneMock = jest.fn(); - const middlewareTwoMock = jest.fn(); - handlerMock.mockRejectedValue(Error()); - - await expect( - async () => - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( - contextMock, - requestMock, - ), - ).rejects.toThrow(); - - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - }); - - test('fail when the handler function is failing and a error with status is returned', async () => { - const handlerMock = jest.fn(); - const middlewareOneMock = jest.fn(); - const middlewareTwoMock = jest.fn(); - handlerMock.mockRejectedValue(new ApplicationError('Validation Error', 401, 'test-body')); - - await expect( - async () => - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( - contextMock, - requestMock, - ), - ).rejects.toThrow(); - - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(contextMock.error).toBeCalled(); + expect(middlewarePostFunction).toHaveBeenCalledWith(requestMock, contextMock, { + $error: Error(), + $failed: true, + }); }); test('dynamically filter items, which resolve to false', async () => { @@ -260,12 +186,95 @@ describe('The middleware layer should', () => { await sut([excludeFunction() && middlewareOneMock, middlewareTwoMock], handlerMock, [ includeFunction() && middlewarePostOneMock, excludeFunction() && middlewarePostTwoMock, - ])(contextMock, requestMock); + ])(requestMock, contextMock); + + expect(middlewareOneMock).not.toHaveBeenCalled(); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); + expect(middlewarePostOneMock).toHaveBeenCalledWith(requestMock, contextMock, { + $failed: false, + $result: undefined, + }); + expect(middlewarePostTwoMock).not.toHaveBeenCalled(); + }); + + describe('fail if error-handling is disabled and', () => { + test('the first middleware (beforeExecution) is failing', async () => { + const handlerMock = jest.fn(); + const middlewareOneMock = jest.fn(); + const middlewareTwoMock = jest.fn(); + middlewareOneMock.mockRejectedValue(Error()); + + await expect( + async () => + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( + requestMock, + contextMock, + ), + ).rejects.toThrow(); + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { + $failed: true, + $error: Error(), + }); + expect(handlerMock).not.toBeCalled(); + }); - expect(middlewareOneMock).not.toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewarePostOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewarePostTwoMock).not.toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + test('the second middleware (beforeExecution) is failing', async () => { + const handlerMock = jest.fn(); + const middlewareOneMock = jest.fn(); + const middlewareTwoMock = jest.fn(); + middlewareTwoMock.mockRejectedValue(Error()); + + await expect( + async () => + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( + requestMock, + contextMock, + ), + ).rejects.toThrow(); + + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).not.toBeCalled(); + }); + + test('the handler function is failing', async () => { + const handlerMock = jest.fn(); + const middlewareOneMock = jest.fn(); + const middlewareTwoMock = jest.fn(); + handlerMock.mockRejectedValue(Error()); + + await expect( + async () => + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( + requestMock, + contextMock, + ), + ).rejects.toThrow(); + + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); + }); + + test('the handler function is failing and a error with status is returned', async () => { + const handlerMock = jest.fn(); + const middlewareOneMock = jest.fn(); + const middlewareTwoMock = jest.fn(); + handlerMock.mockRejectedValue(new ApplicationError('Validation Error', 401, 'test-body')); + + await expect( + async () => + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( + requestMock, + contextMock, + ), + ).rejects.toThrow(); + + expect(middlewareOneMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(middlewareTwoMock).toHaveBeenCalledWith(requestMock, contextMock, { $failed: false }); + expect(handlerMock).toHaveBeenCalledWith(requestMock, contextMock); + }); }); }); diff --git a/src/middleware.ts b/src/middleware.ts index ba05513..d9924ca 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,106 +1,121 @@ -import { AzureFunction, Context } from '@azure/functions'; +import { FunctionHandler, HttpRequest, InvocationContext } from '@azure/functions'; import { errorHandler } from './error'; +import { stringify } from './util/stringify'; -type ErrorResult = { $failed: true; $error: unknown }; +export type ExceptionalResult = { $failed: true; $error: Error }; +export type MiddlewareResult = ExceptionalResult | { $failed: false; $result: Awaited }; -const isErrorResult = (result: unknown | ErrorResult): result is ErrorResult => (result as ErrorResult)?.$failed; +export type BeforeExecutionFunction = T extends (...a: infer U) => infer R + ? (...a: [...U, MiddlewareResult]) => unknown + : never; + +export type PostExecutionFunction = BeforeExecutionFunction; + +export const isErrorResult = (result: MiddlewareResult | ExceptionalResult): result is ExceptionalResult => + (result as ExceptionalResult)?.$failed; const middlewareCore = - (beforeExecution: (AzureFunction | false)[], handler: AzureFunction, postExecution: (AzureFunction | false)[]) => - async (context: Context, ...args: unknown[]): Promise => { - let error = undefined; + ( + beforeExecution: (BeforeExecutionFunction | false)[], + handler: T, + postExecution: (PostExecutionFunction | false)[], + ) => + async (request: HttpRequest, context: InvocationContext): Promise | Error> => { + let handlerResult: MiddlewareResult> = { $failed: false, $result: undefined }; if (beforeExecution) { const middlewareFunctions = beforeExecution.filter( - (predicate): predicate is AzureFunction => predicate !== false, + (predicate): predicate is BeforeExecutionFunction => predicate !== false, ); for (const middlewareFunction of middlewareFunctions) { try { - // TODO: Give before-execution functions a parameter to indicate if the handler failed. So each function has the ability to decide for itself, if it should get executed. - if (error === undefined) { - await middlewareFunction(context, ...args); - } + await middlewareFunction(request, context, handlerResult); } catch (err) { - error = err; + if (err instanceof Error) { + handlerResult = { $failed: true, $error: err }; + } } } } - let handlerResult; - - if (error === undefined) { + if (!handlerResult.$failed) { try { - handlerResult = await handler(context, ...args); + handlerResult = { $failed: false, $result: await handler(request, context) }; } catch (err) { - error = err; + if (err instanceof Error) { + handlerResult = { $failed: true, $error: err }; + } } } - // TODO: Give post-execution functions a parameter to indicate if the handler failed. So each function has the ability to decide for itself, if it should get executed. if (postExecution) { const middlewareFunctions = postExecution.filter( - (predicate): predicate is AzureFunction => predicate !== false, + (predicate): predicate is PostExecutionFunction => predicate !== false, ); for (const middlewareFunction of middlewareFunctions) { - await middlewareFunction(context, ...args); + await middlewareFunction(request, context, handlerResult); } } - if (error !== undefined) { - context.log.error(`An uncaught error occurred in the execution of the handler: ${error}`); - return { $failed: true, $error: error }; + if (isErrorResult(handlerResult)) { + context.error(`An uncaught error occurred in the execution of the handler: ${stringify(handlerResult)}`); + return handlerResult.$error; } - return handlerResult; + + if (handlerResult.$result === undefined) { + return new Error('Illegal-State: Result of the handler was empty.'); + } + + return handlerResult.$result; }; export type Options = { errorResponseHandler?: ( error: unknown, - context: Context, + context: InvocationContext, ) => { [key: string]: unknown; }; disableErrorHandling?: boolean; }; -async function middlewareWrapper( - beforeExecution: (AzureFunction | false)[], - handler: (context: Context, ...args: unknown[]) => Promise | void, - postExecution: (AzureFunction | false)[], - context: Context, - args: unknown[], +async function middlewareWrapper( + beforeExecution: (BeforeExecutionFunction | false)[], + handler: T, + postExecution: (PostExecutionFunction | false)[], + request: any, + context: InvocationContext, opts?: Options, ) { - const result = await middlewareCore(beforeExecution, handler, postExecution)(context, ...args); + const result = await middlewareCore(beforeExecution, handler, postExecution)(request, context); - if (isErrorResult(result)) { + if (result instanceof Error) { if (opts?.disableErrorHandling) { - throw result.$error; + throw result; } - context.res = errorHandler(result.$error, context, opts); - return; + return errorHandler(result, context, opts); } return result; } export const middleware = - ( - beforeExecution: (AzureFunction | false)[], - handler: AzureFunction, - postExecution: (AzureFunction | false)[], + ( + beforeExecution: (BeforeExecutionFunction | false)[], + handler: T, + postExecution: (PostExecutionFunction | false)[], opts?: Options, ) => - async (context: Context, ...args: unknown[]): Promise => { + async (request: HttpRequest, context: InvocationContext): Promise => { if (opts?.disableErrorHandling) { - return await middlewareWrapper(beforeExecution, handler, postExecution, context, args, opts); + return await middlewareWrapper(beforeExecution, handler, postExecution, request, context, opts); } try { - return await middlewareWrapper(beforeExecution, handler, postExecution, context, args, opts); + return await middlewareWrapper(beforeExecution, handler, postExecution, request, context, opts); } catch (error) { - context.res = errorHandler(error, context, opts); + return errorHandler(error, context, opts); } }; diff --git a/src/validation.test.ts b/src/validation.test.ts index 14d35a1..8fbfe8f 100644 --- a/src/validation.test.ts +++ b/src/validation.test.ts @@ -1,65 +1,70 @@ -import { Context, HttpRequest } from '@azure/functions'; +import { HttpHandler, HttpRequest, InvocationContext } from '@azure/functions'; import { mockDeep } from 'jest-mock-extended'; import Joi from 'joi'; import { ApplicationError } from './error'; +import { MiddlewareResult } from './middleware'; import { requestValidation, responseValidation } from './validation'; describe('The requestValidation should', () => { const exampleSchema = Joi.object({ example: Joi.string().required() }); - const contextMock = mockDeep(); + const contextMock = mockDeep(); const requestMock = mockDeep(); + const initialMiddlewareResult: MiddlewareResult> = { $failed: false, $result: undefined }; - beforeEach(() => { - contextMock.log.verbose = jest.fn(); - jest.restoreAllMocks(); - }); + // https://github.com/marchaos/jest-mock-extended/issues/29 + const requestMethodMock = jest.fn(); + Object.defineProperty(requestMock, 'method', { get: requestMethodMock }); + + const requestBodyMock = jest.fn(); + Object.defineProperty(requestMock, 'body', { get: requestBodyMock }); test('successfully validate the passed object', async () => { - requestMock.method = 'POST'; - requestMock.body = { example: 'test-body' }; + requestMethodMock.mockReturnValue('POST'); + requestBodyMock.mockReturnValue({ example: 'test-body' }); - const result = await requestValidation(exampleSchema)(contextMock, requestMock); + const result = await requestValidation(exampleSchema)(requestMock, contextMock, initialMiddlewareResult); expect(result).toBeUndefined(); }); test('successfully validate the object extracted through the passed function', async () => { - requestMock.method = 'GET'; + requestMethodMock.mockReturnValue('GET'); const result = await requestValidation(exampleSchema, { extractValidationContentFromRequest: () => ({ example: 'test-extracted-content', }), - })(contextMock, requestMock); + })(requestMock, contextMock, initialMiddlewareResult); expect(result).toBeUndefined(); }); test('fail when the validation was not successful', async () => { - requestMock.method = 'POST'; - requestMock.body = 'test-body'; + requestMethodMock.mockReturnValue('POST'); + requestBodyMock.mockReturnValue('test-body'); - await expect(requestValidation(exampleSchema)(contextMock, requestMock)).rejects.toThrowError( - new ApplicationError('Validation Error', 400), - ); + await expect( + requestValidation(exampleSchema)(requestMock, contextMock, initialMiddlewareResult), + ).rejects.toThrowError(new ApplicationError('Validation Error', 400)); }); test('do not throw an error, even when the validation was not successful, if throwing is disabled', async () => { - requestMock.method = 'POST'; - requestMock.body = 'test-body'; - - requestMock.method = 'POST'; - requestMock.body = 'test-body'; + requestMethodMock.mockReturnValue('POST'); + requestBodyMock.mockReturnValue({ example: 'test-body' }); await expect( - requestValidation(exampleSchema, { shouldThrowOnValidationError: false })(contextMock, requestMock), + requestValidation(exampleSchema, { shouldThrowOnValidationError: false })( + requestMock, + contextMock, + initialMiddlewareResult, + ), ).resolves.toEqual(undefined); }); test('fail when the validation was not successful with transformed error message', async () => { - requestMock.method = 'POST'; - requestMock.body = { fail: 'test-body' }; + requestMethodMock.mockReturnValue('POST'); + requestBodyMock.mockReturnValue({ example: { fail: 'test-body' } }); await expect( requestValidation(exampleSchema, { @@ -67,79 +72,79 @@ describe('The requestValidation should', () => { type: 'Validation Error', message, }), - })(contextMock, requestMock), + })(requestMock, contextMock, initialMiddlewareResult), ).rejects.toThrowError(new ApplicationError('Validation Error', 400)); }); }); describe('The responseValidation should', () => { + const initialMiddlewareResult: MiddlewareResult> = { $failed: false, $result: undefined }; + const exampleSchema = Joi.object({ status: Joi.number().required(), - body: Joi.object({ + jsonBody: Joi.object({ example: Joi.string().required(), }), }); - const contextMock = mockDeep(); + const contextMock = mockDeep(); const requestMock = mockDeep(); beforeEach(() => { - contextMock.log.verbose = jest.fn(); jest.restoreAllMocks(); }); test('do nothing, if the response is valid', async () => { - contextMock.res = {}; - contextMock.res.status = 201; - contextMock.res.body = { example: 'test-body' }; + initialMiddlewareResult.$result = { status: 201, jsonBody: { example: 'test-body' } }; - const result = await responseValidation(exampleSchema)(contextMock, requestMock); + const result = await responseValidation(exampleSchema)(requestMock, contextMock, initialMiddlewareResult); expect(result).toBeUndefined(); }); test('do nothing, if the object, extracted through the passed function, is valid', async () => { - contextMock.res = {}; - contextMock.res.body = { example: 'test-body' }; + initialMiddlewareResult.$result = { jsonBody: { example: 'test-body' } }; + const extractValidationContentFromRequest = () => ({ + status: 201, + jsonBody: { example: 'not-test-body' }, + }); const result = await responseValidation(exampleSchema, { - extractValidationContentFromRequest: (context) => ({ status: 201, body: context?.res?.body }), - })(contextMock, requestMock); + extractValidationContentFromRequest, + })(requestMock, contextMock, initialMiddlewareResult); expect(result).toBeUndefined(); }); test('throw, if the response is invalid', async () => { - contextMock.res = {}; - contextMock.res.status = 201; - contextMock.res.body = { fail: 'this fails' }; + initialMiddlewareResult.$result = { status: 201, jsonBody: { fail: 'this-fails' } }; - await expect(responseValidation(exampleSchema)(contextMock, requestMock)).rejects.toThrowError( - new ApplicationError('Internal server error', 500), - ); + await expect( + responseValidation(exampleSchema)(requestMock, contextMock, initialMiddlewareResult), + ).rejects.toThrowError(new ApplicationError('Internal server error', 500)); }); test('do not throw an error, even when the validation was not successful, if throwing is disabled', async () => { - contextMock.res = {}; - contextMock.res.status = 201; - contextMock.res.body = { fail: 'this fails' }; + initialMiddlewareResult.$result = { status: 201, jsonBody: { fail: 'this-fails' } }; await expect( - responseValidation(exampleSchema, { shouldThrowOnValidationError: false })(contextMock, requestMock), + responseValidation(exampleSchema, { shouldThrowOnValidationError: false })( + requestMock, + contextMock, + initialMiddlewareResult, + ), ).resolves.toEqual(undefined); }); test('fail when the validation was not successful with transformed error message', async () => { - contextMock.res = {}; - contextMock.res.status = 201; - contextMock.res.body = { fail: 'this fails' }; + initialMiddlewareResult.$result = { status: 201, jsonBody: { fail: 'this-fails' } }; await expect( responseValidation(exampleSchema, { - transformErrorMessage: (message) => ({ + transformErrorMessage: (message: string) => ({ type: 'Validation Error', message, }), - })(contextMock, requestMock), + })(requestMock, contextMock, initialMiddlewareResult), ).rejects.toThrowError(new ApplicationError('Internal server error', 500)); }); }); diff --git a/src/validation.ts b/src/validation.ts index 8761aa0..97e2eb2 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,57 +1,97 @@ -import { AzureFunction, Context, HttpRequest } from '@azure/functions'; +import { FunctionHandler, FunctionResult, HttpHandler, HttpRequest, InvocationContext } from '@azure/functions'; import { AnySchema } from 'joi'; import { ApplicationError } from './error'; +import { BeforeExecutionFunction, MiddlewareResult, PostExecutionFunction, isErrorResult } from './middleware'; +import { stringify } from './util/stringify'; type ValidationOptions = Partial<{ transformErrorMessage: (message: string) => unknown; - extractValidationContentFromRequest: (context: Context, req: HttpRequest) => unknown; + extractValidationContentFromRequest: ( + req: HttpRequest, + context: InvocationContext, + result: MiddlewareResult>, + ) => unknown; shouldThrowOnValidationError: boolean; + skipIfResultIsFaulty: boolean; + printRequest: boolean; }>; -export function requestValidation(schema: AnySchema, opts?: ValidationOptions): AzureFunction { - return (context: Context, req: HttpRequest): Promise => { - context.log.verbose('Validating the request.'); +export function requestValidation(schema: AnySchema, opts?: ValidationOptions): BeforeExecutionFunction { + const skipIfResultIsFaulty = opts?.skipIfResultIsFaulty ?? true; + const printRequest = opts?.printRequest ?? false; - const toBeValidatedContent = opts?.extractValidationContentFromRequest?.(context, req) ?? req.body; - const shouldThrowOnValidationError = opts?.shouldThrowOnValidationError ?? true; + return async (req, context, result): Promise => { + if (skipIfResultIsFaulty && result.$failed) { + context.info('Skipping request-validation because the result is faulty.'); + return Promise.resolve(); + } - const validationResult = schema.validate(toBeValidatedContent); - if (validationResult && validationResult.error) { - context.log.error('The request did not match the given schema.'); - context.log.verbose(validationResult); + const clonedRequest = req.clone(); //see https://github.com/nuxt/nuxt/issues/19245 + context.info('Validating the request.'); + try { + const toBeValidatedContent = + opts?.extractValidationContentFromRequest?.(clonedRequest, context, result) ?? + (await clonedRequest.json()); + const shouldThrowOnValidationError = opts?.shouldThrowOnValidationError ?? true; - if (shouldThrowOnValidationError) { + const validationResult = schema.validate(toBeValidatedContent); + if (validationResult && validationResult.error) { + context.error( + `The request did not match the given schema.${printRequest ? stringify(toBeValidatedContent) : ''}`, + ); + context.info(validationResult); + + if (shouldThrowOnValidationError) { + return Promise.reject( + new ApplicationError( + 'Validation Error', + 400, + opts?.transformErrorMessage + ? opts?.transformErrorMessage(validationResult.error.message) + : { + message: validationResult.error.message, + }, + ), + ); + } + } + + context.info('Finished validating the request.'); + return Promise.resolve(); + } catch (error) { + if (error instanceof SyntaxError) { + context.error(`The Json was probably ill-defined: ${error}`); + //see https://fetch.spec.whatwg.org/#dom-body-json return Promise.reject( - new ApplicationError( - 'Validation Error', - 400, - opts?.transformErrorMessage - ? opts?.transformErrorMessage(validationResult.error.message) - : { - message: validationResult.error.message, - }, - ), + new ApplicationError('Validation Error', 400, { + message: error.message, + }), ); } + context.error(`Unexpected server error occurred: ${error}`); + return Promise.reject( + new ApplicationError('Internal Server Error', 500, { + message: 'An internal Server Error occurred while validating the request.', + }), + ); } - - context.log.verbose('Finished validating the request.'); - return Promise.resolve(); }; } -export function responseValidation(schema: AnySchema, opts?: ValidationOptions): AzureFunction { - return (context: Context, req: HttpRequest): Promise => { - context.log.verbose('Validating the server-response.'); +export function responseValidation(schema: AnySchema, opts?: ValidationOptions): PostExecutionFunction { + return (req, context, result): Promise => { + context.info('Validating the server-response.'); - const toBeValidatedContent = opts?.extractValidationContentFromRequest?.(context, req) ?? context.res; + const toBeValidatedContent = + opts?.extractValidationContentFromRequest?.(req, context, result) ?? + (isErrorResult>(result) ? result.$error : result.$result); const shouldThrowOnValidationError = opts?.shouldThrowOnValidationError ?? true; const validationResult = schema.validate(toBeValidatedContent); if (validationResult && validationResult.error) { - context.log.error('The response did not match the given schema.'); - context.log.verbose(validationResult); + context.error('The response did not match the given schema.'); + context.info(validationResult); if (shouldThrowOnValidationError) { return Promise.reject( @@ -68,7 +108,7 @@ export function responseValidation(schema: AnySchema, opts?: ValidationOptions): } } - context.log.verbose('Finished validating the response.'); + context.info('Finished validating the response.'); return Promise.resolve(); }; }