From e831dc788bb0da680f371901c7a837a118b54d02 Mon Sep 17 00:00:00 2001 From: maaaNu Date: Tue, 19 Sep 2023 19:09:28 +0200 Subject: [PATCH] [2.1.0] + Post-Execution-Functions will now be executed, even if the handler failed - The middlewareWithErrorHandling was removed. To regain the same functionality you can now pass an option-flag, called disableErrorHandling --- .eslintignore | 1 - .eslintrc.js | 18 +- .github/workflows/ci.yml | 2 +- .prettierignore | 1 - .prettierrc.js | 5 +- CHANGELOG.md | 4 + .../test-header-authentication-function.ts | 3 +- .../test-jwt-authorization-function.ts | 3 +- .../test-validation-function.ts | 5 +- .../header-authentication-test.integration.ts | 1 + .../jwt-authorization-test.integration.ts | 1 + .../validation-test.integration.ts | 1 + package-lock.json | 100 ++++++++++- package.json | 3 +- src/appInsights/Logger.ts | 4 +- src/appInsights/appInsightsWrapper.ts | 3 +- .../ApplicationError.ts} | 0 src/error/errorHandler.test.ts | 39 +++++ src/error/errorHandler.ts | 79 +++++++++ src/error/index.ts | 2 + src/headerAuthentication.test.ts | 19 ++- src/headerAuthentication.ts | 3 +- src/index.ts | 4 +- src/jwtAuthorization.test.ts | 5 +- src/jwtAuthorization.ts | 3 +- src/middleware.test.ts | 156 +++++++++++++++++- src/middleware.ts | 146 ++++++++-------- src/middlewareWithoutErrorHandling.test.ts | 108 ------------ src/validation.test.ts | 7 +- src/validation.ts | 3 +- 30 files changed, 494 insertions(+), 235 deletions(-) rename src/{applicationError.ts => error/ApplicationError.ts} (100%) create mode 100644 src/error/errorHandler.test.ts create mode 100644 src/error/errorHandler.ts create mode 100644 src/error/index.ts delete mode 100644 src/middlewareWithoutErrorHandling.test.ts diff --git a/.eslintignore b/.eslintignore index 8fbbd8e..f06235c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,2 @@ node_modules dist -*.test.ts \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 93739ca..b7147e4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,17 +1,11 @@ module.exports = { - parser: "@typescript-eslint/parser", // Specifies the ESLint parser + parser: '@typescript-eslint/parser', // Specifies the ESLint parser parserOptions: { ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features - sourceType: "module", // Allows for the use of imports - ecmaFeatures: { - } - }, - settings: { - }, - extends: [ - "plugin:@typescript-eslint/recommended", - "plugin:prettier/recommended" - ], - rules: { + sourceType: 'module', // Allows for the use of imports + ecmaFeatures: {}, }, + settings: {}, + extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], + rules: {}, }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feb9a7d..ab945c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [ 15.x, 16.x ] + node-version: [ 16.x, 18.x ] steps: - uses: actions/checkout@v4 diff --git a/.prettierignore b/.prettierignore index a967bf0..543141e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,5 +4,4 @@ package-lock.json *.ejs dist coverage -dist/ config/* diff --git a/.prettierrc.js b/.prettierrc.js index 20bf3f3..4859634 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -3,5 +3,8 @@ module.exports = { trailingComma: "all", singleQuote: true, printWidth: 120, - tabWidth: 4 + tabWidth: 4, + importOrder: ["^[./]"], + importOrderSeparation: true, + importOrderSortSpecifiers: true }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ca53a..0b86dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.1.0 (19.09.2023) ++ Post-Execution-Functions will now be executed, even if the handler failed +- The middlewareWithErrorHandling was removed. To regain the same functionality you can now pass an option-flag, called disableErrorHandling + ## 2.0.0 (01.04.2023) - Added auto-logging functionality to the library that enhances searchability of saved log statements in Azure AppInsights by storing context properties in commonProperties. - Removed the MiddlewareFunction-Type in favor for the azure-built-in one diff --git a/example/test-header-authentication-function/test-header-authentication-function.ts b/example/test-header-authentication-function/test-header-authentication-function.ts index 9132f93..623eac8 100644 --- a/example/test-header-authentication-function/test-header-authentication-function.ts +++ b/example/test-header-authentication-function/test-header-authentication-function.ts @@ -1,5 +1,6 @@ import { Context } from '@azure/functions'; -import middleware from '../../src/middleware'; + +import { middleware } from '../../src'; import headerAuthentication from '../../src/headerAuthentication'; const functionHandler = async (context: Context): Promise => { diff --git a/example/test-jwt-authorization-function/test-jwt-authorization-function.ts b/example/test-jwt-authorization-function/test-jwt-authorization-function.ts index adc0711..23f1760 100644 --- a/example/test-jwt-authorization-function/test-jwt-authorization-function.ts +++ b/example/test-jwt-authorization-function/test-jwt-authorization-function.ts @@ -1,5 +1,6 @@ import { Context, ContextBindingData } from '@azure/functions'; -import middleware from '../../src/middleware'; + +import { middleware } from '../../src'; import authorization from '../../src/jwtAuthorization'; const functionHandler = async (context: Context): Promise => { diff --git a/example/test-validation-function/test-validation-function.ts b/example/test-validation-function/test-validation-function.ts index 38f3f07..1d5039b 100644 --- a/example/test-validation-function/test-validation-function.ts +++ b/example/test-validation-function/test-validation-function.ts @@ -1,9 +1,10 @@ import { Context, HttpRequest } from '@azure/functions'; -import middleware from '../../src/middleware'; import * as Joi from 'joi'; -import validation from '../../src/validation'; import { ObjectSchema } from 'joi'; +import { middleware } from '../../src'; +import validation from '../../src/validation'; + const schema: ObjectSchema = Joi.object({ name: Joi.string().min(3).max(30).required(), }).required(); diff --git a/integration-test/header-authentication-test.integration.ts b/integration-test/header-authentication-test.integration.ts index 845685b..7736d4e 100644 --- a/integration-test/header-authentication-test.integration.ts +++ b/integration-test/header-authentication-test.integration.ts @@ -1,4 +1,5 @@ import axios from 'axios'; + import waitTillFunctionReady from './waitTillFunctionReady'; describe('The example azure function is started and the header authentication should execute the request', () => { diff --git a/integration-test/jwt-authorization-test.integration.ts b/integration-test/jwt-authorization-test.integration.ts index d7fc5ac..44bea48 100644 --- a/integration-test/jwt-authorization-test.integration.ts +++ b/integration-test/jwt-authorization-test.integration.ts @@ -1,4 +1,5 @@ import axios from 'axios'; + import waitTillFunctionReady from './waitTillFunctionReady'; // Token generated with https://jwt.io/ containing the "userId" "c8e65ca7-a008-4b1c-b52a-4ad0ee417017" diff --git a/integration-test/validation-test.integration.ts b/integration-test/validation-test.integration.ts index e7d6247..15b5c27 100644 --- a/integration-test/validation-test.integration.ts +++ b/integration-test/validation-test.integration.ts @@ -1,4 +1,5 @@ import axios from 'axios'; + import waitTillFunctionReady from './waitTillFunctionReady'; describe('The example azure function is started and the JOI validation should', () => { diff --git a/package-lock.json b/package-lock.json index e2a3da6..ca48f54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@senacor/azure-function-middleware", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@senacor/azure-function-middleware", - "version": "2.0.1", + "version": "2.1.0", "license": "MIT", "dependencies": { "@azure/functions": "^3.0.0", @@ -15,6 +15,7 @@ "jwt-decode": "^3.1.2" }, "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/jest": "^27.0.3", "@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/parser": "^5.10.2", @@ -1351,6 +1352,95 @@ "node": ">= 6" } }, + "node_modules/@trivago/prettier-plugin-sort-imports": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.0.tgz", + "integrity": "sha512-YBepjbt+ZNBVmN3ev1amQH3lWCmHyt5qTbLCp/syXJRu/Kw2koXh44qayB1gMRxcL/gV8egmjN5xWSrYyfUtyw==", + "dev": true, + "dependencies": { + "@babel/generator": "7.17.7", + "@babel/parser": "^7.20.5", + "@babel/traverse": "7.17.3", + "@babel/types": "7.17.0", + "javascript-natural-sort": "0.7.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@vue/compiler-sfc": "3.x", + "prettier": "2.x - 3.x" + }, + "peerDependenciesMeta": { + "@vue/compiler-sfc": { + "optional": true + } + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/generator": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.7.tgz", + "integrity": "sha512-oLcVCTeIFadUoArDTwpluncplrYBmTCCZZgXCbgNGvOBBiSDDK3eWO4b/+eOTli5tKv1lg+a5/NAXg+nTcei1w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.17.0", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz", + "integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.3", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.3", + "@babel/types": "^7.17.0", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.0", "dev": true, @@ -3330,6 +3420,12 @@ "node": ">=8" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true + }, "node_modules/jest": { "version": "27.5.1", "dev": true, diff --git a/package.json b/package.json index 48a0a66..a467b89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@senacor/azure-function-middleware", - "version": "2.0.1", + "version": "2.1.0", "description": "Middleware for azure functions to handle authentication, authorization, error handling and logging", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -25,6 +25,7 @@ "author": "florian.rudisch@senacor.com", "license": "MIT", "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/jest": "^27.0.3", "@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/parser": "^5.10.2", diff --git a/src/appInsights/Logger.ts b/src/appInsights/Logger.ts index b2bd24e..dbea180 100644 --- a/src/appInsights/Logger.ts +++ b/src/appInsights/Logger.ts @@ -1,6 +1,6 @@ -import { SeverityLevel } from 'applicationinsights/out/Declarations/Contracts'; -import { TelemetryClient } from 'applicationinsights'; import { Logger } from '@azure/functions'; +import { TelemetryClient } from 'applicationinsights'; +import { SeverityLevel } from 'applicationinsights/out/Declarations/Contracts'; const consoleDefaultLog = (message: string): void => { console.log(message); diff --git a/src/appInsights/appInsightsWrapper.ts b/src/appInsights/appInsightsWrapper.ts index 6ce189e..a14d39d 100644 --- a/src/appInsights/appInsightsWrapper.ts +++ b/src/appInsights/appInsightsWrapper.ts @@ -1,8 +1,9 @@ import { Context, HttpRequest } from '@azure/functions'; import * as appInsights from 'applicationinsights'; -import { createAppInsightsLogger, consoleLogger } from './Logger'; import { TelemetryClient } from 'applicationinsights'; +import { consoleLogger, createAppInsightsLogger } from './Logger'; + const telemetryClients: { [key: string]: TelemetryClient } = {}; const isDisabled = diff --git a/src/applicationError.ts b/src/error/ApplicationError.ts similarity index 100% rename from src/applicationError.ts rename to src/error/ApplicationError.ts diff --git a/src/error/errorHandler.test.ts b/src/error/errorHandler.test.ts new file mode 100644 index 0000000..b2e6bef --- /dev/null +++ b/src/error/errorHandler.test.ts @@ -0,0 +1,39 @@ +import { Context } 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(); + + beforeEach(() => { + jest.restoreAllMocks(); + + contextMock.log.error = jest.fn(); + }); + + test('return an provided ApplicationError', () => { + const res = sut(new ApplicationError('', 400, { error: 'critical' }), contextMock); + expect(res.status).toStrictEqual(400); + expect(res.body).toStrictEqual({ error: 'critical' }); + }); + + test('return an default error-message, if no errorResponseHandler was provided', () => { + const res = sut('Error!', contextMock); + expect(res.status).toStrictEqual(500); + expect(res.body).toStrictEqual({ message: 'Internal server error' }); + }); + + test('use the errorResponseHandler, if an errorResponseHandler was provided', () => { + const errorResponseHandler = () => ({ + status: 409, + body: { + message: 'conflict!', + }, + }); + const res = sut('Error!', contextMock, { errorResponseHandler }); + expect(res.status).toStrictEqual(409); + expect(res.body).toStrictEqual({ message: 'conflict!' }); + }); +}); diff --git a/src/error/errorHandler.ts b/src/error/errorHandler.ts new file mode 100644 index 0000000..b6dbc92 --- /dev/null +++ b/src/error/errorHandler.ts @@ -0,0 +1,79 @@ +import { Context } from '@azure/functions'; + +import { Options } from '../middleware'; +import { ApplicationError } from './ApplicationError'; + +type ErrorWithMessage = { + message: string; + stack?: string; +}; + +const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as Record).message === 'string' + ); +}; + +const logErrorObject = (error: object | null, context: Context) => { + if (error === null) { + context.log.error('The provided error was eq to null - unable to log a specific error-message'); + return; + } + + if (isErrorWithMessage(error)) { + context.log.error({ message: error.message, stack: error.stack }); + } else { + try { + const errorAsJson = JSON.stringify(error); + if (errorAsJson === '{}') { + context.log.error(error.toString()); + } else { + context.log.error(errorAsJson); // Log the JSON string of the error object + } + } catch (_) { + //Fallback in case there's an error stringify + context.log.error(error.toString()); + } + } +}; + +export const errorHandler = ( + error: unknown, + context: Context, + opts?: Options, +): { + [key: string]: unknown; +} => { + if (error instanceof ApplicationError) { + context.log.error(`Received application error with message ${error.message}`); + return { + status: error.status, + body: error.body, + }; + } + + switch (typeof error) { + case 'string': + context.log.error(error); + break; + case 'object': + logErrorObject(error, context); + break; + default: + context.log(`The error object has a type, that is not suitable for logging: ${typeof error}`); + } + + if (opts?.errorResponseHandler === undefined) { + return { + status: 500, + body: { + message: 'Internal server error', + }, + }; + } else { + return opts.errorResponseHandler(error); + } +}; diff --git a/src/error/index.ts b/src/error/index.ts new file mode 100644 index 0000000..c3903e3 --- /dev/null +++ b/src/error/index.ts @@ -0,0 +1,2 @@ +export * from './errorHandler'; +export * from './ApplicationError'; diff --git a/src/headerAuthentication.test.ts b/src/headerAuthentication.test.ts index 721c54e..7573b47 100644 --- a/src/headerAuthentication.test.ts +++ b/src/headerAuthentication.test.ts @@ -1,7 +1,8 @@ -import { mock } from 'jest-mock-extended'; import { Context, HttpRequest } from '@azure/functions'; +import { mock } from 'jest-mock-extended'; + +import { ApplicationError } from './error'; import sut from './headerAuthentication'; -import { ApplicationError } from './applicationError'; describe('The header authentication middleware should', () => { const contextMock = mock(); @@ -24,6 +25,7 @@ describe('The header authentication middleware should', () => { test('fail caused by missing default "x-ms-client-principal" header', async () => { // suppressing in order to enforce missing header + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore requestMock.headers['x-ms-client-principal-id'] = undefined; @@ -34,12 +36,13 @@ describe('The header authentication middleware should', () => { test('fail caused by missing default "x-ms-client-principal" header and using the provided error body', async () => { // suppressing in order to enforce missing header + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore requestMock.headers['x-ms-client-principal-id'] = undefined; - await expect(sut(undefined, {error: 'Please authenticate properly'})(contextMock, requestMock)).rejects.toEqual( - new ApplicationError('Authentication error', 403, {error: 'Please authenticate properly'}), - ); + await expect( + sut(undefined, { error: 'Please authenticate properly' })(contextMock, requestMock), + ).rejects.toEqual(new ApplicationError('Authentication error', 403, { error: 'Please authenticate properly' })); }); test('fail caused by passed header validation function returns false', async () => { @@ -49,8 +52,8 @@ describe('The header authentication middleware should', () => { }); 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)).rejects.toEqual( - new ApplicationError('Authentication error', 403, {error: 'Please authenticate properly'}), - ); + await expect( + sut(() => false, { error: 'Please authenticate properly' })(contextMock, requestMock), + ).rejects.toEqual(new ApplicationError('Authentication error', 403, { error: 'Please authenticate properly' })); }); }); diff --git a/src/headerAuthentication.ts b/src/headerAuthentication.ts index 2babc20..9d78171 100644 --- a/src/headerAuthentication.ts +++ b/src/headerAuthentication.ts @@ -1,7 +1,8 @@ import { AzureFunction, Context, HttpRequest } from '@azure/functions'; -import { ApplicationError } from './applicationError'; import { HttpRequestHeaders } from '@azure/functions/types/http'; +import { ApplicationError } from './error'; + export default ( validateUsingHeaderFn?: (headers: HttpRequestHeaders) => boolean, errorResponseBody?: unknown, diff --git a/src/index.ts b/src/index.ts index 913464b..edc403b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { default as headerAuthentication } from './headerAuthentication'; -export { default as middleware, middlewareWithoutErrorHandling } from './middleware'; -export { ApplicationError } from './applicationError'; +export * from './middleware'; +export * from './error'; export { Rule, default as jwtAuthorization } from './jwtAuthorization'; export { default as validation } from './validation'; export { AppInsightForHttpTrigger, AppInsightForNoNHttpTrigger } from './appInsights/appInsightsWrapper'; diff --git a/src/jwtAuthorization.test.ts b/src/jwtAuthorization.test.ts index 3a20366..0c0ebd3 100644 --- a/src/jwtAuthorization.test.ts +++ b/src/jwtAuthorization.test.ts @@ -1,8 +1,9 @@ -import { mock } from 'jest-mock-extended'; import { Context, HttpRequest } from '@azure/functions'; +import { mock } from 'jest-mock-extended'; import * as JWTDecoder from 'jwt-decode'; + +import { ApplicationError } from './error'; import sut from './jwtAuthorization'; -import { ApplicationError } from './applicationError'; jest.mock('jwt-decode'); const jwtMock = JWTDecoder as jest.Mocked; diff --git a/src/jwtAuthorization.ts b/src/jwtAuthorization.ts index c4c71a6..80b09fc 100644 --- a/src/jwtAuthorization.ts +++ b/src/jwtAuthorization.ts @@ -1,6 +1,7 @@ import { AzureFunction, Context, ContextBindingData, HttpRequest } from '@azure/functions'; import jwtDecode from 'jwt-decode'; -import { ApplicationError } from './applicationError'; + +import { ApplicationError } from './error'; const evaluate = (rule: Rule, parameters: ContextBindingData, jwt: T) => { const pathParameter = rule.parameterExtractor(parameters); diff --git a/src/middleware.test.ts b/src/middleware.test.ts index fca3a72..ce01a38 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,7 +1,8 @@ -import { mock } from 'jest-mock-extended'; import { Context, HttpRequest } from '@azure/functions'; -import sut from './middleware'; -import { ApplicationError } from './applicationError'; +import { mock } from 'jest-mock-extended'; + +import { ApplicationError } from './error'; +import { middleware as sut } from './middleware'; describe('The middleware layer should', () => { const contextMock = mock(); @@ -125,6 +126,26 @@ describe('The middleware layer should', () => { expect(contextMock.log.error).toBeCalled(); }); + test('fail when 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); + + expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(contextMock.res).toEqual({ + status: 500, + body: { + message: 'Internal server error', + }, + }); + expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(contextMock.log.error).toBeCalled(); + }); + test('use the provided error-handle to create a response', async () => { const handlerMock = jest.fn(); const middlewareOneMock = jest.fn(); @@ -132,8 +153,8 @@ describe('The middleware layer should', () => { const middlewarePostFunction = jest.fn(); handlerMock.mockRejectedValue(Error()); - const errorResponseHandler = (error: unknown, context: Context) => { - context.res = { + const errorResponseHandler = () => { + return { status: 1337, body: 'My custom error response', }; @@ -152,3 +173,128 @@ describe('The middleware layer should', () => { // expect(middlewarePostFunction).toBeCalled(); TODO: Fix later }); }); + +describe('The middleware layer with disabled error-handling should', () => { + 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, [], { disableErrorHandling: true })(contextMock, requestMock); + + expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + }); + + test('successfully call the middleware and the passed functions', async () => { + const handlerMock = jest.fn(); + const middlewareOneMock = jest.fn(); + const middlewareTwoMock = jest.fn(); + + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [], { disableErrorHandling: true })( + contextMock, + requestMock, + ); + + expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + }); + + test('successfully call the pre middleware and post middleware and the passed functions', async () => { + const handlerMock = jest.fn(); + const middlewareOneMock = jest.fn(); + const middlewareTwoMock = jest.fn(); + const middlewarePostMock = jest.fn(); + + await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [middlewarePostMock], { + disableErrorHandling: true, + })(contextMock, requestMock); + + expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(middlewarePostMock).toHaveBeenCalledWith(contextMock, requestMock); + expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); + }); + + 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); + }); +}); diff --git a/src/middleware.ts b/src/middleware.ts index ac3d6ac..20a41d3 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,102 +1,92 @@ import { AzureFunction, Context } from '@azure/functions'; -import { ApplicationError } from './applicationError'; -type ErrorWithMessage = { - message: string; - stack?: string; -}; +import { errorHandler } from './error'; -const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => { - return ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as Record).message === 'string' - ); -}; +type ErrorResult = { $failed: true; $error: unknown }; -const logErrorObject = (error: object | null, context: Context) => { - if (error === null) { - context.log.error('The provided error was eq to null - unable to log a specific error-message'); - return; - } - - if (isErrorWithMessage(error)) { - context.log.error({ message: error.message, stack: error.stack }); - } else { - try { - const errorAsJson = JSON.stringify(error); - if (errorAsJson === '{}') { - context.log.error(error.toString()); - } - } catch (_) { - //Fallback in case there's an error stringify - context.log.error(error.toString()); - } - } -}; +const isErrorResult = (result: unknown | ErrorResult): result is ErrorResult => (result as ErrorResult)?.$failed; const middlewareCore = - (beforeExecution: AzureFunction[], handler: AzureFunction, afterExecution: AzureFunction[]) => - async (context: Context, ...args: unknown[]): Promise => { + (beforeExecution: AzureFunction[], handler: AzureFunction, postExecution: AzureFunction[]) => + async (context: Context, ...args: unknown[]): Promise => { + let error = undefined; + if (beforeExecution) { for (const middlewareFunctions of beforeExecution) { - await middlewareFunctions(context, ...args); + 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 middlewareFunctions(context, ...args); + } + } catch (err) { + error = err; + } + } + } + + let handlerResult; + + if (error === undefined) { + try { + handlerResult = await handler(context, ...args); + } catch (err) { + error = err; } } - const handlerResult = await handler(context, ...args); - if (afterExecution) { - for (const middleware of afterExecution) { + + // 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) { + for (const middleware of postExecution) { await middleware(context, ...args); } } + + if (error !== undefined) { + context.log.error(`An uncaught error occurred in the execution of the hander: ${error}`); + return { $failed: true, $error: error }; + } return handlerResult; }; export type Options = { - errorResponseHandler?: (error: unknown, context: Context) => void; + errorResponseHandler?: (error: unknown) => { + [key: string]: unknown; + }; + disableErrorHandling?: boolean; }; -const middleware = +async function middlewareWrapper( + beforeExecution: AzureFunction[], + handler: (context: Context, ...args: unknown[]) => Promise | void, + postExecution: AzureFunction[], + context: Context, + args: unknown[], + opts?: Options, +) { + const result = await middlewareCore(beforeExecution, handler, postExecution)(context, ...args); + + if (isErrorResult(result)) { + if (opts?.disableErrorHandling) { + throw result.$error; + } + + context.res = errorHandler(result.$error, context, opts); + return; + } + + return result; +} + +export const middleware = (beforeExecution: AzureFunction[], handler: AzureFunction, postExecution: AzureFunction[], opts?: Options) => - async (context: Context, ...args: unknown[]): Promise => { + async (context: Context, ...args: unknown[]): Promise => { + if (opts?.disableErrorHandling) { + return await middlewareWrapper(beforeExecution, handler, postExecution, context, args, opts); + } + try { - return await middlewareCore(beforeExecution, handler, postExecution)(context, ...args); + return await middlewareWrapper(beforeExecution, handler, postExecution, context, args, opts); } catch (error) { - if (error instanceof ApplicationError) { - context.log.error(`Received application error with message ${error.message}`); - context.res = { - status: error.status, - body: error.body, - }; - return; - } - - switch (typeof error) { - case 'string': - context.log.error(error); - break; - case 'object': - logErrorObject(error, context); - break; - default: - context.log(`The error object has a type, that is not suitable for logging: ${typeof error}`); - } - - if (opts?.errorResponseHandler === undefined) { - context.res = { - status: 500, - body: { - message: 'Internal server error', - }, - }; - return; - } else { - opts.errorResponseHandler(error, context); - return; - } + context.res = errorHandler(error, context, opts); } }; - -export default middleware; -export const middlewareWithoutErrorHandling = middlewareCore; diff --git a/src/middlewareWithoutErrorHandling.test.ts b/src/middlewareWithoutErrorHandling.test.ts deleted file mode 100644 index 2fe73a6..0000000 --- a/src/middlewareWithoutErrorHandling.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import { Context, HttpRequest } from '@azure/functions'; -import { middlewareWithoutErrorHandling as sut } from './middleware'; -import { ApplicationError } from './applicationError'; - -describe('The middleware layer should', () => { - 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); - - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - }); - - test('successfully call the middleware and the passed functions', async () => { - const handlerMock = jest.fn(); - const middlewareOneMock = jest.fn(); - const middlewareTwoMock = jest.fn(); - - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [])(contextMock, requestMock); - - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - }); - - test('successfully call the pre middleware and post middleware and the passed functions', async () => { - const handlerMock = jest.fn(); - const middlewareOneMock = jest.fn(); - const middlewareTwoMock = jest.fn(); - const middlewarePostMock = jest.fn(); - - await sut([middlewareOneMock, middlewareTwoMock], handlerMock, [middlewarePostMock])(contextMock, requestMock); - - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewarePostMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - }); - - 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, [])(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, [])(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, [])(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, [])(contextMock, requestMock), - ).rejects.toThrow(); - - expect(middlewareOneMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(middlewareTwoMock).toHaveBeenCalledWith(contextMock, requestMock); - expect(handlerMock).toHaveBeenCalledWith(contextMock, requestMock); - }); -}); diff --git a/src/validation.test.ts b/src/validation.test.ts index f8c8f9d..ce18a75 100644 --- a/src/validation.test.ts +++ b/src/validation.test.ts @@ -1,9 +1,10 @@ -import { mock } from 'jest-mock-extended'; import { Context, HttpRequest } from '@azure/functions'; -import * as JoiValidator from 'joi'; +import { mock } from 'jest-mock-extended'; +import JoiValidator from 'joi'; import { ValidationError } from 'joi'; + +import { ApplicationError } from './error'; import sut from './validation'; -import { ApplicationError } from './applicationError'; jest.mock('joi'); describe('The joi validator should', () => { diff --git a/src/validation.ts b/src/validation.ts index 2050bb6..de061c1 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,6 +1,7 @@ import { AzureFunction, Context, HttpRequest } from '@azure/functions'; import { AnySchema } from 'joi'; -import { ApplicationError } from './applicationError'; + +import { ApplicationError } from './error'; export default ( schema: AnySchema,