├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── LICENSE ├── README.adoc ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── docs └── asciidoc │ └── index.adoc ├── main ├── java │ └── io │ │ └── github │ │ └── wimdeblauwe │ │ └── errorhandlingspringbootstarter │ │ ├── AbstractErrorHandlingConfiguration.java │ │ ├── ApiErrorResponse.java │ │ ├── ApiErrorResponseAccessDeniedHandler.java │ │ ├── ApiErrorResponseCustomizer.java │ │ ├── ApiErrorResponseSerializer.java │ │ ├── ApiExceptionHandler.java │ │ ├── ApiFieldError.java │ │ ├── ApiGlobalError.java │ │ ├── ApiParameterError.java │ │ ├── DefaultFallbackApiExceptionHandler.java │ │ ├── ErrorHandlingFacade.java │ │ ├── ErrorHandlingProperties.java │ │ ├── FallbackApiExceptionHandler.java │ │ ├── LoggingService.java │ │ ├── ResponseErrorCode.java │ │ ├── ResponseErrorProperty.java │ │ ├── SpringOrmErrorHandlingConfiguration.java │ │ ├── SpringSecurityErrorHandlingConfiguration.java │ │ ├── UnauthorizedEntryPoint.java │ │ ├── ValidationErrorHandlingConfiguration.java │ │ ├── handler │ │ ├── AbstractApiExceptionHandler.java │ │ ├── BindApiExceptionHandler.java │ │ ├── ConstraintViolationApiExceptionHandler.java │ │ ├── HandlerMethodValidationExceptionHandler.java │ │ ├── HttpMessageNotReadableApiExceptionHandler.java │ │ ├── MissingRequestValueExceptionHandler.java │ │ ├── ObjectOptimisticLockingFailureApiExceptionHandler.java │ │ ├── ServerErrorExceptionHandler.java │ │ ├── ServerWebInputExceptionHandler.java │ │ ├── SpringSecurityApiExceptionHandler.java │ │ └── TypeMismatchApiExceptionHandler.java │ │ ├── mapper │ │ ├── ErrorCodeMapper.java │ │ ├── ErrorMessageMapper.java │ │ ├── HttpResponseStatusFromExceptionMapper.java │ │ ├── HttpStatusMapper.java │ │ ├── ResponseStatusExceptionHttpResponseStatusFromExceptionMapper.java │ │ └── RestClientResponseExceptionHttpResponseStatusFromExceptionMapper.java │ │ ├── reactive │ │ ├── GlobalErrorWebExceptionHandler.java │ │ └── ReactiveErrorHandlingConfiguration.java │ │ └── servlet │ │ ├── ErrorHandlingControllerAdvice.java │ │ ├── FilterChainExceptionHandlerFilter.java │ │ └── ServletErrorHandlingConfiguration.java └── resources │ ├── META-INF │ └── spring │ │ ├── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ ├── org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebFlux.imports │ │ └── org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc.imports │ └── error-handling-defaults.properties └── test ├── java └── io │ └── github │ └── wimdeblauwe │ └── errorhandlingspringbootstarter │ ├── ApiErrorResponseSerializationTest.java │ ├── DefaultFallbackApiExceptionHandlerTest.java │ ├── DummyApplication.java │ ├── ErrorHandlingPropertiesTest.java │ ├── IntegrationTest.java │ ├── IntegrationTestRestController.java │ ├── ReactiveIntegrationTest.java │ ├── ReactiveIntegrationTestRestController.java │ ├── exception │ ├── ApplicationException.java │ ├── ExceptionWithBadRequestStatus.java │ ├── ExceptionWithResponseErrorCode.java │ ├── ExceptionWithResponseErrorPropertyOnField.java │ ├── ExceptionWithResponseErrorPropertyOnFieldWithIncludeIfNull.java │ ├── ExceptionWithResponseErrorPropertyOnMethod.java │ ├── ExceptionWithResponseErrorPropertyOnMethodWithIncludeIfNull.java │ ├── MyCustomHttpResponseStatusException.java │ ├── MyEntityNotFoundException.java │ ├── SubclassOfApplicationException.java │ ├── SubclassOfExceptionWithResponseErrorPropertyOnField.java │ └── SubclassOfExceptionWithResponseErrorPropertyOnMethod.java │ ├── handler │ ├── BindApiExceptionHandlerTest.java │ ├── BindApiExceptionHandlerWithMethodArgumentNotValidTest.java │ ├── ConstraintViolationApiExceptionHandlerTest.java │ ├── CustomApiExceptionHandlerDocumentation.java │ ├── CustomException.java │ ├── CustomExceptionApiExceptionHandler.java │ ├── HandlerMethodValidationExceptionHandlerTest.java │ ├── HttpMessageNotReadableApiExceptionHandlerTest.java │ ├── MissingRequestValueExceptionHandlerTest.java │ ├── ObjectOptimisticLockingFailureApiExceptionHandlerTest.java │ ├── ServerErrorExceptionHandlerTest.java │ ├── ServerErrorExceptionHandlerTestController.java │ ├── ServerWebInputExceptionHandlerTest.java │ ├── ServerWebInputExceptionHandlerTestController.java │ └── SpringSecurityApiExceptionHandlerTest.java │ └── servlet │ └── FilterChainExceptionHandlerFilterTest.java └── resources ├── io └── github │ └── wimdeblauwe │ └── errorhandlingspringbootstarter │ └── error-handling-properties-test.properties └── logback-test.xml /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | build: 13 | name: "Build with ${{ matrix.java }}" 14 | strategy: 15 | matrix: 16 | java: [ 17 ] 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Setup java ${{ matrix.java }} 22 | uses: actions/setup-java@v4 23 | with: 24 | java-version: ${{ matrix.java }} 25 | distribution: 'temurin' 26 | cache: maven 27 | 28 | - name: Build with Maven 29 | run: ./mvnw -B -ntp clean verify 30 | 31 | - name: Set Release version env variable 32 | run: | 33 | echo "RELEASE_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_ENV 34 | 35 | - name: GitHub Pages action 36 | if: | 37 | github.ref == 'refs/heads/master' && 38 | matrix.java == 17 39 | uses: peaceiris/actions-gh-pages@v3 40 | with: 41 | github_token: ${{ secrets.GITHUB_TOKEN }} 42 | publish_dir: ./target/generated-docs 43 | destination_dir: current 44 | exclude_assets: 'img/banner-logo.svg,img/doc-background.svg,img/doc-background-dark.svg' 45 | 46 | - name: GitHub Pages action (versioned dir) 47 | if: | 48 | github.ref == 'refs/heads/master' && 49 | matrix.java == 17 50 | uses: peaceiris/actions-gh-pages@v3 51 | with: 52 | github_token: ${{ secrets.GITHUB_TOKEN }} 53 | publish_dir: ./target/generated-docs 54 | destination_dir: ${{ env.RELEASE_VERSION }} 55 | exclude_assets: 'img/banner-logo.svg,img/doc-background.svg,img/doc-background-dark.svg' 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | release: 9 | name: Release on Sonatype OSS 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Apache Maven Central 16 | uses: actions/setup-java@v4 17 | with: # running setup-java again overwrites the settings.xml 18 | distribution: 'temurin' 19 | java-version: 17 20 | cache: 'maven' 21 | server-id: ossrh 22 | server-username: OSSRH_USERNAME 23 | server-password: OSSRH_PASSWORD 24 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} 25 | gpg-passphrase: MAVEN_GPG_PASSPHRASE 26 | 27 | - name: Publish to Apache Maven Central 28 | run: | 29 | mvn -Prelease \ 30 | --no-transfer-progress \ 31 | --batch-mode \ 32 | deploy 33 | env: 34 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} 35 | OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} 36 | MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/** 5 | !**/src/test/** 6 | .sdkmanrc 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | 17 | ### IntelliJ IDEA ### 18 | .idea 19 | *.iws 20 | *.iml 21 | *.ipr 22 | 23 | ### NetBeans ### 24 | /nbproject/private/ 25 | /nbbuild/ 26 | /dist/ 27 | /nbdist/ 28 | /.nb-gradle/ 29 | build/ 30 | 31 | ### VS Code ### 32 | .vscode/ 33 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Error Handling Spring Boot Starter 2 | :toc: macro 3 | :toclevels: 3 4 | 5 | ifdef::env-github[] 6 | :tip-caption: :bulb: 7 | :note-caption: :information_source: 8 | :important-caption: :heavy_exclamation_mark: 9 | :caution-caption: :fire: 10 | :warning-caption: :warning: 11 | endif::[] 12 | 13 | image:https://github.com/wimdeblauwe/error-handling-spring-boot-starter/actions/workflows/build.yml/badge.svg[] 14 | 15 | image:https://maven-badges.herokuapp.com/maven-central/io.github.wimdeblauwe/error-handling-spring-boot-starter/badge.svg["Maven Central",link="https://search.maven.org/search?q=a:error-handling-spring-boot-starter"] 16 | 17 | toc::[] 18 | 19 | == Goal 20 | 21 | The goal of the project is to make it easy to have proper and consistent error responses for REST APIs build with Spring Boot. 22 | 23 | == Documentation 24 | 25 | See https://wimdeblauwe.github.io/error-handling-spring-boot-starter for the extensive documentation. 26 | 27 | If you are new to the library, check out https://foojay.io/today/better-error-handling-for-your-spring-boot-rest-apis/[Better Error Handling for Your Spring Boot REST APIs] for an introductory overview. 28 | 29 | NOTE: Documentation is very important to us, so if you find something missing from the docs, please create an issue about it. 30 | 31 | == Spring Boot compatibility 32 | 33 | |=== 34 | |error-handling-spring-boot-starter |Spring Boot|Minimum Java version|Docs 35 | 36 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/4.5.0[4.5.0] 37 | |3.3.x 38 | |17 39 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/4.5.0/[Documentation 4.5.0] 40 | 41 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/4.4.0[4.4.0] 42 | |3.3.x 43 | |17 44 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/4.4.0/[Documentation 4.4.0] 45 | 46 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/4.3.0[4.3.0] 47 | |3.x 48 | |17 49 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/4.3.0/[Documentation 4.3.0] 50 | 51 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/4.2.0[4.2.0] 52 | |3.x 53 | |17 54 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/4.2.0/[Documentation 4.2.0] 55 | 56 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/4.1.3[4.1.3] 57 | |3.x 58 | |17 59 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/4.1.3/[Documentation 4.1.3] 60 | 61 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/4.0.0[4.0.0] 62 | |3.0.x 63 | |17 64 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/4.0.0/[Documentation 4.0.0] 65 | 66 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/3.4.1[3.4.1] 67 | |2.7.x 68 | |11 69 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/3.4.1/[Documentation 3.4.1] 70 | 71 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/3.3.0[3.3.0] 72 | |2.7.x 73 | |11 74 | |https://wimdeblauwe.github.io/error-handling-spring-boot-starter/3.3.0/[Documentation 3.3.0] 75 | 76 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/3.2.0[3.2.0] 77 | |2.5.x 78 | |11 79 | |N/A 80 | 81 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/2.1.0[2.1.0] 82 | |2.5.x 83 | |11 84 | |N/A 85 | 86 | |https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/tag/1.7.0[1.7.0] 87 | |2.2.x 88 | |8 89 | 90 | |=== 91 | 92 | == Articles 93 | 94 | Blogs and articles about this library: 95 | 96 | * https://foojay.io/today/better-error-handling-for-your-spring-boot-rest-apis/[Better Error Handling for Your Spring Boot REST APIs] - Nice article on foojay.io that explains the library in detail 97 | * https://www.wimdeblauwe.com/blog/2021/05/01/error-handling-spring-boot-starter-release-1.6.0/[Error Handling Spring Boot Starter release 1.6.0] - Blog post explaining the updates in version 1.6.0 98 | * https://www.wimdeblauwe.com/blog/2020/07/20/error-handling-library-spring-boot/[Error handling library for Spring Boot] - Original blog post that introduced the library 99 | 100 | == Release 101 | 102 | To release a new version of the project, follow these steps: 103 | 104 | 1. Update `pom.xml` with the new version (Use `mvn versions:set -DgenerateBackupPoms=false -DnewVersion=`) 105 | 2. Commit the changes locally. 106 | 3. Tag the commit with the version (e.g. `1.0.0`) and push the tag. 107 | 4. Create a new release in GitHub via https://github.com/wimdeblauwe/error-handling-spring-boot-starter/releases/new 108 | - Select the newly pushed tag 109 | - Update the release notes. 110 | This should automatically start the [release action](https://github.com/wimdeblauwe/error-handling-spring-boot-starter/actions). 111 | 5. Merge the tag to `master` so the documentation is updated. 112 | 6. Update `pom.xml` again with the next `SNAPSHOT` version. 113 | 7. Close the milestone in the GitHub issue tracker. 114 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.BindApiExceptionHandler; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.HandlerMethodValidationExceptionHandler; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.HttpMessageNotReadableApiExceptionHandler; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.TypeMismatchApiExceptionHandler; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.*; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; 11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 12 | import org.springframework.context.annotation.Bean; 13 | import org.springframework.core.annotation.AnnotationAwareOrderComparator; 14 | import org.springframework.web.client.RestClientResponseException; 15 | 16 | import java.util.List; 17 | 18 | public abstract class AbstractErrorHandlingConfiguration { 19 | private static final Logger LOGGER = LoggerFactory.getLogger(AbstractErrorHandlingConfiguration.class); 20 | 21 | @Bean 22 | @ConditionalOnMissingBean 23 | public ErrorHandlingFacade errorHandlingFacade(List handlers, 24 | FallbackApiExceptionHandler fallbackHandler, 25 | LoggingService loggingService, 26 | List responseCustomizers) { 27 | handlers.sort(AnnotationAwareOrderComparator.INSTANCE); 28 | LOGGER.info("Error Handling Spring Boot Starter active with {} handlers", handlers.size()); 29 | LOGGER.debug("Handlers: {}", handlers); 30 | 31 | return new ErrorHandlingFacade(handlers, fallbackHandler, loggingService, responseCustomizers); 32 | } 33 | 34 | @Bean 35 | @ConditionalOnMissingBean 36 | public LoggingService loggingService(ErrorHandlingProperties properties) { 37 | return new LoggingService(properties); 38 | } 39 | 40 | @Bean 41 | @ConditionalOnMissingBean 42 | public HttpStatusMapper httpStatusMapper(ErrorHandlingProperties properties, 43 | List httpResponseStatusFromExceptionMapperList) { 44 | return new HttpStatusMapper(properties, httpResponseStatusFromExceptionMapperList); 45 | } 46 | 47 | @Bean 48 | public ResponseStatusExceptionHttpResponseStatusFromExceptionMapper responseStatusExceptionHttpResponseStatusFromExceptionMapper() { 49 | return new ResponseStatusExceptionHttpResponseStatusFromExceptionMapper(); 50 | } 51 | 52 | @Bean 53 | @ConditionalOnClass(RestClientResponseException.class) 54 | public RestClientResponseExceptionHttpResponseStatusFromExceptionMapper restClientResponseExceptionHttpResponseStatusFromExceptionMapper() { 55 | return new RestClientResponseExceptionHttpResponseStatusFromExceptionMapper(); 56 | } 57 | 58 | @Bean 59 | @ConditionalOnMissingBean 60 | public ErrorCodeMapper errorCodeMapper(ErrorHandlingProperties properties) { 61 | return new ErrorCodeMapper(properties); 62 | } 63 | 64 | @Bean 65 | @ConditionalOnMissingBean 66 | public ErrorMessageMapper errorMessageMapper(ErrorHandlingProperties properties) { 67 | return new ErrorMessageMapper(properties); 68 | } 69 | 70 | @Bean 71 | @ConditionalOnMissingBean 72 | public FallbackApiExceptionHandler defaultHandler(HttpStatusMapper httpStatusMapper, 73 | ErrorCodeMapper errorCodeMapper, 74 | ErrorMessageMapper errorMessageMapper) { 75 | return new DefaultFallbackApiExceptionHandler(httpStatusMapper, 76 | errorCodeMapper, 77 | errorMessageMapper); 78 | } 79 | 80 | @Bean 81 | @ConditionalOnMissingBean 82 | public TypeMismatchApiExceptionHandler typeMismatchApiExceptionHandler(ErrorHandlingProperties properties, 83 | HttpStatusMapper httpStatusMapper, 84 | ErrorCodeMapper errorCodeMapper, 85 | ErrorMessageMapper errorMessageMapper) { 86 | return new TypeMismatchApiExceptionHandler(properties, httpStatusMapper, errorCodeMapper, errorMessageMapper); 87 | } 88 | 89 | @Bean 90 | @ConditionalOnMissingBean 91 | public HttpMessageNotReadableApiExceptionHandler httpMessageNotReadableApiExceptionHandler(ErrorHandlingProperties properties, 92 | HttpStatusMapper httpStatusMapper, 93 | ErrorCodeMapper errorCodeMapper, 94 | ErrorMessageMapper errorMessageMapper) { 95 | return new HttpMessageNotReadableApiExceptionHandler(properties, httpStatusMapper, errorCodeMapper, errorMessageMapper); 96 | } 97 | 98 | @Bean 99 | @ConditionalOnMissingBean 100 | public BindApiExceptionHandler bindApiExceptionHandler(ErrorHandlingProperties properties, 101 | HttpStatusMapper httpStatusMapper, 102 | ErrorCodeMapper errorCodeMapper, 103 | ErrorMessageMapper errorMessageMapper) { 104 | return new BindApiExceptionHandler(properties, httpStatusMapper, errorCodeMapper, errorMessageMapper); 105 | } 106 | 107 | @Bean 108 | @ConditionalOnMissingBean 109 | public HandlerMethodValidationExceptionHandler handlerMethodValidationExceptionHandler(HttpStatusMapper httpStatusMapper, 110 | ErrorCodeMapper errorCodeMapper, 111 | ErrorMessageMapper errorMessageMapper) { 112 | return new HandlerMethodValidationExceptionHandler(httpStatusMapper, errorCodeMapper, errorMessageMapper); 113 | } 114 | 115 | @Bean 116 | @ConditionalOnMissingBean 117 | public ApiErrorResponseSerializer apiErrorResponseSerializer(ErrorHandlingProperties properties) { 118 | return new ApiErrorResponseSerializer(properties); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponse.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import com.fasterxml.jackson.annotation.JsonAnyGetter; 4 | import com.fasterxml.jackson.annotation.JsonIgnore; 5 | import com.fasterxml.jackson.annotation.JsonInclude; 6 | import com.fasterxml.jackson.databind.annotation.JsonSerialize; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.http.HttpStatusCode; 9 | 10 | import java.util.ArrayList; 11 | import java.util.HashMap; 12 | import java.util.List; 13 | import java.util.Map; 14 | 15 | @JsonInclude(JsonInclude.Include.NON_EMPTY) 16 | public class ApiErrorResponse { 17 | private final HttpStatusCode httpStatus; 18 | private final String code; 19 | private final String message; 20 | private final Map properties; 21 | private final List fieldErrors; 22 | private final List globalErrors; 23 | private final List parameterErrors; 24 | 25 | public ApiErrorResponse(HttpStatusCode httpStatus, String code, String message) { 26 | this.httpStatus = httpStatus; 27 | this.code = code; 28 | this.message = message; 29 | this.properties = new HashMap<>(); 30 | this.fieldErrors = new ArrayList<>(); 31 | this.globalErrors = new ArrayList<>(); 32 | this.parameterErrors = new ArrayList<>(); 33 | } 34 | 35 | @JsonIgnore 36 | public HttpStatusCode getHttpStatus() { 37 | return httpStatus; 38 | } 39 | 40 | public String getCode() { 41 | return code; 42 | } 43 | 44 | public String getMessage() { 45 | return message; 46 | } 47 | 48 | @JsonAnyGetter 49 | public Map getProperties() { 50 | return properties; 51 | } 52 | 53 | public List getFieldErrors() { 54 | return fieldErrors; 55 | } 56 | 57 | public List getGlobalErrors() { 58 | return globalErrors; 59 | } 60 | 61 | public List getParameterErrors() { 62 | return parameterErrors; 63 | } 64 | 65 | public void addErrorProperties(Map errorProperties) { 66 | properties.putAll(errorProperties); 67 | } 68 | 69 | public void addErrorProperty(String propertyName, Object propertyValue) { 70 | properties.put(propertyName, propertyValue); 71 | } 72 | 73 | public void addFieldError(ApiFieldError fieldError) { 74 | fieldErrors.add(fieldError); 75 | } 76 | 77 | public void addGlobalError(ApiGlobalError globalError) { 78 | globalErrors.add(globalError); 79 | } 80 | 81 | public void addParameterError(ApiParameterError parameterError) { 82 | parameterErrors.add(parameterError); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseAccessDeniedHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import jakarta.servlet.ServletException; 8 | import jakarta.servlet.http.HttpServletRequest; 9 | import jakarta.servlet.http.HttpServletResponse; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.http.HttpStatusCode; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.security.access.AccessDeniedException; 14 | import org.springframework.security.web.access.AccessDeniedHandler; 15 | 16 | import java.io.IOException; 17 | import java.nio.charset.StandardCharsets; 18 | 19 | /** 20 | * Use this {@link AccessDeniedHandler} implementation if you want to have a consistent response 21 | * with how this library works when the user is not allowed to access a resource. 22 | *

23 | * It is impossible for the library to provide auto-configuration for this. So you need to manually add 24 | * this to your security configuration. For example: 25 | * 26 | *

27 |  *     public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {*
28 |  *         @Bean
29 |  *         public AccessDeniedHandler accessDeniedHandler(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) {
30 |  *             return new ApiErrorResponseAccessDeniedHandler(objectMapper, httpStatusMapper, errorCodeMapper, errorMessageMapper);
31 |  *         }
32 |  *
33 |  *         @Bean
34 |  *         public SecurityFilterChain securityFilterChain(HttpSecurity http,
35 |  *                                                        AccessDeniedHandler accessDeniedHandler) throws Exception {
36 |  *             http.httpBasic().disable();
37 |  *
38 |  *             http.authorizeHttpRequests().anyRequest().authenticated();
39 |  *
40 |  *             http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
41 |  *
42 |  *             return http.build();
43 |  *         }
44 |  *     }
45 |  * 
46 | * 47 | * @see UnauthorizedEntryPoint 48 | */ 49 | public class ApiErrorResponseAccessDeniedHandler implements AccessDeniedHandler { 50 | private final ObjectMapper objectMapper; 51 | private final HttpStatusMapper httpStatusMapper; 52 | private final ErrorCodeMapper errorCodeMapper; 53 | private final ErrorMessageMapper errorMessageMapper; 54 | 55 | public ApiErrorResponseAccessDeniedHandler(ObjectMapper objectMapper, HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, 56 | ErrorMessageMapper errorMessageMapper) { 57 | this.objectMapper = objectMapper; 58 | this.httpStatusMapper = httpStatusMapper; 59 | this.errorCodeMapper = errorCodeMapper; 60 | this.errorMessageMapper = errorMessageMapper; 61 | } 62 | 63 | @Override 64 | public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) 65 | throws IOException, ServletException { 66 | ApiErrorResponse errorResponse = createResponse(accessDeniedException); 67 | 68 | response.setStatus(errorResponse.getHttpStatus().value()); 69 | response.setContentType(MediaType.APPLICATION_JSON_VALUE); 70 | response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); 71 | response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); 72 | } 73 | 74 | public ApiErrorResponse createResponse(AccessDeniedException exception) { 75 | HttpStatusCode httpStatus = httpStatusMapper.getHttpStatus(exception, HttpStatus.FORBIDDEN); 76 | String code = errorCodeMapper.getErrorCode(exception); 77 | String message = errorMessageMapper.getErrorMessage(exception); 78 | 79 | return new ApiErrorResponse(httpStatus, code, message); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseCustomizer.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | public interface ApiErrorResponseCustomizer { 4 | void customize(ApiErrorResponse response); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseSerializer.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import com.fasterxml.jackson.core.JsonGenerator; 4 | import com.fasterxml.jackson.databind.JsonSerializer; 5 | import com.fasterxml.jackson.databind.SerializerProvider; 6 | import org.springframework.boot.jackson.JsonComponent; 7 | 8 | import java.io.IOException; 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | @JsonComponent 13 | public class ApiErrorResponseSerializer extends JsonSerializer { 14 | 15 | private final ErrorHandlingProperties properties; 16 | 17 | public ApiErrorResponseSerializer(ErrorHandlingProperties properties) { 18 | this.properties = properties; 19 | } 20 | 21 | @Override 22 | public void serialize(ApiErrorResponse errorResponse, 23 | JsonGenerator jsonGenerator, 24 | SerializerProvider serializerProvider) throws IOException { 25 | jsonGenerator.writeStartObject(); 26 | if (properties.isHttpStatusInJsonResponse()) { 27 | jsonGenerator.writeNumberField("status", errorResponse.getHttpStatus().value()); 28 | } 29 | ErrorHandlingProperties.JsonFieldNames fieldNames = properties.getJsonFieldNames(); 30 | jsonGenerator.writeStringField(fieldNames.getCode(), errorResponse.getCode()); 31 | jsonGenerator.writeStringField(fieldNames.getMessage(), errorResponse.getMessage()); 32 | 33 | List fieldErrors = errorResponse.getFieldErrors(); 34 | if (!fieldErrors.isEmpty()) { 35 | jsonGenerator.writeArrayFieldStart(fieldNames.getFieldErrors()); 36 | for (ApiFieldError fieldError : fieldErrors) { 37 | jsonGenerator.writeStartObject(); 38 | jsonGenerator.writeStringField(fieldNames.getCode(), fieldError.getCode()); 39 | jsonGenerator.writeStringField(fieldNames.getMessage(), fieldError.getMessage()); 40 | jsonGenerator.writeStringField("property", fieldError.getProperty()); 41 | jsonGenerator.writeObjectField("rejectedValue", fieldError.getRejectedValue()); 42 | jsonGenerator.writeObjectField("path", fieldError.getPath()); 43 | jsonGenerator.writeEndObject(); 44 | } 45 | jsonGenerator.writeEndArray(); 46 | } 47 | 48 | List globalErrors = errorResponse.getGlobalErrors(); 49 | if (!globalErrors.isEmpty()) { 50 | jsonGenerator.writeArrayFieldStart(fieldNames.getGlobalErrors()); 51 | for (ApiGlobalError globalError : globalErrors) { 52 | jsonGenerator.writeStartObject(); 53 | jsonGenerator.writeStringField(fieldNames.getCode(), globalError.getCode()); 54 | jsonGenerator.writeStringField(fieldNames.getMessage(), globalError.getMessage()); 55 | jsonGenerator.writeEndObject(); 56 | } 57 | jsonGenerator.writeEndArray(); 58 | } 59 | 60 | List parameterErrors = errorResponse.getParameterErrors(); 61 | if (!parameterErrors.isEmpty()) { 62 | jsonGenerator.writeArrayFieldStart(fieldNames.getParameterErrors()); 63 | for (ApiParameterError parameterError : parameterErrors) { 64 | jsonGenerator.writeStartObject(); 65 | jsonGenerator.writeStringField(fieldNames.getCode(), parameterError.getCode()); 66 | jsonGenerator.writeStringField(fieldNames.getMessage(), parameterError.getMessage()); 67 | jsonGenerator.writeStringField("parameter", parameterError.getParameter()); 68 | jsonGenerator.writeObjectField("rejectedValue", parameterError.getRejectedValue()); 69 | jsonGenerator.writeEndObject(); 70 | } 71 | jsonGenerator.writeEndArray(); 72 | } 73 | 74 | Map properties = errorResponse.getProperties(); 75 | for (String property : properties.keySet()) { 76 | jsonGenerator.writeObjectField(property, properties.get(property)); 77 | } 78 | 79 | jsonGenerator.writeEndObject(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | public interface ApiExceptionHandler { 4 | /** 5 | * Determine if this {@link ApiExceptionHandler} can handle the given {@link Throwable}. 6 | * It is guaranteed that this method is called first, and the {@link #handle(Throwable)} method 7 | * will only be called if this method returns true. 8 | * 9 | * @param exception the Throwable that needs to be handled 10 | * @return true if this handler can handle the Throwable, false otherwise. 11 | */ 12 | boolean canHandle(Throwable exception); 13 | 14 | /** 15 | * Handle the given {@link Throwable} and return an {@link ApiErrorResponse} instance 16 | * that will be serialized to JSON and returned from the controller method that has 17 | * thrown the Throwable. 18 | * 19 | * @param exception the Throwable that needs to be handled 20 | * @return the non-null ApiErrorResponse 21 | */ 22 | ApiErrorResponse handle(Throwable exception); 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiFieldError.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | public class ApiFieldError { 4 | private final String code; 5 | private final String property; 6 | private final String message; 7 | private final Object rejectedValue; 8 | private final String path; 9 | 10 | public ApiFieldError(String code, String property, String message, Object rejectedValue, String path) { 11 | this.code = code; 12 | this.property = property; 13 | this.message = message; 14 | this.rejectedValue = rejectedValue; 15 | this.path = path; 16 | } 17 | 18 | public String getCode() { 19 | return code; 20 | } 21 | 22 | public String getProperty() { 23 | return property; 24 | } 25 | 26 | public String getMessage() { 27 | return message; 28 | } 29 | 30 | public Object getRejectedValue() { 31 | return rejectedValue; 32 | } 33 | 34 | public String getPath() { 35 | return path; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiGlobalError.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | public class ApiGlobalError { 4 | private final String code; 5 | private final String message; 6 | 7 | public ApiGlobalError(String code, String message) { 8 | this.code = code; 9 | this.message = message; 10 | } 11 | 12 | public String getCode() { 13 | return code; 14 | } 15 | 16 | public String getMessage() { 17 | return message; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiParameterError.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | public class ApiParameterError { 4 | private final String code; 5 | private final String parameter; 6 | private final String message; 7 | private final Object rejectedValue; 8 | 9 | public ApiParameterError(String code, String parameter, String message, Object rejectedValue) { 10 | this.code = code; 11 | this.parameter = parameter; 12 | this.message = message; 13 | this.rejectedValue = rejectedValue; 14 | } 15 | 16 | public String getCode() { 17 | return code; 18 | } 19 | 20 | public String getParameter() { 21 | return parameter; 22 | } 23 | 24 | public String getMessage() { 25 | return message; 26 | } 27 | 28 | public Object getRejectedValue() { 29 | return rejectedValue; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/DefaultFallbackApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.core.annotation.AnnotationUtils; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.HttpStatusCode; 11 | import org.springframework.util.ReflectionUtils; 12 | import org.springframework.util.StringUtils; 13 | 14 | import java.beans.BeanInfo; 15 | import java.beans.IntrospectionException; 16 | import java.beans.Introspector; 17 | import java.beans.PropertyDescriptor; 18 | import java.lang.reflect.Field; 19 | import java.lang.reflect.InvocationTargetException; 20 | import java.lang.reflect.Method; 21 | import java.util.HashMap; 22 | import java.util.Map; 23 | 24 | 25 | public class DefaultFallbackApiExceptionHandler implements FallbackApiExceptionHandler { 26 | private static final Logger LOGGER = LoggerFactory.getLogger(DefaultFallbackApiExceptionHandler.class); 27 | 28 | private final HttpStatusMapper httpStatusMapper; 29 | private final ErrorCodeMapper errorCodeMapper; 30 | private final ErrorMessageMapper errorMessageMapper; 31 | 32 | public DefaultFallbackApiExceptionHandler(HttpStatusMapper httpStatusMapper, 33 | ErrorCodeMapper errorCodeMapper, 34 | ErrorMessageMapper errorMessageMapper) { 35 | this.httpStatusMapper = httpStatusMapper; 36 | this.errorCodeMapper = errorCodeMapper; 37 | this.errorMessageMapper = errorMessageMapper; 38 | } 39 | 40 | @Override 41 | public ApiErrorResponse handle(Throwable exception) { 42 | HttpStatusCode statusCode = httpStatusMapper.getHttpStatus(exception); 43 | String errorCode = errorCodeMapper.getErrorCode(exception); 44 | String errorMessage = errorMessageMapper.getErrorMessage(exception); 45 | 46 | ApiErrorResponse response = new ApiErrorResponse(statusCode, errorCode, errorMessage); 47 | response.addErrorProperties(getMethodResponseErrorProperties(exception)); 48 | response.addErrorProperties(getFieldResponseErrorProperties(exception)); 49 | 50 | return response; 51 | } 52 | 53 | private Map getFieldResponseErrorProperties(Throwable exception) { 54 | Map result = new HashMap<>(); 55 | ReflectionUtils.doWithFields(exception.getClass(), field -> { 56 | if (field.isAnnotationPresent(ResponseErrorProperty.class)) { 57 | try { 58 | field.setAccessible(true); 59 | Object value = field.get(exception); 60 | if (value != null || field.getAnnotation(ResponseErrorProperty.class).includeIfNull()) { 61 | result.put(getPropertyName(field), value); 62 | } 63 | } catch (IllegalAccessException e) { 64 | LOGGER.error(String.format("Unable to use field result of field %s.%s", exception.getClass().getName(), field.getName())); 65 | } 66 | } 67 | }); 68 | return result; 69 | } 70 | 71 | private Map getMethodResponseErrorProperties(Throwable exception) { 72 | Map result = new HashMap<>(); 73 | Class exceptionClass = exception.getClass(); 74 | ReflectionUtils.doWithMethods(exceptionClass, method -> { 75 | if (method.isAnnotationPresent(ResponseErrorProperty.class) 76 | && method.getReturnType() != Void.TYPE 77 | && method.getParameterCount() == 0) { 78 | try { 79 | method.setAccessible(true); 80 | 81 | Object value = method.invoke(exception); 82 | if (value != null || method.getAnnotation(ResponseErrorProperty.class).includeIfNull()) { 83 | result.put(getPropertyName(exceptionClass, method), 84 | value); 85 | } 86 | } catch (IllegalAccessException | InvocationTargetException e) { 87 | LOGGER.error(String.format("Unable to use method result of method %s.%s", exceptionClass.getName(), method.getName())); 88 | } 89 | } 90 | }); 91 | return result; 92 | } 93 | 94 | private String getPropertyName(Field field) { 95 | ResponseErrorProperty annotation = AnnotationUtils.getAnnotation(field, ResponseErrorProperty.class); 96 | assert annotation != null; 97 | if (StringUtils.hasText(annotation.value())) { 98 | return annotation.value(); 99 | } 100 | 101 | return field.getName(); 102 | } 103 | 104 | private String getPropertyName(Class exceptionClass, Method method) { 105 | ResponseErrorProperty annotation = AnnotationUtils.getAnnotation(method, ResponseErrorProperty.class); 106 | assert annotation != null; 107 | if (StringUtils.hasText(annotation.value())) { 108 | return annotation.value(); 109 | } 110 | 111 | try { 112 | BeanInfo beanInfo = Introspector.getBeanInfo(exceptionClass); 113 | PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors(); 114 | for (PropertyDescriptor propertyDescriptor : propertyDescriptors) { 115 | if (propertyDescriptor.getReadMethod().equals(method)) { 116 | return propertyDescriptor.getName(); 117 | } 118 | } 119 | } catch (IntrospectionException e) { 120 | //ignore 121 | } 122 | 123 | return method.getName(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingFacade.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import java.util.List; 4 | 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | 8 | public class ErrorHandlingFacade { 9 | private static final Logger LOGGER = LoggerFactory.getLogger(ErrorHandlingFacade.class); 10 | 11 | private final List handlers; 12 | private final FallbackApiExceptionHandler fallbackHandler; 13 | private final LoggingService loggingService; 14 | private final List responseCustomizers; 15 | 16 | public ErrorHandlingFacade(List handlers, FallbackApiExceptionHandler fallbackHandler, LoggingService loggingService, 17 | List responseCustomizers) { 18 | this.handlers = handlers; 19 | this.fallbackHandler = fallbackHandler; 20 | this.loggingService = loggingService; 21 | this.responseCustomizers = responseCustomizers; 22 | } 23 | 24 | public ApiErrorResponse handle(Throwable exception) { 25 | ApiErrorResponse errorResponse = null; 26 | for (ApiExceptionHandler handler : handlers) { 27 | if (handler.canHandle(exception)) { 28 | errorResponse = handler.handle(exception); 29 | break; 30 | } 31 | } 32 | 33 | if (errorResponse == null) { 34 | errorResponse = fallbackHandler.handle(exception); 35 | } 36 | 37 | for (ApiErrorResponseCustomizer responseCustomizer : responseCustomizers) { 38 | responseCustomizer.customize(errorResponse); 39 | } 40 | 41 | loggingService.logException(errorResponse, exception); 42 | 43 | return errorResponse; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingProperties.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.boot.logging.LogLevel; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.util.ArrayList; 9 | import java.util.HashMap; 10 | import java.util.List; 11 | import java.util.Map; 12 | 13 | @ConfigurationProperties("error.handling") 14 | @Component 15 | public class ErrorHandlingProperties { 16 | private boolean enabled = true; 17 | 18 | private JsonFieldNames jsonFieldNames = new JsonFieldNames(); 19 | 20 | private ExceptionLogging exceptionLogging = ExceptionLogging.MESSAGE_ONLY; 21 | 22 | private List> fullStacktraceClasses = new ArrayList<>(); 23 | 24 | private List fullStacktraceHttpStatuses = new ArrayList<>(); 25 | 26 | private Map logLevels = new HashMap<>(); 27 | 28 | private DefaultErrorCodeStrategy defaultErrorCodeStrategy = DefaultErrorCodeStrategy.ALL_CAPS; 29 | 30 | private boolean httpStatusInJsonResponse = false; 31 | 32 | private Map httpStatuses = new HashMap<>(); 33 | 34 | private Map codes = new HashMap<>(); 35 | 36 | private Map messages = new HashMap<>(); 37 | 38 | private boolean addPathToError = true; 39 | 40 | private boolean searchSuperClassHierarchy = false; 41 | 42 | private boolean handleFilterChainExceptions = false; 43 | 44 | public boolean isEnabled() { 45 | return enabled; 46 | } 47 | 48 | public void setEnabled(boolean enabled) { 49 | this.enabled = enabled; 50 | } 51 | 52 | public JsonFieldNames getJsonFieldNames() { 53 | return jsonFieldNames; 54 | } 55 | 56 | public void setJsonFieldNames(JsonFieldNames jsonFieldNames) { 57 | this.jsonFieldNames = jsonFieldNames; 58 | } 59 | 60 | public ExceptionLogging getExceptionLogging() { 61 | return exceptionLogging; 62 | } 63 | 64 | public void setExceptionLogging(ExceptionLogging exceptionLogging) { 65 | this.exceptionLogging = exceptionLogging; 66 | } 67 | 68 | public List> getFullStacktraceClasses() { 69 | return fullStacktraceClasses; 70 | } 71 | 72 | public void setFullStacktraceClasses(List> fullStacktraceClasses) { 73 | this.fullStacktraceClasses = fullStacktraceClasses; 74 | } 75 | 76 | public List getFullStacktraceHttpStatuses() { 77 | return fullStacktraceHttpStatuses; 78 | } 79 | 80 | public void setFullStacktraceHttpStatuses(List fullStacktraceHttpStatuses) { 81 | this.fullStacktraceHttpStatuses = fullStacktraceHttpStatuses; 82 | } 83 | 84 | public Map getLogLevels() { 85 | return logLevels; 86 | } 87 | 88 | public void setLogLevels(Map logLevels) { 89 | this.logLevels = logLevels; 90 | } 91 | 92 | public DefaultErrorCodeStrategy getDefaultErrorCodeStrategy() { 93 | return defaultErrorCodeStrategy; 94 | } 95 | 96 | public void setDefaultErrorCodeStrategy(DefaultErrorCodeStrategy defaultErrorCodeStrategy) { 97 | this.defaultErrorCodeStrategy = defaultErrorCodeStrategy; 98 | } 99 | 100 | public boolean isHttpStatusInJsonResponse() { 101 | return httpStatusInJsonResponse; 102 | } 103 | 104 | public void setHttpStatusInJsonResponse(boolean httpStatusInJsonResponse) { 105 | this.httpStatusInJsonResponse = httpStatusInJsonResponse; 106 | } 107 | 108 | public Map getHttpStatuses() { 109 | return httpStatuses; 110 | } 111 | 112 | public void setHttpStatuses(Map httpStatuses) { 113 | this.httpStatuses = httpStatuses; 114 | } 115 | 116 | public Map getCodes() { 117 | return codes; 118 | } 119 | 120 | public void setCodes(Map codes) { 121 | this.codes = codes; 122 | } 123 | 124 | public Map getMessages() { 125 | return messages; 126 | } 127 | 128 | public void setMessages(Map messages) { 129 | this.messages = messages; 130 | } 131 | 132 | public boolean isSearchSuperClassHierarchy() { 133 | return searchSuperClassHierarchy; 134 | } 135 | 136 | public void setSearchSuperClassHierarchy(boolean searchSuperClassHierarchy) { 137 | this.searchSuperClassHierarchy = searchSuperClassHierarchy; 138 | } 139 | 140 | public boolean isAddPathToError() { 141 | return addPathToError; 142 | } 143 | 144 | public void setAddPathToError(boolean addPathToError) { 145 | this.addPathToError = addPathToError; 146 | } 147 | 148 | public boolean isHandleFilterChainExceptions() { 149 | return handleFilterChainExceptions; 150 | } 151 | 152 | public void setHandleFilterChainExceptions(boolean handleFilterChainExceptions) { 153 | this.handleFilterChainExceptions = handleFilterChainExceptions; 154 | } 155 | 156 | public enum ExceptionLogging { 157 | NO_LOGGING, 158 | MESSAGE_ONLY, 159 | WITH_STACKTRACE 160 | } 161 | 162 | public enum DefaultErrorCodeStrategy { 163 | FULL_QUALIFIED_NAME, 164 | ALL_CAPS 165 | } 166 | 167 | public static class JsonFieldNames { 168 | private String code = "code"; 169 | private String message = "message"; 170 | private String fieldErrors = "fieldErrors"; 171 | private String globalErrors = "globalErrors"; 172 | private String parameterErrors = "parameterErrors"; 173 | 174 | public String getCode() { 175 | return code; 176 | } 177 | 178 | public void setCode(String code) { 179 | this.code = code; 180 | } 181 | 182 | public String getMessage() { 183 | return message; 184 | } 185 | 186 | public void setMessage(String message) { 187 | this.message = message; 188 | } 189 | 190 | public String getFieldErrors() { 191 | return fieldErrors; 192 | } 193 | 194 | public void setFieldErrors(String fieldErrors) { 195 | this.fieldErrors = fieldErrors; 196 | } 197 | 198 | public String getGlobalErrors() { 199 | return globalErrors; 200 | } 201 | 202 | public void setGlobalErrors(String globalErrors) { 203 | this.globalErrors = globalErrors; 204 | } 205 | 206 | public String getParameterErrors() { 207 | return parameterErrors; 208 | } 209 | 210 | public void setParameterErrors(String parameterErrors) { 211 | this.parameterErrors = parameterErrors; 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/FallbackApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | public interface FallbackApiExceptionHandler { 4 | ApiErrorResponse handle(Throwable exception); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/LoggingService.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.boot.logging.LogLevel; 6 | import org.springframework.http.HttpStatusCode; 7 | 8 | public class LoggingService { 9 | private static final Logger LOGGER = LoggerFactory.getLogger(LoggingService.class); 10 | private final ErrorHandlingProperties properties; 11 | 12 | public LoggingService(ErrorHandlingProperties properties) { 13 | this.properties = properties; 14 | } 15 | 16 | public void logException(ApiErrorResponse errorResponse, Throwable exception) { 17 | HttpStatusCode httpStatus = errorResponse.getHttpStatus(); 18 | if (properties.getFullStacktraceClasses().contains(exception.getClass())) { 19 | logAccordingToRequestedLogLevel(httpStatus, exception, true); 20 | } else if (!properties.getFullStacktraceHttpStatuses().isEmpty()) { 21 | boolean alreadyLogged = logFullStacktraceIfNeeded(httpStatus, exception); 22 | if (!alreadyLogged) { 23 | doStandardFallbackLogging(httpStatus, exception); 24 | } 25 | } else { 26 | doStandardFallbackLogging(httpStatus, exception); 27 | } 28 | } 29 | 30 | private void logAccordingToRequestedLogLevel(HttpStatusCode httpStatus, Throwable exception, boolean includeStacktrace) { 31 | String httpStatusValue = String.valueOf(httpStatus.value()); 32 | if (properties.getLogLevels().get(httpStatusValue) != null) { 33 | doLogOnLogLevel(properties.getLogLevels().get(httpStatusValue), exception, includeStacktrace); 34 | } else if (properties.getLogLevels().get(getStatusWithLastNumberAsWildcard(httpStatusValue)) != null) { 35 | doLogOnLogLevel(properties.getLogLevels().get(getStatusWithLastNumberAsWildcard(httpStatusValue)), exception, includeStacktrace); 36 | } else if (properties.getLogLevels().get(getStatusWithLastTwoNumbersAsWildcard(httpStatusValue)) != null) { 37 | doLogOnLogLevel(properties.getLogLevels().get(getStatusWithLastTwoNumbersAsWildcard(httpStatusValue)), exception, includeStacktrace); 38 | } else { 39 | doLogOnLogLevel(LogLevel.ERROR, exception, includeStacktrace); 40 | } 41 | } 42 | 43 | private void doLogOnLogLevel(LogLevel logLevel, Throwable exception, boolean includeStacktrace) { 44 | if (includeStacktrace) { 45 | switch (logLevel) { 46 | case TRACE -> LOGGER.trace(exception.getMessage(), exception); 47 | case DEBUG -> LOGGER.debug(exception.getMessage(), exception); 48 | case INFO -> LOGGER.info(exception.getMessage(), exception); 49 | case WARN -> LOGGER.warn(exception.getMessage(), exception); 50 | case ERROR, FATAL -> LOGGER.error(exception.getMessage(), exception); 51 | case OFF -> { 52 | // no-op 53 | } 54 | } 55 | } else { 56 | switch (logLevel) { 57 | case TRACE -> LOGGER.trace(exception.getMessage()); 58 | case DEBUG -> LOGGER.debug(exception.getMessage()); 59 | case INFO -> LOGGER.info(exception.getMessage()); 60 | case WARN -> LOGGER.warn(exception.getMessage()); 61 | case ERROR, FATAL -> LOGGER.error(exception.getMessage()); 62 | case OFF -> { 63 | // no-op 64 | } 65 | } 66 | } 67 | } 68 | 69 | private void doStandardFallbackLogging(HttpStatusCode httpStatus, Throwable exception) { 70 | switch (properties.getExceptionLogging()) { 71 | case WITH_STACKTRACE -> logAccordingToRequestedLogLevel(httpStatus, exception, true); 72 | case MESSAGE_ONLY -> logAccordingToRequestedLogLevel(httpStatus, exception, false); 73 | } 74 | } 75 | 76 | private boolean logFullStacktraceIfNeeded(HttpStatusCode httpStatus, Throwable exception) { 77 | String httpStatusValue = String.valueOf(httpStatus.value()); 78 | if (properties.getFullStacktraceHttpStatuses().contains(httpStatusValue)) { 79 | logAccordingToRequestedLogLevel(httpStatus, exception, true); 80 | return true; 81 | } else if (properties.getFullStacktraceHttpStatuses().contains(getStatusWithLastNumberAsWildcard(httpStatusValue))) { 82 | logAccordingToRequestedLogLevel(httpStatus, exception, true); 83 | return true; 84 | } else if (properties.getFullStacktraceHttpStatuses().contains(getStatusWithLastTwoNumbersAsWildcard(httpStatusValue))) { 85 | logAccordingToRequestedLogLevel(httpStatus, exception, true); 86 | return true; 87 | } 88 | 89 | return false; 90 | } 91 | 92 | private static String getStatusWithLastTwoNumbersAsWildcard(String httpStatusValue) { 93 | return httpStatusValue.replaceFirst("\\d\\d$", "xx"); 94 | } 95 | 96 | private static String getStatusWithLastNumberAsWildcard(String httpStatusValue) { 97 | return httpStatusValue.replaceFirst("\\d$", "x"); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ResponseErrorCode.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Target(ElementType.TYPE) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface ResponseErrorCode { 11 | 12 | String value(); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ResponseErrorProperty.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Target({ElementType.FIELD, ElementType.METHOD}) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface ResponseErrorProperty { 11 | String value() default ""; 12 | 13 | boolean includeIfNull() default false; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/SpringOrmErrorHandlingConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.ObjectOptimisticLockingFailureApiExceptionHandler; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.orm.ObjectOptimisticLockingFailureException; 12 | 13 | @Configuration 14 | @ConditionalOnClass(ObjectOptimisticLockingFailureException.class) 15 | public class SpringOrmErrorHandlingConfiguration { 16 | @Bean 17 | @ConditionalOnMissingBean 18 | public ObjectOptimisticLockingFailureApiExceptionHandler objectOptimisticLockingFailureApiExceptionHandler(ErrorHandlingProperties properties, 19 | HttpStatusMapper httpStatusMapper, 20 | ErrorCodeMapper errorCodeMapper, 21 | ErrorMessageMapper errorMessageMapper) { 22 | return new ObjectOptimisticLockingFailureApiExceptionHandler(properties, httpStatusMapper, errorCodeMapper, errorMessageMapper); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/SpringSecurityErrorHandlingConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.SpringSecurityApiExceptionHandler; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.security.access.AccessDeniedException; 12 | 13 | @Configuration 14 | @ConditionalOnClass(AccessDeniedException.class) 15 | public class SpringSecurityErrorHandlingConfiguration { 16 | @Bean 17 | @ConditionalOnMissingBean 18 | public SpringSecurityApiExceptionHandler springSecurityApiExceptionHandler(ErrorHandlingProperties properties, 19 | HttpStatusMapper httpStatusMapper, 20 | ErrorCodeMapper errorCodeMapper, 21 | ErrorMessageMapper errorMessageMapper) { 22 | return new SpringSecurityApiExceptionHandler(properties, httpStatusMapper, errorCodeMapper, errorMessageMapper); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/UnauthorizedEntryPoint.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 8 | import jakarta.servlet.http.HttpServletRequest; 9 | import jakarta.servlet.http.HttpServletResponse; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.http.HttpStatusCode; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.security.core.AuthenticationException; 14 | import org.springframework.security.web.AuthenticationEntryPoint; 15 | 16 | import java.io.IOException; 17 | import java.nio.charset.StandardCharsets; 18 | 19 | /** 20 | * Use this {@link AuthenticationEntryPoint} implementation if you want to have a consistent response 21 | * with how this library works when the user is not authorized. 22 | *

23 | * It is impossible for the library to provide auto-configuration for this. So you need to manually add 24 | * this to your security configuration. For example: 25 | * 26 | *

27 |  *     public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
28 |  *         @Bean
29 |  *         public UnauthorizedEntryPoint unauthorizedEntryPoint(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) {
30 |  *             return new UnauthorizedEntryPoint(httpStatusMapper, errorCodeMapper, errorMessageMapper, objectMapper);
31 |  *         }
32 |  *
33 |  *         @Bean
34 |  *         public SecurityFilterChain securityFilterChain(HttpSecurity http,
35 |  *                                                        UnauthorizedEntryPoint unauthorizedEntryPoint) throws Exception {
36 |  *             http.httpBasic().disable();
37 |  *
38 |  *             http.authorizeHttpRequests().anyRequest().authenticated();
39 |  *
40 |  *             http.exceptionHandling().authenticationEntryPoint(unauthorizedEntryPoint);
41 |  *
42 |  *             return http.build();
43 |  *         }
44 |  *     }
45 |  * 
46 | * 47 | * @see ApiErrorResponseAccessDeniedHandler 48 | */ 49 | public class UnauthorizedEntryPoint implements AuthenticationEntryPoint { 50 | 51 | protected final HttpStatusMapper httpStatusMapper; 52 | protected final ErrorCodeMapper errorCodeMapper; 53 | protected final ErrorMessageMapper errorMessageMapper; 54 | protected final ObjectMapper objectMapper; 55 | 56 | public UnauthorizedEntryPoint(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) { 57 | this.httpStatusMapper = httpStatusMapper; 58 | this.errorCodeMapper = errorCodeMapper; 59 | this.errorMessageMapper = errorMessageMapper; 60 | this.objectMapper = objectMapper; 61 | } 62 | 63 | @Override 64 | public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws JsonProcessingException, IOException { 65 | ApiErrorResponse errorResponse = createResponse(authException); 66 | 67 | response.setStatus(errorResponse.getHttpStatus().value()); 68 | response.setContentType(MediaType.APPLICATION_JSON_VALUE); 69 | response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); 70 | response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); 71 | } 72 | 73 | public ApiErrorResponse createResponse(AuthenticationException exception) { 74 | HttpStatusCode httpStatus = httpStatusMapper.getHttpStatus(exception, HttpStatus.UNAUTHORIZED); 75 | String code = errorCodeMapper.getErrorCode(exception); 76 | String message = errorMessageMapper.getErrorMessage(exception); 77 | 78 | return new ApiErrorResponse(httpStatus, code, message); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ValidationErrorHandlingConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.ConstraintViolationApiExceptionHandler; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | 12 | import jakarta.validation.ConstraintViolationException; 13 | 14 | @Configuration 15 | @ConditionalOnClass(ConstraintViolationException.class) 16 | public class ValidationErrorHandlingConfiguration { 17 | @Bean 18 | @ConditionalOnMissingBean 19 | public ConstraintViolationApiExceptionHandler constraintViolationApiExceptionHandler(ErrorHandlingProperties properties, 20 | HttpStatusMapper httpStatusMapper, 21 | ErrorCodeMapper errorCodeMapper, 22 | ErrorMessageMapper errorMessageMapper) { 23 | return new ConstraintViolationApiExceptionHandler(properties, httpStatusMapper, errorCodeMapper, errorMessageMapper); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/AbstractApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.http.HttpStatusCode; 9 | 10 | public abstract class AbstractApiExceptionHandler implements ApiExceptionHandler { 11 | protected final HttpStatusMapper httpStatusMapper; 12 | protected final ErrorCodeMapper errorCodeMapper; 13 | protected final ErrorMessageMapper errorMessageMapper; 14 | 15 | public AbstractApiExceptionHandler(HttpStatusMapper httpStatusMapper, 16 | ErrorCodeMapper errorCodeMapper, 17 | ErrorMessageMapper errorMessageMapper) { 18 | this.httpStatusMapper = httpStatusMapper; 19 | this.errorCodeMapper = errorCodeMapper; 20 | this.errorMessageMapper = errorMessageMapper; 21 | } 22 | 23 | protected HttpStatusCode getHttpStatus(Throwable exception, HttpStatus defaultHttpStatus) { 24 | return httpStatusMapper.getHttpStatus(exception, defaultHttpStatus); 25 | } 26 | 27 | protected String getErrorCode(Throwable exception) { 28 | return errorCodeMapper.getErrorCode(exception); 29 | } 30 | 31 | protected String getErrorMessage(Throwable exception) { 32 | return errorMessageMapper.getErrorMessage(exception); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/BindApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiFieldError; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiGlobalError; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 8 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 9 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 10 | import org.hibernate.validator.internal.engine.ConstraintViolationImpl; 11 | import org.springframework.http.HttpStatus; 12 | import org.springframework.validation.BindException; 13 | import org.springframework.validation.BindingResult; 14 | import org.springframework.validation.FieldError; 15 | import org.springframework.web.bind.MethodArgumentNotValidException; 16 | 17 | /** 18 | * Class to handle {@link BindException} and {@link MethodArgumentNotValidException} exceptions. This is typically 19 | * used: 20 | * * when `@Valid` is used on {@link org.springframework.web.bind.annotation.RestController} method arguments. 21 | * * when `@Valid` is used on {@link org.springframework.web.bind.annotation.RestController} query parameters 22 | */ 23 | public class BindApiExceptionHandler extends AbstractApiExceptionHandler { 24 | 25 | private final ErrorHandlingProperties properties; 26 | 27 | public BindApiExceptionHandler(ErrorHandlingProperties properties, 28 | HttpStatusMapper httpStatusMapper, 29 | ErrorCodeMapper errorCodeMapper, 30 | ErrorMessageMapper errorMessageMapper) { 31 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 32 | this.properties = properties; 33 | } 34 | 35 | @Override 36 | public boolean canHandle(Throwable exception) { 37 | // BindingResult is a common interface between org.springframework.validation.BindException 38 | // and org.springframework.web.bind.support.WebExchangeBindException 39 | return exception instanceof BindingResult; 40 | } 41 | 42 | @Override 43 | public ApiErrorResponse handle(Throwable exception) { 44 | 45 | BindingResult bindingResult = (BindingResult) exception; 46 | ApiErrorResponse response = new ApiErrorResponse(getHttpStatus(exception, HttpStatus.BAD_REQUEST), 47 | getErrorCode(exception), 48 | getMessage(bindingResult)); 49 | if (bindingResult.hasFieldErrors()) { 50 | bindingResult.getFieldErrors().stream() 51 | .map(fieldError -> new ApiFieldError(getCode(fieldError), 52 | fieldError.getField(), 53 | getMessage(fieldError), 54 | fieldError.getRejectedValue(), 55 | getPath(fieldError))) 56 | .forEach(response::addFieldError); 57 | } 58 | 59 | if (bindingResult.hasGlobalErrors()) { 60 | bindingResult.getGlobalErrors().stream() 61 | .map(globalError -> new ApiGlobalError(errorCodeMapper.getErrorCode(globalError.getCode()), 62 | errorMessageMapper.getErrorMessage(globalError.getCode(), globalError.getDefaultMessage()))) 63 | .forEach(response::addGlobalError); 64 | } 65 | 66 | return response; 67 | } 68 | 69 | private String getCode(FieldError fieldError) { 70 | String code = fieldError.getCode(); 71 | String fieldSpecificCode = fieldError.getField() + "." + code; 72 | return errorCodeMapper.getErrorCode(fieldSpecificCode, code); 73 | } 74 | 75 | private String getMessage(FieldError fieldError) { 76 | String code = fieldError.getCode(); 77 | String fieldSpecificCode = fieldError.getField() + "." + code; 78 | return errorMessageMapper.getErrorMessage(fieldSpecificCode, code, fieldError.getDefaultMessage()); 79 | } 80 | 81 | private String getMessage(BindingResult bindingResult) { 82 | return "Validation failed for object='" + bindingResult.getObjectName() + "'. Error count: " + bindingResult.getErrorCount(); 83 | } 84 | 85 | private String getPath(FieldError fieldError) { 86 | if (!properties.isAddPathToError()) { 87 | return null; 88 | } 89 | 90 | String path = null; 91 | try { 92 | path = fieldError.unwrap(ConstraintViolationImpl.class) 93 | .getPropertyPath() 94 | .toString(); 95 | } catch (RuntimeException runtimeException) { 96 | // only set a path if we have a ConstraintViolation 97 | } 98 | return path; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ConstraintViolationApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.*; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import jakarta.validation.ConstraintViolation; 8 | import jakarta.validation.ConstraintViolationException; 9 | import jakarta.validation.ElementKind; 10 | import jakarta.validation.Path; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.http.HttpStatus; 14 | 15 | import java.util.Comparator; 16 | import java.util.Optional; 17 | import java.util.Set; 18 | import java.util.stream.Collectors; 19 | import java.util.stream.StreamSupport; 20 | 21 | /** 22 | * {@link io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler} for 23 | * {@link ConstraintViolationException}. This typically happens when there is validation 24 | * on Spring services that gets triggered. 25 | * 26 | * @see BindApiExceptionHandler 27 | */ 28 | public class ConstraintViolationApiExceptionHandler extends AbstractApiExceptionHandler { 29 | private static final Logger LOGGER = LoggerFactory.getLogger(ConstraintViolationApiExceptionHandler.class); 30 | private final ErrorHandlingProperties properties; 31 | 32 | public ConstraintViolationApiExceptionHandler(ErrorHandlingProperties properties, 33 | HttpStatusMapper httpStatusMapper, 34 | ErrorCodeMapper errorCodeMapper, 35 | ErrorMessageMapper errorMessageMapper) { 36 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 37 | this.properties = properties; 38 | } 39 | 40 | @Override 41 | public boolean canHandle(Throwable exception) { 42 | return exception instanceof ConstraintViolationException; 43 | } 44 | 45 | @Override 46 | public ApiErrorResponse handle(Throwable exception) { 47 | 48 | ConstraintViolationException ex = (ConstraintViolationException) exception; 49 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_REQUEST, 50 | getErrorCode(exception), 51 | getMessage(ex)); 52 | Set> violations = ex.getConstraintViolations(); 53 | violations.stream() 54 | // sort violations to ensure deterministic order 55 | .sorted(Comparator.comparing(constraintViolation -> constraintViolation.getPropertyPath().toString())) 56 | .map(constraintViolation -> { 57 | Optional leafNode = getLeafNode(constraintViolation.getPropertyPath()); 58 | if (leafNode.isPresent()) { 59 | Path.Node node = leafNode.get(); 60 | ElementKind elementKind = node.getKind(); 61 | if (elementKind == ElementKind.PROPERTY) { 62 | return new ApiFieldError(getCode(constraintViolation), 63 | node.toString(), 64 | getMessage(constraintViolation), 65 | constraintViolation.getInvalidValue(), 66 | getPath(constraintViolation)); 67 | } else if (elementKind == ElementKind.BEAN) { 68 | return new ApiGlobalError(getCode(constraintViolation), 69 | getMessage(constraintViolation)); 70 | } else if (elementKind == ElementKind.PARAMETER) { 71 | return new ApiParameterError(getCode(constraintViolation), 72 | node.toString(), 73 | getMessage(constraintViolation), 74 | constraintViolation.getInvalidValue()); 75 | } else { 76 | LOGGER.warn("Unable to convert constraint violation with element kind {}: {}", elementKind, constraintViolation); 77 | return null; 78 | } 79 | } else { 80 | LOGGER.warn("Unable to convert constraint violation: {}", constraintViolation); 81 | return null; 82 | } 83 | }) 84 | .forEach(error -> { 85 | if (error instanceof ApiFieldError) { 86 | response.addFieldError((ApiFieldError) error); 87 | } else if (error instanceof ApiGlobalError) { 88 | response.addGlobalError((ApiGlobalError) error); 89 | } else if (error instanceof ApiParameterError) { 90 | response.addParameterError((ApiParameterError) error); 91 | } 92 | }); 93 | 94 | return response; 95 | } 96 | 97 | private Optional getLeafNode(Path path) { 98 | return StreamSupport.stream(path.spliterator(), false).reduce((a, b) -> b); 99 | } 100 | 101 | private String getPath(ConstraintViolation constraintViolation) { 102 | if (!properties.isAddPathToError()) { 103 | return null; 104 | } 105 | 106 | return getPathWithoutPrefix(constraintViolation.getPropertyPath()); 107 | } 108 | 109 | /** 110 | * This method will truncate the first 2 parts of the full property path so the 111 | * method name and argument name are not visible in the returned path. 112 | * 113 | * @param path the full property path of the constraint violation 114 | * @return The truncated property path 115 | */ 116 | private String getPathWithoutPrefix(Path path) { 117 | String collect = StreamSupport.stream(path.spliterator(), false) 118 | .limit(2) 119 | .map(Path.Node::getName) 120 | .collect(Collectors.joining(".")); 121 | String substring = path.toString().substring(collect.length()); 122 | return substring.startsWith(".") ? substring.substring(1) : substring; 123 | } 124 | 125 | private String getCode(ConstraintViolation constraintViolation) { 126 | String code = constraintViolation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(); 127 | String fieldSpecificCode = constraintViolation.getPropertyPath().toString() + "." + code; 128 | return errorCodeMapper.getErrorCode(fieldSpecificCode, code); 129 | } 130 | 131 | private String getMessage(ConstraintViolation constraintViolation) { 132 | String code = constraintViolation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(); 133 | String fieldSpecificCode = constraintViolation.getPropertyPath().toString() + "." + code; 134 | return errorMessageMapper.getErrorMessage(fieldSpecificCode, code, constraintViolation.getMessage()); 135 | } 136 | 137 | private String getMessage(ConstraintViolationException exception) { 138 | return errorMessageMapper.getErrorMessageIfConfiguredInProperties(exception) 139 | .orElseGet(() -> "Validation failed. Error count: " + exception.getConstraintViolations().size()); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiFieldError; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiGlobalError; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 8 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 9 | import org.springframework.context.MessageSourceResolvable; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.validation.FieldError; 12 | import org.springframework.web.method.annotation.HandlerMethodValidationException; 13 | 14 | import java.util.List; 15 | import java.util.Optional; 16 | 17 | public class HandlerMethodValidationExceptionHandler extends AbstractApiExceptionHandler { 18 | 19 | public HandlerMethodValidationExceptionHandler(HttpStatusMapper httpStatusMapper, 20 | ErrorCodeMapper errorCodeMapper, 21 | ErrorMessageMapper errorMessageMapper) { 22 | 23 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 24 | } 25 | 26 | @Override 27 | public boolean canHandle(Throwable exception) { 28 | return exception instanceof HandlerMethodValidationException; 29 | } 30 | 31 | @Override 32 | public ApiErrorResponse handle(Throwable ex) { 33 | var response = new ApiErrorResponse(HttpStatus.BAD_REQUEST, getErrorCode(ex), getErrorMessage(ex)); 34 | var validationException = (HandlerMethodValidationException) ex; 35 | List errors = validationException.getAllErrors(); 36 | 37 | errors.forEach(error -> { 38 | if (error instanceof FieldError fieldError) { 39 | var apiFieldError = new ApiFieldError( 40 | errorCodeMapper.getErrorCode(fieldError.getCode()), 41 | fieldError.getField(), 42 | errorMessageMapper.getErrorMessage(fieldError.getCode(), fieldError.getDefaultMessage()), 43 | fieldError.getRejectedValue(), 44 | null); 45 | response.addFieldError(apiFieldError); 46 | } else { 47 | var lastCode = Optional.ofNullable(error.getCodes()) 48 | .filter(codes -> codes.length > 0) 49 | .map(codes -> codes[codes.length - 1]) 50 | .orElse(null); 51 | var apiGlobalErrorMessage = new ApiGlobalError( 52 | errorCodeMapper.getErrorCode(lastCode), 53 | errorMessageMapper.getErrorMessage(lastCode, error.getDefaultMessage())); 54 | response.addGlobalError(apiGlobalErrorMessage); 55 | } 56 | }); 57 | 58 | return response; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HttpMessageNotReadableApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.converter.HttpMessageNotReadableException; 10 | 11 | /** 12 | * {@link io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler} for 13 | * {@link HttpMessageNotReadableException}. This typically happens when Spring can't properly 14 | * decode the incoming request to JSON. 15 | */ 16 | public class HttpMessageNotReadableApiExceptionHandler extends AbstractApiExceptionHandler { 17 | public HttpMessageNotReadableApiExceptionHandler(ErrorHandlingProperties properties, 18 | HttpStatusMapper httpStatusMapper, 19 | ErrorCodeMapper errorCodeMapper, 20 | ErrorMessageMapper errorMessageMapper) { 21 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 22 | } 23 | 24 | @Override 25 | public boolean canHandle(Throwable exception) { 26 | return exception instanceof HttpMessageNotReadableException; 27 | } 28 | 29 | @Override 30 | public ApiErrorResponse handle(Throwable exception) { 31 | return new ApiErrorResponse(getHttpStatus(exception, HttpStatus.BAD_REQUEST), 32 | getErrorCode(exception), 33 | getErrorMessage(exception)); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/MissingRequestValueExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import org.springframework.core.MethodParameter; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.web.bind.*; 10 | 11 | public class MissingRequestValueExceptionHandler extends AbstractApiExceptionHandler { 12 | public MissingRequestValueExceptionHandler(HttpStatusMapper httpStatusMapper, 13 | ErrorCodeMapper errorCodeMapper, 14 | ErrorMessageMapper errorMessageMapper) { 15 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 16 | } 17 | 18 | @Override 19 | public boolean canHandle(Throwable exception) { 20 | return exception instanceof MissingRequestValueException; 21 | } 22 | 23 | @Override 24 | public ApiErrorResponse handle(Throwable exception) { 25 | ApiErrorResponse response = new ApiErrorResponse(getHttpStatus(exception), 26 | getErrorCode(exception), 27 | getErrorMessage(exception)); 28 | if (exception instanceof MissingMatrixVariableException) { 29 | response.addErrorProperty("variableName", ((MissingMatrixVariableException) exception).getVariableName()); 30 | addParameterInfo(response, ((MissingMatrixVariableException) exception).getParameter()); 31 | } else if (exception instanceof MissingPathVariableException) { 32 | response.addErrorProperty("variableName", ((MissingPathVariableException) exception).getVariableName()); 33 | addParameterInfo(response, ((MissingPathVariableException) exception).getParameter()); 34 | } else if (exception instanceof MissingRequestCookieException) { 35 | response.addErrorProperty("cookieName", ((MissingRequestCookieException) exception).getCookieName()); 36 | addParameterInfo(response, ((MissingRequestCookieException) exception).getParameter()); 37 | } else if (exception instanceof MissingRequestHeaderException) { 38 | response.addErrorProperty("headerName", ((MissingRequestHeaderException) exception).getHeaderName()); 39 | addParameterInfo(response, ((MissingRequestHeaderException) exception).getParameter()); 40 | } else if (exception instanceof MissingServletRequestParameterException) { 41 | String parameterName = ((MissingServletRequestParameterException) exception).getParameterName(); 42 | String parameterType = ((MissingServletRequestParameterException) exception).getParameterType(); 43 | response.addErrorProperty("parameterName", parameterName); 44 | response.addErrorProperty("parameterType", parameterType); 45 | } 46 | return response; 47 | } 48 | 49 | private void addParameterInfo(ApiErrorResponse response, MethodParameter parameter) { 50 | response.addErrorProperty("parameterName", parameter.getParameterName()); 51 | response.addErrorProperty("parameterType", parameter.getParameterType().getSimpleName()); 52 | } 53 | 54 | private HttpStatus getHttpStatus(Throwable exception) { 55 | if (exception instanceof MissingPathVariableException) { 56 | return HttpStatus.INTERNAL_SERVER_ERROR; 57 | } 58 | return HttpStatus.BAD_REQUEST; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ObjectOptimisticLockingFailureApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.orm.ObjectOptimisticLockingFailureException; 10 | 11 | public class ObjectOptimisticLockingFailureApiExceptionHandler extends AbstractApiExceptionHandler { 12 | 13 | public ObjectOptimisticLockingFailureApiExceptionHandler(ErrorHandlingProperties properties, 14 | HttpStatusMapper httpStatusMapper, 15 | ErrorCodeMapper errorCodeMapper, 16 | ErrorMessageMapper errorMessageMapper) { 17 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 18 | } 19 | 20 | @Override 21 | public boolean canHandle(Throwable exception) { 22 | return exception instanceof ObjectOptimisticLockingFailureException; 23 | } 24 | 25 | @Override 26 | public ApiErrorResponse handle(Throwable exception) { 27 | ApiErrorResponse response = new ApiErrorResponse(getHttpStatus(exception, HttpStatus.CONFLICT), 28 | getErrorCode(exception), 29 | getErrorMessage(exception)); 30 | ObjectOptimisticLockingFailureException ex = (ObjectOptimisticLockingFailureException) exception; 31 | response.addErrorProperty("identifier", ex.getIdentifier()); 32 | response.addErrorProperty("persistentClassName", ex.getPersistentClassName()); 33 | return response; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ServerErrorExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import org.springframework.core.MethodParameter; 8 | import org.springframework.web.server.ServerErrorException; 9 | 10 | import java.lang.reflect.Method; 11 | 12 | public class ServerErrorExceptionHandler extends AbstractApiExceptionHandler { 13 | public ServerErrorExceptionHandler(HttpStatusMapper httpStatusMapper, 14 | ErrorCodeMapper errorCodeMapper, 15 | ErrorMessageMapper errorMessageMapper) { 16 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 17 | } 18 | 19 | @Override 20 | public boolean canHandle(Throwable exception) { 21 | return exception instanceof ServerErrorException; 22 | } 23 | 24 | @Override 25 | public ApiErrorResponse handle(Throwable exception) { 26 | ServerErrorException ex = (ServerErrorException) exception; 27 | ApiErrorResponse response = new ApiErrorResponse(ex.getStatusCode(), 28 | getErrorCode(ex), 29 | getErrorMessage(ex)); 30 | MethodParameter methodParameter = ex.getMethodParameter(); 31 | if (methodParameter != null) { 32 | response.addErrorProperty("parameterName", methodParameter.getParameterName()); 33 | response.addErrorProperty("parameterType", methodParameter.getParameterType().getSimpleName()); 34 | } 35 | 36 | Method handlerMethod = ex.getHandlerMethod(); 37 | if (handlerMethod != null) { 38 | response.addErrorProperty("methodName", handlerMethod.getName()); 39 | response.addErrorProperty("methodClassName", handlerMethod.getDeclaringClass().getSimpleName()); 40 | } 41 | 42 | return response; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ServerWebInputExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 7 | import org.springframework.core.MethodParameter; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.HttpStatusCode; 10 | import org.springframework.web.bind.support.WebExchangeBindException; 11 | import org.springframework.web.server.MissingRequestValueException; 12 | import org.springframework.web.server.ServerWebInputException; 13 | 14 | public class ServerWebInputExceptionHandler extends AbstractApiExceptionHandler { 15 | public ServerWebInputExceptionHandler(HttpStatusMapper httpStatusMapper, 16 | ErrorCodeMapper errorCodeMapper, 17 | ErrorMessageMapper errorMessageMapper) { 18 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 19 | } 20 | 21 | @Override 22 | public boolean canHandle(Throwable exception) { 23 | return exception instanceof ServerWebInputException 24 | // WebExchangeBindException should be handled by BindApiExceptionHandler 25 | && !(exception instanceof WebExchangeBindException); 26 | } 27 | 28 | @Override 29 | public ApiErrorResponse handle(Throwable exception) { 30 | ServerWebInputException ex = (ServerWebInputException) exception; 31 | HttpStatusCode status = ex.getStatusCode(); 32 | ApiErrorResponse response = new ApiErrorResponse(status, 33 | getErrorCode(exception), 34 | getErrorMessage(exception)); 35 | MethodParameter methodParameter = ex.getMethodParameter(); 36 | if (methodParameter != null) { 37 | response.addErrorProperty("parameterName", methodParameter.getParameterName()); 38 | response.addErrorProperty("parameterType", methodParameter.getParameterType().getSimpleName()); 39 | } 40 | return response; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.security.access.AccessDeniedException; 10 | import org.springframework.security.authentication.*; 11 | import org.springframework.security.authorization.AuthorizationDeniedException; 12 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 13 | 14 | import java.util.HashMap; 15 | import java.util.Map; 16 | 17 | import static org.springframework.http.HttpStatus.*; 18 | 19 | public class SpringSecurityApiExceptionHandler extends AbstractApiExceptionHandler { 20 | 21 | private static final Map, HttpStatus> EXCEPTION_TO_STATUS_MAPPING; 22 | 23 | static { 24 | EXCEPTION_TO_STATUS_MAPPING = new HashMap<>(); 25 | EXCEPTION_TO_STATUS_MAPPING.put(AccessDeniedException.class, FORBIDDEN); 26 | EXCEPTION_TO_STATUS_MAPPING.put(AuthorizationDeniedException.class, FORBIDDEN); 27 | EXCEPTION_TO_STATUS_MAPPING.put(AccountExpiredException.class, BAD_REQUEST); 28 | EXCEPTION_TO_STATUS_MAPPING.put(AuthenticationCredentialsNotFoundException.class, UNAUTHORIZED); 29 | EXCEPTION_TO_STATUS_MAPPING.put(AuthenticationServiceException.class, INTERNAL_SERVER_ERROR); 30 | EXCEPTION_TO_STATUS_MAPPING.put(BadCredentialsException.class, BAD_REQUEST); 31 | EXCEPTION_TO_STATUS_MAPPING.put(UsernameNotFoundException.class, BAD_REQUEST); 32 | EXCEPTION_TO_STATUS_MAPPING.put(InsufficientAuthenticationException.class, UNAUTHORIZED); 33 | EXCEPTION_TO_STATUS_MAPPING.put(LockedException.class, BAD_REQUEST); 34 | EXCEPTION_TO_STATUS_MAPPING.put(DisabledException.class, BAD_REQUEST); 35 | } 36 | 37 | public SpringSecurityApiExceptionHandler(ErrorHandlingProperties properties, 38 | HttpStatusMapper httpStatusMapper, 39 | ErrorCodeMapper errorCodeMapper, 40 | ErrorMessageMapper errorMessageMapper) { 41 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 42 | } 43 | 44 | @Override 45 | public boolean canHandle(Throwable exception) { 46 | return EXCEPTION_TO_STATUS_MAPPING.containsKey(exception.getClass()); 47 | } 48 | 49 | @Override 50 | public ApiErrorResponse handle(Throwable exception) { 51 | HttpStatus httpStatus = EXCEPTION_TO_STATUS_MAPPING.getOrDefault(exception.getClass(), INTERNAL_SERVER_ERROR); 52 | return new ApiErrorResponse(getHttpStatus(exception, httpStatus), 53 | getErrorCode(exception), 54 | getErrorMessage(exception)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/TypeMismatchApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 8 | import org.springframework.beans.TypeMismatchException; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; 11 | 12 | public class TypeMismatchApiExceptionHandler extends AbstractApiExceptionHandler { 13 | public TypeMismatchApiExceptionHandler(ErrorHandlingProperties properties, 14 | HttpStatusMapper httpStatusMapper, 15 | ErrorCodeMapper errorCodeMapper, 16 | ErrorMessageMapper errorMessageMapper) { 17 | super(httpStatusMapper, errorCodeMapper, errorMessageMapper); 18 | } 19 | 20 | @Override 21 | public boolean canHandle(Throwable exception) { 22 | return exception instanceof TypeMismatchException; 23 | } 24 | 25 | @Override 26 | public ApiErrorResponse handle(Throwable exception) { 27 | ApiErrorResponse response = new ApiErrorResponse(getHttpStatus(exception, HttpStatus.BAD_REQUEST), 28 | getErrorCode(exception), 29 | getErrorMessage(exception)); 30 | TypeMismatchException ex = (TypeMismatchException) exception; 31 | response.addErrorProperty("property", getPropertyName(ex)); 32 | response.addErrorProperty("rejectedValue", ex.getValue()); 33 | response.addErrorProperty("expectedType", ex.getRequiredType() != null ? ex.getRequiredType().getName() : null); 34 | return response; 35 | } 36 | 37 | private String getPropertyName(TypeMismatchException exception) { 38 | if (exception instanceof MethodArgumentTypeMismatchException) { 39 | return ((MethodArgumentTypeMismatchException) exception).getName(); 40 | } else { 41 | return exception.getPropertyName(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/mapper/ErrorCodeMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorCode; 5 | import org.springframework.core.annotation.AnnotationUtils; 6 | 7 | import java.util.Locale; 8 | 9 | /** 10 | * This class contains the logic for getting the matching error code for the given {@link Throwable}. 11 | */ 12 | public class ErrorCodeMapper { 13 | 14 | private final ErrorHandlingProperties properties; 15 | 16 | public ErrorCodeMapper(ErrorHandlingProperties properties) { 17 | this.properties = properties; 18 | } 19 | 20 | public String getErrorCode(Throwable exception) { 21 | String code = getErrorCodeFromPropertiesOrAnnotation(exception.getClass()); 22 | if (code != null) { 23 | return code; 24 | } 25 | switch (properties.getDefaultErrorCodeStrategy()) { 26 | case FULL_QUALIFIED_NAME: 27 | return exception.getClass().getName(); 28 | case ALL_CAPS: 29 | return convertToAllCaps(exception.getClass().getSimpleName()); 30 | default: 31 | throw new IllegalArgumentException("Unknown default error code strategy: " + properties.getDefaultErrorCodeStrategy()); 32 | } 33 | } 34 | 35 | public String getErrorCode(String fieldSpecificErrorCode, String errorCode) { 36 | if (properties.getCodes().containsKey(fieldSpecificErrorCode)) { 37 | return properties.getCodes().get(fieldSpecificErrorCode); 38 | } 39 | 40 | return getErrorCode(errorCode); 41 | } 42 | 43 | public String getErrorCode(String errorCode) { 44 | if (properties.getCodes().containsKey(errorCode)) { 45 | return properties.getCodes().get(errorCode); 46 | } 47 | 48 | return errorCode; 49 | } 50 | 51 | private String convertToAllCaps(String exceptionClassName) { 52 | String result = exceptionClassName.replaceFirst("Exception$", ""); 53 | result = result.replaceAll("([a-z])([A-Z]+)", "$1_$2").toUpperCase(Locale.ENGLISH); 54 | return result; 55 | } 56 | 57 | private String getErrorCodeFromPropertiesOrAnnotation(Class exceptionClass) { 58 | if (exceptionClass == null) { 59 | return null; 60 | } 61 | String exceptionClassName = exceptionClass.getName(); 62 | if (properties.getCodes().containsKey(exceptionClassName)) { 63 | return properties.getCodes().get(exceptionClassName); 64 | } 65 | ResponseErrorCode errorCodeAnnotation = AnnotationUtils.getAnnotation(exceptionClass, ResponseErrorCode.class); 66 | if (errorCodeAnnotation != null) { 67 | return errorCodeAnnotation.value(); 68 | } 69 | 70 | if (properties.isSearchSuperClassHierarchy()) { 71 | return getErrorCodeFromPropertiesOrAnnotation(exceptionClass.getSuperclass()); 72 | } else { 73 | return null; 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/mapper/ErrorMessageMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 4 | 5 | import java.util.Optional; 6 | 7 | import static org.springframework.util.StringUtils.hasText; 8 | 9 | /** 10 | * This class contains the logic for getting the matching error message for the given {@link Throwable}. 11 | */ 12 | public class ErrorMessageMapper { 13 | private final ErrorHandlingProperties properties; 14 | 15 | public ErrorMessageMapper(ErrorHandlingProperties properties) { 16 | this.properties = properties; 17 | } 18 | 19 | public String getErrorMessage(Throwable exception) { 20 | String code = getErrorMessageFromProperties(exception.getClass()); 21 | if (hasText(code)) { 22 | return code; 23 | } 24 | return exception.getMessage(); 25 | } 26 | 27 | public Optional getErrorMessageIfConfiguredInProperties(Throwable exception) { 28 | return Optional.ofNullable(getErrorMessageFromProperties(exception.getClass())); 29 | } 30 | 31 | public String getErrorMessage(String fieldSpecificCode, String code, String defaultMessage) { 32 | if (properties.getMessages().containsKey(fieldSpecificCode)) { 33 | return properties.getMessages().get(fieldSpecificCode); 34 | } 35 | 36 | return getErrorMessage(code, defaultMessage); 37 | } 38 | 39 | public String getErrorMessage(String code, String defaultMessage) { 40 | if (properties.getMessages().containsKey(code)) { 41 | return properties.getMessages().get(code); 42 | } 43 | 44 | return defaultMessage; 45 | } 46 | 47 | private String getErrorMessageFromProperties(Class exceptionClass) { 48 | if (exceptionClass == null) { 49 | return null; 50 | } 51 | String exceptionClassName = exceptionClass.getName(); 52 | if (properties.getMessages().containsKey(exceptionClassName)) { 53 | return properties.getMessages().get(exceptionClassName); 54 | } 55 | if (properties.isSearchSuperClassHierarchy()) { 56 | return getErrorMessageFromProperties(exceptionClass.getSuperclass()); 57 | } else { 58 | return null; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/mapper/HttpResponseStatusFromExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper; 2 | 3 | import org.springframework.http.HttpStatusCode; 4 | 5 | /** 6 | * This interface can be used to contribute Spring beans that can extract 7 | * a {@link HttpStatusCode} from the exception instance. 8 | */ 9 | public interface HttpResponseStatusFromExceptionMapper { 10 | /** 11 | * Determine if this {@link HttpResponseStatusFromExceptionMapper} can extract 12 | * a {@link HttpStatusCode} from the exception instance. 13 | * It is guaranteed that this method is called first, and the {@link #getResponseStatus(Throwable)} method 14 | * will only be called if this method returns true. 15 | * 16 | * @param exception the Throwable that needs to be examined 17 | * @return true if this mapper can extract the response status, false otherwise 18 | */ 19 | boolean canExtractResponseStatus(Throwable exception); 20 | 21 | /** 22 | * Extract a {@link HttpStatusCode} from the exception instance. 23 | * 24 | * @param exception the Throwable that was thrown 25 | * @return the non-null HttpStatusCode. 26 | */ 27 | HttpStatusCode getResponseStatus(Throwable exception); 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/mapper/HttpStatusMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 4 | import org.springframework.core.annotation.AnnotationUtils; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.HttpStatusCode; 7 | import org.springframework.web.bind.annotation.ResponseStatus; 8 | 9 | import java.util.List; 10 | 11 | /** 12 | * This class contains the logic for getting the matching HTTP Status for the given {@link Throwable}. 13 | */ 14 | public class HttpStatusMapper { 15 | private final ErrorHandlingProperties properties; 16 | private final List httpResponseStatusFromExceptionMapperList; 17 | 18 | public HttpStatusMapper(ErrorHandlingProperties properties, 19 | List httpResponseStatusFromExceptionMapperList) { 20 | this.properties = properties; 21 | this.httpResponseStatusFromExceptionMapperList = httpResponseStatusFromExceptionMapperList; 22 | } 23 | 24 | public HttpStatusCode getHttpStatus(Throwable exception) { 25 | return getHttpStatus(exception, HttpStatus.INTERNAL_SERVER_ERROR); 26 | } 27 | 28 | public HttpStatusCode getHttpStatus(Throwable exception, HttpStatus defaultHttpStatus) { 29 | HttpStatusCode status = getHttpStatusFromPropertiesOrAnnotation(exception.getClass()); 30 | if (status != null) { 31 | return status; 32 | } 33 | 34 | for (HttpResponseStatusFromExceptionMapper statusFromExceptionMapper : httpResponseStatusFromExceptionMapperList) { 35 | if( statusFromExceptionMapper.canExtractResponseStatus(exception)) { 36 | return statusFromExceptionMapper.getResponseStatus(exception); 37 | } 38 | } 39 | 40 | return defaultHttpStatus; 41 | } 42 | 43 | private HttpStatusCode getHttpStatusFromPropertiesOrAnnotation(Class exceptionClass) { 44 | if (exceptionClass == null) { 45 | return null; 46 | } 47 | String exceptionClassName = exceptionClass.getName(); 48 | if (properties.getHttpStatuses().containsKey(exceptionClassName)) { 49 | return properties.getHttpStatuses().get(exceptionClassName); 50 | } 51 | 52 | ResponseStatus responseStatus = AnnotationUtils.getAnnotation(exceptionClass, ResponseStatus.class); 53 | if (responseStatus != null) { 54 | return responseStatus.value(); 55 | } 56 | 57 | if (properties.isSearchSuperClassHierarchy()) { 58 | return getHttpStatusFromPropertiesOrAnnotation(exceptionClass.getSuperclass()); 59 | } else { 60 | return null; 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/mapper/ResponseStatusExceptionHttpResponseStatusFromExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper; 2 | 3 | import org.springframework.http.HttpStatusCode; 4 | import org.springframework.web.server.ResponseStatusException; 5 | 6 | public class ResponseStatusExceptionHttpResponseStatusFromExceptionMapper implements HttpResponseStatusFromExceptionMapper { 7 | 8 | @Override 9 | public boolean canExtractResponseStatus(Throwable exception) { 10 | return exception instanceof ResponseStatusException; 11 | } 12 | 13 | @Override 14 | public HttpStatusCode getResponseStatus(Throwable exception) { 15 | return ((ResponseStatusException) exception).getStatusCode(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/mapper/RestClientResponseExceptionHttpResponseStatusFromExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper; 2 | 3 | import org.springframework.http.HttpStatusCode; 4 | import org.springframework.web.client.RestClientResponseException; 5 | 6 | public class RestClientResponseExceptionHttpResponseStatusFromExceptionMapper implements HttpResponseStatusFromExceptionMapper { 7 | @Override 8 | public boolean canExtractResponseStatus(Throwable exception) { 9 | return exception instanceof RestClientResponseException; 10 | } 11 | 12 | @Override 13 | public HttpStatusCode getResponseStatus(Throwable exception) { 14 | return ((RestClientResponseException) exception).getStatusCode(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/GlobalErrorWebExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.reactive; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponseCustomizer; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.boot.autoconfigure.web.ErrorProperties; 10 | import org.springframework.boot.autoconfigure.web.WebProperties; 11 | import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler; 12 | import org.springframework.boot.web.reactive.error.ErrorAttributes; 13 | import org.springframework.context.ApplicationContext; 14 | import org.springframework.http.MediaType; 15 | import org.springframework.web.reactive.function.BodyInserters; 16 | import org.springframework.web.reactive.function.server.*; 17 | import reactor.core.publisher.Mono; 18 | 19 | import java.util.Locale; 20 | 21 | public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler { 22 | private static final Logger LOGGER = LoggerFactory.getLogger(GlobalErrorWebExceptionHandler.class); 23 | 24 | private final ErrorHandlingFacade errorHandlingFacade; 25 | 26 | public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes, 27 | WebProperties.Resources resources, 28 | ErrorProperties errorProperties, 29 | ApplicationContext applicationContext, 30 | ErrorHandlingFacade errorHandlingFacade) { 31 | super(errorAttributes, resources, errorProperties, applicationContext); 32 | this.errorHandlingFacade = errorHandlingFacade; 33 | } 34 | 35 | @Override 36 | protected RouterFunction getRoutingFunction(ErrorAttributes errorAttributes) { 37 | return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse); 38 | } 39 | 40 | @Override 41 | protected Mono renderErrorResponse(ServerRequest request) { 42 | return handleException(request); 43 | } 44 | 45 | public Mono handleException(ServerRequest request) { 46 | Locale locale = request.exchange().getLocaleContext().getLocale(); 47 | Throwable exception = getError(request); 48 | LOGGER.debug("webRequest: {}", request); 49 | LOGGER.debug("locale: {}", locale); 50 | 51 | ApiErrorResponse errorResponse = errorHandlingFacade.handle(exception); 52 | 53 | return ServerResponse.status(errorResponse.getHttpStatus()) 54 | .contentType(MediaType.APPLICATION_JSON) 55 | .body(BodyInserters.fromValue(errorResponse)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/ReactiveErrorHandlingConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.reactive; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.*; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.ServerErrorExceptionHandler; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.ServerWebInputExceptionHandler; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 8 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 9 | import org.springframework.beans.factory.ObjectProvider; 10 | import org.springframework.boot.autoconfigure.AutoConfiguration; 11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 12 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 13 | import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; 14 | import org.springframework.boot.autoconfigure.condition.SearchStrategy; 15 | import org.springframework.boot.autoconfigure.web.ServerProperties; 16 | import org.springframework.boot.autoconfigure.web.WebProperties; 17 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 18 | import org.springframework.boot.web.reactive.error.DefaultErrorAttributes; 19 | import org.springframework.boot.web.reactive.error.ErrorAttributes; 20 | import org.springframework.context.ApplicationContext; 21 | import org.springframework.context.annotation.Bean; 22 | import org.springframework.context.annotation.Import; 23 | import org.springframework.context.annotation.PropertySource; 24 | import org.springframework.core.annotation.Order; 25 | import org.springframework.http.codec.ServerCodecConfigurer; 26 | import org.springframework.web.reactive.result.view.ViewResolver; 27 | 28 | import java.util.List; 29 | import java.util.stream.Collectors; 30 | 31 | @AutoConfiguration 32 | @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) 33 | @EnableConfigurationProperties({ErrorHandlingProperties.class, WebProperties.class, ServerProperties.class}) 34 | @ConditionalOnProperty(value = "error.handling.enabled", matchIfMissing = true) 35 | @PropertySource("classpath:/error-handling-defaults.properties") 36 | @Import({ValidationErrorHandlingConfiguration.class, 37 | SpringSecurityErrorHandlingConfiguration.class, 38 | SpringOrmErrorHandlingConfiguration.class}) 39 | public class ReactiveErrorHandlingConfiguration extends AbstractErrorHandlingConfiguration { 40 | 41 | @Bean 42 | @ConditionalOnMissingBean 43 | public ServerWebInputExceptionHandler serverWebInputExceptionHandler(HttpStatusMapper httpStatusMapper, 44 | ErrorCodeMapper errorCodeMapper, 45 | ErrorMessageMapper errorMessageMapper) { 46 | return new ServerWebInputExceptionHandler(httpStatusMapper, 47 | errorCodeMapper, 48 | errorMessageMapper); 49 | } 50 | 51 | @Bean 52 | @ConditionalOnMissingBean 53 | public ServerErrorExceptionHandler serverErrorExceptionHandler(HttpStatusMapper httpStatusMapper, 54 | ErrorCodeMapper errorCodeMapper, 55 | ErrorMessageMapper errorMessageMapper) { 56 | return new ServerErrorExceptionHandler(httpStatusMapper, 57 | errorCodeMapper, 58 | errorMessageMapper); 59 | } 60 | 61 | @Bean 62 | @ConditionalOnMissingBean 63 | @Order(-2) 64 | public GlobalErrorWebExceptionHandler globalErrorWebExceptionHandler(ErrorAttributes errorAttributes, 65 | ServerProperties serverProperties, 66 | WebProperties webProperties, 67 | ObjectProvider viewResolvers, 68 | ServerCodecConfigurer serverCodecConfigurer, 69 | ApplicationContext applicationContext, 70 | ErrorHandlingFacade errorHandlingFacade) { 71 | 72 | GlobalErrorWebExceptionHandler exceptionHandler = new GlobalErrorWebExceptionHandler(errorAttributes, 73 | webProperties.getResources(), 74 | serverProperties.getError(), 75 | applicationContext, 76 | errorHandlingFacade); 77 | exceptionHandler.setViewResolvers(viewResolvers.orderedStream().collect(Collectors.toList())); 78 | exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters()); 79 | exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders()); 80 | return exceptionHandler; 81 | } 82 | 83 | @Bean 84 | @ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT) 85 | public DefaultErrorAttributes errorAttributes() { 86 | return new DefaultErrorAttributes(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ErrorHandlingControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; 8 | import org.springframework.http.ResponseEntity; 9 | import org.springframework.web.bind.annotation.ControllerAdvice; 10 | import org.springframework.web.bind.annotation.ExceptionHandler; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import org.springframework.web.context.request.WebRequest; 13 | 14 | import java.util.Locale; 15 | 16 | @ControllerAdvice(annotations = RestController.class) 17 | @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) 18 | public class ErrorHandlingControllerAdvice { 19 | private static final Logger LOGGER = LoggerFactory.getLogger(ErrorHandlingControllerAdvice.class); 20 | 21 | private final ErrorHandlingFacade errorHandlingFacade; 22 | 23 | public ErrorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) { 24 | this.errorHandlingFacade = errorHandlingFacade; 25 | } 26 | 27 | @ExceptionHandler 28 | public ResponseEntity handleException(Throwable exception, WebRequest webRequest, Locale locale) { 29 | LOGGER.debug("webRequest: {}", webRequest); 30 | LOGGER.debug("locale: {}", locale); 31 | 32 | ApiErrorResponse errorResponse = errorHandlingFacade.handle(exception); 33 | 34 | return ResponseEntity.status(errorResponse.getHttpStatus()) 35 | .body(errorResponse); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilter.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade; 6 | import jakarta.servlet.FilterChain; 7 | import jakarta.servlet.ServletException; 8 | import jakarta.servlet.http.HttpServletRequest; 9 | import jakarta.servlet.http.HttpServletResponse; 10 | import org.springframework.web.filter.OncePerRequestFilter; 11 | 12 | import java.io.IOException; 13 | 14 | public class FilterChainExceptionHandlerFilter extends OncePerRequestFilter { 15 | 16 | private final ErrorHandlingFacade errorHandlingFacade; 17 | private final ObjectMapper objectMapper; 18 | 19 | public FilterChainExceptionHandlerFilter(ErrorHandlingFacade errorHandlingFacade, ObjectMapper objectMapper) { 20 | this.errorHandlingFacade = errorHandlingFacade; 21 | this.objectMapper = objectMapper; 22 | } 23 | 24 | @Override 25 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 26 | throws ServletException, IOException { 27 | 28 | try { 29 | filterChain.doFilter(request, response); 30 | } catch (Exception ex) { 31 | ApiErrorResponse errorResponse = errorHandlingFacade.handle(ex); 32 | response.setStatus(errorResponse.getHttpStatus().value()); 33 | var jsonResponseBody = objectMapper.writeValueAsString(errorResponse); 34 | response.getWriter().write(jsonResponseBody); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ServletErrorHandlingConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.*; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.MissingRequestValueExceptionHandler; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 8 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 9 | import org.springframework.boot.autoconfigure.AutoConfiguration; 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 12 | import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; 13 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 14 | import org.springframework.boot.web.servlet.FilterRegistrationBean; 15 | import org.springframework.context.annotation.Bean; 16 | import org.springframework.context.annotation.Import; 17 | import org.springframework.context.annotation.PropertySource; 18 | import org.springframework.core.Ordered; 19 | 20 | @AutoConfiguration 21 | @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) 22 | @EnableConfigurationProperties(ErrorHandlingProperties.class) 23 | @ConditionalOnProperty(value = "error.handling.enabled", matchIfMissing = true) 24 | @PropertySource("classpath:/error-handling-defaults.properties") 25 | @Import({ValidationErrorHandlingConfiguration.class, 26 | SpringSecurityErrorHandlingConfiguration.class, 27 | SpringOrmErrorHandlingConfiguration.class}) 28 | public class ServletErrorHandlingConfiguration extends AbstractErrorHandlingConfiguration { 29 | 30 | @Bean 31 | @ConditionalOnMissingBean 32 | public MissingRequestValueExceptionHandler missingRequestValueExceptionHandler(HttpStatusMapper httpStatusMapper, 33 | ErrorCodeMapper errorCodeMapper, 34 | ErrorMessageMapper errorMessageMapper) { 35 | return new MissingRequestValueExceptionHandler(httpStatusMapper, 36 | errorCodeMapper, 37 | errorMessageMapper); 38 | } 39 | 40 | @Bean 41 | @ConditionalOnMissingBean 42 | public ErrorHandlingControllerAdvice errorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) { 43 | return new ErrorHandlingControllerAdvice(errorHandlingFacade); 44 | } 45 | 46 | @Bean 47 | @ConditionalOnProperty("error.handling.handle-filter-chain-exceptions") 48 | public FilterChainExceptionHandlerFilter filterChainExceptionHandlerFilter(ErrorHandlingFacade errorHandlingFacade, ObjectMapper objectMapper) { 49 | return new FilterChainExceptionHandlerFilter(errorHandlingFacade, objectMapper); 50 | } 51 | 52 | @Bean 53 | @ConditionalOnProperty("error.handling.handle-filter-chain-exceptions") 54 | public FilterRegistrationBean filterChainExceptionHandlerFilterFilterRegistrationBean(FilterChainExceptionHandlerFilter filterChainExceptionHandlerFilter) { 55 | FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); 56 | registrationBean.setFilter(filterChainExceptionHandlerFilter); 57 | registrationBean.addUrlPatterns("/*"); 58 | registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); 59 | 60 | return registrationBean; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration 2 | io.github.wimdeblauwe.errorhandlingspringbootstarter.reactive.ReactiveErrorHandlingConfiguration 3 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebFlux.imports: -------------------------------------------------------------------------------- 1 | io.github.wimdeblauwe.errorhandlingspringbootstarter.reactive.ReactiveErrorHandlingConfiguration 2 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc.imports: -------------------------------------------------------------------------------- 1 | io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration 2 | -------------------------------------------------------------------------------- /src/main/resources/error-handling-defaults.properties: -------------------------------------------------------------------------------- 1 | error.handling.codes.org.springframework.web.bind.MethodArgumentNotValidException=VALIDATION_FAILED 2 | error.handling.codes.org.springframework.web.method.annotation.HandlerMethodValidationException=VALIDATION_FAILED 3 | error.handling.messages.org.springframework.web.method.annotation.HandlerMethodValidationException=There was a validation failure. 4 | error.handling.codes.org.springframework.http.converter.HttpMessageNotReadableException=MESSAGE_NOT_READABLE 5 | error.handling.codes.jakarta.validation.ConstraintViolationException=VALIDATION_FAILED 6 | error.handling.codes.org.springframework.beans.TypeMismatchException=TYPE_MISMATCH 7 | error.handling.codes.org.springframework.web.method.annotation.MethodArgumentTypeMismatchException=ARGUMENT_TYPE_MISMATCH 8 | error.handling.codes.org.springframework.security.access.AccessDeniedException=ACCESS_DENIED 9 | error.handling.codes.org.springframework.security.authentication.AccountExpiredException=ACCOUNT_EXPIRED 10 | error.handling.codes.org.springframework.security.authentication.AuthenticationCredentialsNotFoundException=AUTH_CREDENTIALS_NOT_FOUND 11 | error.handling.codes.org.springframework.security.authentication.AuthenticationServiceException=AUTH_SERVICE_ERROR 12 | error.handling.codes.org.springframework.security.authentication.BadCredentialsException=BAD_CREDENTIALS 13 | error.handling.codes.org.springframework.security.core.userdetails.UsernameNotFoundException=USERNAME_NOT_FOUND 14 | error.handling.codes.org.springframework.security.authentication.InsufficientAuthenticationException=UNAUTHORIZED 15 | error.handling.codes.org.springframework.security.authentication.LockedException=ACCOUNT_LOCKED 16 | error.handling.codes.org.springframework.security.authentication.DisabledException=ACCOUNT_DISABLED 17 | error.handling.codes.org.springframework.validation.BindException=VALIDATION_FAILED 18 | error.handling.codes.org.springframework.web.bind.support.WebExchangeBindException=VALIDATION_FAILED 19 | error.handling.codes.org.springframework.web.bind.MissingServletRequestParameterException=VALIDATION_FAILED 20 | error.handling.codes.org.springframework.web.bind.MissingMatrixVariableException=VALIDATION_FAILED 21 | error.handling.codes.org.springframework.web.bind.MissingRequestCookieException=VALIDATION_FAILED 22 | error.handling.codes.org.springframework.web.bind.MissingRequestHeaderException=VALIDATION_FAILED 23 | error.handling.codes.org.springframework.orm.ObjectOptimisticLockingFailureException=OPTIMISTIC_LOCKING_ERROR 24 | error.handling.codes.org.springframework.web.server.ServerWebInputException=VALIDATION_FAILED 25 | error.handling.codes.org.springframework.web.server.MissingRequestValueException=VALIDATION_FAILED 26 | error.handling.codes.AssertFalse=REQUIRED_FALSE 27 | error.handling.codes.AssertTrue=REQUIRED_TRUE 28 | error.handling.codes.DecimalMax=DECIMAL_VALUE_GREATER_THAN_MAX 29 | error.handling.codes.DecimalMin=DECIMAL_VALUE_LESS_THAN_MIN 30 | error.handling.codes.Digits=INVALID_DIGITS 31 | error.handling.codes.Email=INVALID_EMAIL 32 | error.handling.codes.Future=DATE_SHOULD_BE_IN_FUTURE 33 | error.handling.codes.FutureOrPresent=DATE_SHOULD_BE_PRESENT_OR_IN_FUTURE 34 | error.handling.codes.Max=VALUE_GREATER_THAN_MAX 35 | error.handling.codes.Min=VALUE_LESS_THAN_MIN 36 | error.handling.codes.Negative=VALUE_SHOULD_BE_NEGATIVE 37 | error.handling.codes.NegativeOrZero=VALUE_SHOULD_BE_NEGATIVE_OR_ZERO 38 | error.handling.codes.NotBlank=REQUIRED_NOT_BLANK 39 | error.handling.codes.NotEmpty=REQUIRED_NOT_EMPTY 40 | error.handling.codes.NotNull=REQUIRED_NOT_NULL 41 | error.handling.codes.Null=REQUIRED_NULL 42 | error.handling.codes.Past=DATE_SHOULD_BE_IN_PAST 43 | error.handling.codes.PastOrPresent=DATE_SHOULD_BE_PRESENT_OR_IN_PAST 44 | error.handling.codes.Pattern=REGEX_PATTERN_VALIDATION_FAILED 45 | error.handling.codes.Positive=VALUE_SHOULD_BE_POSITIVE 46 | error.handling.codes.PositiveOrZero=VALUE_SHOULD_BE_POSITIVE_OR_ZERO 47 | error.handling.codes.Size=INVALID_SIZE 48 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseSerializationTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.junit.jupiter.api.AfterEach; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Nested; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.autoconfigure.json.JsonTest; 10 | import org.springframework.context.annotation.Import; 11 | import org.springframework.http.HttpStatus; 12 | import org.springframework.test.annotation.DirtiesContext; 13 | 14 | import java.io.IOException; 15 | 16 | import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; 17 | 18 | @JsonTest 19 | @Import(ErrorHandlingProperties.class) 20 | class ApiErrorResponseSerializationTest { 21 | 22 | @Autowired 23 | private ObjectMapper objectMapper; 24 | @Autowired 25 | private ErrorHandlingProperties properties; 26 | 27 | @Test 28 | void testSerialization() throws IOException { 29 | String json = objectMapper.writeValueAsString(new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message")); 30 | assertThatJson(json).and( 31 | jsonAssert -> jsonAssert.node("code").isEqualTo("TEST_CODE"), 32 | jsonAssert -> jsonAssert.node("message").isEqualTo("Test message"), 33 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent() 34 | 35 | ); 36 | } 37 | 38 | @Test 39 | void testSerializationWithFieldError() throws IOException { 40 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message"); 41 | response.addFieldError(new ApiFieldError("FIELD_ERROR_CODE", "testField", "Test Field Message", "bad", "path")); 42 | String json = objectMapper.writeValueAsString(response); 43 | assertThatJson(json).and( 44 | jsonAssert -> jsonAssert.node("code").isEqualTo("TEST_CODE"), 45 | jsonAssert -> jsonAssert.node("message").isEqualTo("Test message"), 46 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent(), 47 | jsonAssert -> jsonAssert.node("fieldErrors[0].code").isEqualTo("FIELD_ERROR_CODE"), 48 | jsonAssert -> jsonAssert.node("fieldErrors[0].property").isEqualTo("testField"), 49 | jsonAssert -> jsonAssert.node("fieldErrors[0].message").isEqualTo("Test Field Message"), 50 | jsonAssert -> jsonAssert.node("fieldErrors[0].rejectedValue").isEqualTo("bad"), 51 | jsonAssert -> jsonAssert.node("fieldErrors[0].path").isEqualTo("path") 52 | ); 53 | } 54 | 55 | @Test 56 | void testSerializationWithFieldErrorWithNullRejectedValue() throws IOException { 57 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message"); 58 | response.addFieldError(new ApiFieldError("FIELD_ERROR_CODE", "testField", "Test Field Message", null, "path")); 59 | String json = objectMapper.writeValueAsString(response); 60 | assertThatJson(json).and( 61 | jsonAssert -> jsonAssert.node("code").isEqualTo("TEST_CODE"), 62 | jsonAssert -> jsonAssert.node("message").isEqualTo("Test message"), 63 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent(), 64 | jsonAssert -> jsonAssert.node("fieldErrors[0].code").isEqualTo("FIELD_ERROR_CODE"), 65 | jsonAssert -> jsonAssert.node("fieldErrors[0].property").isEqualTo("testField"), 66 | jsonAssert -> jsonAssert.node("fieldErrors[0].message").isEqualTo("Test Field Message"), 67 | jsonAssert -> jsonAssert.node("fieldErrors[0].rejectedValue").isNull(), 68 | jsonAssert -> jsonAssert.node("fieldErrors[0].path").isEqualTo("path") 69 | ); 70 | } 71 | 72 | @Test 73 | void testSerializationWithGlobalError() throws IOException { 74 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message"); 75 | response.addGlobalError(new ApiGlobalError("GLOBAL_ERROR_CODE", "Test Global Message")); 76 | String json = objectMapper.writeValueAsString(response); 77 | assertThatJson(json).and( 78 | jsonAssert -> jsonAssert.node("code").isEqualTo("TEST_CODE"), 79 | jsonAssert -> jsonAssert.node("message").isEqualTo("Test message"), 80 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent(), 81 | jsonAssert -> jsonAssert.node("globalErrors[0].code").isEqualTo("GLOBAL_ERROR_CODE"), 82 | jsonAssert -> jsonAssert.node("globalErrors[0].message").isEqualTo("Test Global Message") 83 | ); 84 | } 85 | 86 | @Test 87 | void testSerializationWithErrorProperty() throws IOException { 88 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message"); 89 | response.addErrorProperty("property1", "stringValue"); 90 | response.addErrorProperty("property2", 15); 91 | String json = objectMapper.writeValueAsString(response); 92 | assertThatJson(json).and( 93 | jsonAssert -> jsonAssert.node("code").isEqualTo("TEST_CODE"), 94 | jsonAssert -> jsonAssert.node("message").isEqualTo("Test message"), 95 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent(), 96 | jsonAssert -> jsonAssert.node("property1").isEqualTo("stringValue"), 97 | jsonAssert -> jsonAssert.node("property2").isEqualTo(15) 98 | ); 99 | } 100 | 101 | @Test 102 | void testSerializationWithErrorPropertyThatIsNull() throws IOException { 103 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message"); 104 | response.addErrorProperty("property1", null); 105 | String json = objectMapper.writeValueAsString(response); 106 | assertThatJson(json).and( 107 | jsonAssert -> jsonAssert.node("code").isEqualTo("TEST_CODE"), 108 | jsonAssert -> jsonAssert.node("message").isEqualTo("Test message"), 109 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent(), 110 | jsonAssert -> jsonAssert.node("property1").isNull() 111 | ); 112 | } 113 | 114 | @Test 115 | @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) 116 | void testHttpStatusInJsonResponse() throws IOException { 117 | properties.setHttpStatusInJsonResponse(true); 118 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_REQUEST, "TEST_CODE", "Test message"); 119 | 120 | String json = objectMapper.writeValueAsString(response); 121 | assertThatJson(json).and( 122 | jsonAssert -> jsonAssert.node("status").isEqualTo(400) 123 | ); 124 | } 125 | 126 | @Test 127 | void testHttpStatusInJsonResponseDisabledByDefault() throws IOException { 128 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_REQUEST, "TEST_CODE", "Test message"); 129 | 130 | String json = objectMapper.writeValueAsString(response); 131 | assertThatJson(json).and( 132 | jsonAssert -> jsonAssert.node("status").isAbsent() 133 | ); 134 | } 135 | 136 | @Nested 137 | class CustomFieldNamesTests { 138 | @BeforeEach 139 | void setCustomName() { 140 | properties.getJsonFieldNames().setMessage("description"); 141 | properties.getJsonFieldNames().setCode("errorCode"); 142 | properties.getJsonFieldNames().setFieldErrors("fieldFailures"); 143 | properties.getJsonFieldNames().setGlobalErrors("globalFailures"); 144 | } 145 | 146 | @AfterEach 147 | void resetCustomName() { 148 | properties.getJsonFieldNames().setMessage("message"); 149 | properties.getJsonFieldNames().setCode("code"); 150 | properties.getJsonFieldNames().setFieldErrors("fieldErrors"); 151 | properties.getJsonFieldNames().setGlobalErrors("globalErrors"); 152 | } 153 | 154 | @Test 155 | void testSerializationWithCustomMessageFieldName() throws IOException { 156 | String json = objectMapper.writeValueAsString(new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message")); 157 | assertThatJson(json).and( 158 | jsonAssert -> jsonAssert.node("errorCode").isEqualTo("TEST_CODE"), 159 | jsonAssert -> jsonAssert.node("description").isEqualTo("Test message"), 160 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent() 161 | 162 | ); 163 | } 164 | 165 | @Test 166 | void testSerializationWithFieldError() throws IOException { 167 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message"); 168 | response.addFieldError(new ApiFieldError("FIELD_ERROR_CODE", "testField", "Test Field Message", "bad", "path")); 169 | String json = objectMapper.writeValueAsString(response); 170 | assertThatJson(json).and( 171 | jsonAssert -> jsonAssert.node("errorCode").isEqualTo("TEST_CODE"), 172 | jsonAssert -> jsonAssert.node("description").isEqualTo("Test message"), 173 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent(), 174 | jsonAssert -> jsonAssert.node("fieldFailures[0].errorCode").isEqualTo("FIELD_ERROR_CODE"), 175 | jsonAssert -> jsonAssert.node("fieldFailures[0].property").isEqualTo("testField"), 176 | jsonAssert -> jsonAssert.node("fieldFailures[0].description").isEqualTo("Test Field Message"), 177 | jsonAssert -> jsonAssert.node("fieldFailures[0].rejectedValue").isEqualTo("bad"), 178 | jsonAssert -> jsonAssert.node("fieldFailures[0].path").isEqualTo("path") 179 | ); 180 | } 181 | 182 | @Test 183 | void testSerializationWithGlobalError() throws IOException { 184 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.BAD_GATEWAY, "TEST_CODE", "Test message"); 185 | response.addGlobalError(new ApiGlobalError("GLOBAL_ERROR_CODE", "Test Global Message")); 186 | String json = objectMapper.writeValueAsString(response); 187 | assertThatJson(json).and( 188 | jsonAssert -> jsonAssert.node("errorCode").isEqualTo("TEST_CODE"), 189 | jsonAssert -> jsonAssert.node("description").isEqualTo("Test message"), 190 | jsonAssert -> jsonAssert.node("httpStatus").isAbsent(), 191 | jsonAssert -> jsonAssert.node("globalFailures[0].errorCode").isEqualTo("GLOBAL_ERROR_CODE"), 192 | jsonAssert -> jsonAssert.node("globalFailures[0].description").isEqualTo("Test Global Message") 193 | ); 194 | } 195 | 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/DummyApplication.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication; 4 | 5 | /** 6 | * Just create this here to make @JsonTest work 7 | */ 8 | @SpringBootApplication 9 | public class DummyApplication { 10 | } 11 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingPropertiesTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.test.context.TestPropertySource; 9 | import org.springframework.test.context.junit.jupiter.SpringExtension; 10 | 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | 13 | @ExtendWith(SpringExtension.class) 14 | @EnableConfigurationProperties(value = ErrorHandlingProperties.class) 15 | @TestPropertySource("error-handling-properties-test.properties") 16 | class ErrorHandlingPropertiesTest { 17 | 18 | @Autowired 19 | private ErrorHandlingProperties properties; 20 | 21 | @Test 22 | void loadProperties() { 23 | assertThat(properties).isNotNull(); 24 | assertThat(properties.isEnabled()).isFalse(); 25 | assertThat(properties.getJsonFieldNames().getCode()).isEqualTo("kode"); 26 | assertThat(properties.getJsonFieldNames().getMessage()).isEqualTo("description"); 27 | assertThat(properties.getJsonFieldNames().getFieldErrors()).isEqualTo("veldFouten"); 28 | assertThat(properties.getJsonFieldNames().getGlobalErrors()).isEqualTo("globaleFouten"); 29 | assertThat(properties.getExceptionLogging()).isEqualTo(ErrorHandlingProperties.ExceptionLogging.WITH_STACKTRACE); 30 | assertThat(properties.getDefaultErrorCodeStrategy()).isEqualTo(ErrorHandlingProperties.DefaultErrorCodeStrategy.ALL_CAPS); 31 | assertThat(properties.getHttpStatuses()) 32 | .hasSize(1) 33 | .hasEntrySatisfying("java.lang.IllegalArgumentException", httpStatus -> assertThat(httpStatus).isEqualTo(HttpStatus.BAD_REQUEST)); 34 | assertThat(properties.getCodes()) 35 | .hasSize(1) 36 | .hasEntrySatisfying("java.lang.NullPointerException", code -> assertThat(code).isEqualTo("NPE")); 37 | assertThat(properties.getMessages()) 38 | .hasSize(1) 39 | .hasEntrySatisfying("java.lang.NullPointerException", message -> assertThat(message).isEqualTo("A null pointer was thrown!")); 40 | assertThat(properties.getFullStacktraceHttpStatuses()) 41 | .hasSize(3) 42 | .containsExactly("500", "40x", "2xx"); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/IntegrationTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.exception.MyCustomHttpResponseStatusException; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpResponseStatusFromExceptionMapper; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Import; 10 | import org.springframework.http.HttpStatusCode; 11 | import org.springframework.security.authentication.AuthenticationManager; 12 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 13 | import org.springframework.security.web.SecurityFilterChain; 14 | import org.springframework.test.web.servlet.MockMvc; 15 | 16 | import java.time.Instant; 17 | 18 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 19 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 20 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 22 | 23 | @WebMvcTest(value = IntegrationTestRestController.class, 24 | properties = {"spring.main.allow-bean-definition-overriding=true", 25 | "error.handling.full-stacktrace-http-statuses[0]=500"}) 26 | @Import({IntegrationTest.WebSecurityConfig.class, IntegrationTest.ResponseCustomizerConfiguration.class, IntegrationTest.CustomHttpResponseStatusFromExceptionMapper.class}) 27 | public class IntegrationTest { 28 | 29 | @Autowired 30 | private MockMvc mockMvc; 31 | 32 | @Test 33 | void testRuntimeException() throws Exception { 34 | mockMvc.perform(get("/integration-test/runtime")) 35 | .andExpect(status().isInternalServerError()); 36 | } 37 | 38 | @Test 39 | void testExceptionWithBadRequestStatus() throws Exception { 40 | mockMvc.perform(get("/integration-test/bad-request")) 41 | .andExpect(status().isBadRequest()) 42 | .andDo(print()) 43 | .andExpect(jsonPath("instant").exists()) 44 | .andExpect(jsonPath("currentApplication").value("test-app")) 45 | ; 46 | } 47 | 48 | @Test 49 | void testExceptionWithCustomStatus() throws Exception { 50 | mockMvc.perform(get("/integration-test/teapot")) 51 | .andExpect(status().isIAmATeapot()) 52 | .andDo(print()) 53 | .andExpect(jsonPath("instant").exists()) 54 | .andExpect(jsonPath("currentApplication").value("test-app")) 55 | ; 56 | } 57 | 58 | static class WebSecurityConfig { 59 | @Bean 60 | public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 61 | http.authorizeHttpRequests(registry -> registry.anyRequest().permitAll()); 62 | return http.build(); 63 | } 64 | } 65 | 66 | static class ResponseCustomizerConfiguration { 67 | @Bean 68 | public ApiErrorResponseCustomizer timestampErrorResponseCustomizer() { 69 | return new ApiErrorResponseCustomizer() { 70 | @Override 71 | public void customize(ApiErrorResponse response) { 72 | response.addErrorProperty("instant", Instant.now()); 73 | } 74 | }; 75 | } 76 | 77 | @Bean 78 | public ApiErrorResponseCustomizer applicationErrorResponseCustomizer() { 79 | return new ApiErrorResponseCustomizer() { 80 | @Override 81 | public void customize(ApiErrorResponse response) { 82 | response.addErrorProperty("currentApplication", "test-app"); 83 | } 84 | }; 85 | } 86 | } 87 | 88 | static class CustomHttpResponseStatusFromExceptionMapper implements HttpResponseStatusFromExceptionMapper { 89 | 90 | @Override 91 | public boolean canExtractResponseStatus(Throwable exception) { 92 | return exception instanceof MyCustomHttpResponseStatusException; 93 | } 94 | 95 | @Override 96 | public HttpStatusCode getResponseStatus(Throwable exception) { 97 | return ((MyCustomHttpResponseStatusException) exception).getHttpStatusCode(); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/IntegrationTestRestController.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.exception.ExceptionWithBadRequestStatus; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.exception.MyCustomHttpResponseStatusException; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.RequestMapping; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | @RestController 11 | @RequestMapping("/integration-test") 12 | public class IntegrationTestRestController { 13 | 14 | @GetMapping("/runtime") 15 | void throwRuntimeException() { 16 | throw new RuntimeException("This is a test RuntimeException"); 17 | } 18 | 19 | @GetMapping("/bad-request") 20 | void throwExceptionWithBadRequestStatus() { 21 | throw new ExceptionWithBadRequestStatus(); 22 | } 23 | 24 | @GetMapping("/teapot") 25 | void throwMyCustomHttpResponseStatusException() { 26 | throw new MyCustomHttpResponseStatusException(HttpStatus.I_AM_A_TEAPOT); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ReactiveIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import org.hamcrest.Matchers; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.security.test.context.support.WithMockUser; 10 | import org.springframework.test.web.reactive.server.WebTestClient; 11 | 12 | import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; 13 | 14 | @WebFluxTest( 15 | properties = { 16 | "spring.main.web-application-type=reactive", 17 | "spring.main.allow-bean-definition-overriding=true", 18 | "error.handling.full-stacktrace-http-statuses[0]=500" 19 | }, 20 | controllers = ReactiveIntegrationTestRestController.class 21 | ) 22 | public class ReactiveIntegrationTest { 23 | 24 | @Autowired 25 | WebTestClient webTestClient; 26 | 27 | @Test 28 | @WithMockUser 29 | void testRuntimeException() throws Exception { 30 | webTestClient.get() 31 | .uri("/integration-test/runtime") 32 | .accept(MediaType.ALL) 33 | .exchange() 34 | .expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); 35 | } 36 | 37 | @Test 38 | @WithMockUser 39 | void testExceptionWithBadRequestStatus() throws Exception { 40 | webTestClient.get() 41 | .uri("/integration-test/bad-request") 42 | .exchange() 43 | .expectStatus().isBadRequest(); 44 | } 45 | 46 | @Test 47 | @WithMockUser 48 | void testApplicationException() throws Exception { 49 | webTestClient.get() 50 | .uri("/integration-test/application-request") 51 | .exchange() 52 | .expectStatus().is5xxServerError() 53 | .expectBody() 54 | .jsonPath("$.code").isEqualTo("APPLICATION") 55 | .jsonPath("$.message").isEqualTo("Application error"); 56 | } 57 | 58 | @Test 59 | @WithMockUser 60 | void testPostWithValidationError() { 61 | webTestClient.mutateWith(csrf()) 62 | .post() 63 | .uri("/integration-test") 64 | .contentType(MediaType.APPLICATION_JSON) 65 | .bodyValue("{\n" + 66 | " \"name\": \"\",\n" + 67 | " \"email\": \"invalid\"\n" + 68 | "}") 69 | .exchange() 70 | .expectStatus().isBadRequest() 71 | .expectBody() 72 | .jsonPath("$.code").isEqualTo("VALIDATION_FAILED") 73 | .jsonPath("$.message").isEqualTo("Validation failed for object='createUserRequest'. Error count: 2") 74 | .jsonPath("$.fieldErrors").isArray() 75 | .jsonPath("$.fieldErrors..code").value(Matchers.containsInAnyOrder("INVALID_EMAIL", "REQUIRED_NOT_BLANK")) 76 | .jsonPath("$.fieldErrors..message").value(Matchers.containsInAnyOrder("must be a well-formed email address", "must not be blank")) 77 | .jsonPath("$.fieldErrors..property").value(Matchers.containsInAnyOrder("email", "name")) 78 | .jsonPath("$.fieldErrors..rejectedValue").value(Matchers.containsInAnyOrder("invalid", "")); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ReactiveIntegrationTestRestController.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.exception.ApplicationException; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.exception.ExceptionWithBadRequestStatus; 5 | import org.springframework.web.bind.annotation.*; 6 | import org.springframework.web.servlet.function.ServerResponse; 7 | import reactor.core.publisher.Mono; 8 | 9 | import jakarta.validation.Valid; 10 | import jakarta.validation.constraints.Email; 11 | import jakarta.validation.constraints.NotBlank; 12 | 13 | @RestController 14 | @RequestMapping("/integration-test") 15 | public class ReactiveIntegrationTestRestController { 16 | 17 | @GetMapping("/runtime") 18 | Mono throwRuntimeException() { 19 | throw new RuntimeException("This is a test RuntimeException"); 20 | } 21 | 22 | @GetMapping("/bad-request") 23 | Mono throwExceptionWithBadRequestStatus() { 24 | throw new ExceptionWithBadRequestStatus(); 25 | } 26 | 27 | @GetMapping("/application-request") 28 | Mono throwApplicationException() { 29 | throw new ApplicationException("Application error"); 30 | } 31 | 32 | @PostMapping 33 | public Mono createUser(@RequestBody @Valid CreateUserRequest request) { 34 | return Mono.just(new UserDto()); 35 | } 36 | 37 | public static class UserDto { 38 | } 39 | 40 | public static class CreateUserRequest { 41 | @NotBlank 42 | private String name; 43 | @Email 44 | private String email; 45 | 46 | public String getName() { 47 | return name; 48 | } 49 | 50 | public void setName(String name) { 51 | this.name = name; 52 | } 53 | 54 | public String getEmail() { 55 | return email; 56 | } 57 | 58 | public void setEmail(String email) { 59 | this.email = email; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/ApplicationException.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | public class ApplicationException extends RuntimeException { 4 | 5 | public ApplicationException(String message) { 6 | super(message); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/ExceptionWithBadRequestStatus.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.BAD_REQUEST) 7 | public class ExceptionWithBadRequestStatus extends RuntimeException { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/ExceptionWithResponseErrorCode.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorCode; 4 | 5 | @ResponseErrorCode("MY_ERROR_CODE") 6 | public class ExceptionWithResponseErrorCode extends RuntimeException { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/ExceptionWithResponseErrorPropertyOnField.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorProperty; 4 | 5 | public class ExceptionWithResponseErrorPropertyOnField extends RuntimeException { 6 | @ResponseErrorProperty 7 | private final String myProperty; 8 | 9 | public ExceptionWithResponseErrorPropertyOnField(String message, String myProperty) { 10 | super(message); 11 | this.myProperty = myProperty; 12 | } 13 | 14 | public ExceptionWithResponseErrorPropertyOnField(String myProperty) { 15 | this.myProperty = myProperty; 16 | } 17 | 18 | public String getMyProperty() { 19 | return myProperty; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/ExceptionWithResponseErrorPropertyOnFieldWithIncludeIfNull.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorProperty; 4 | 5 | public class ExceptionWithResponseErrorPropertyOnFieldWithIncludeIfNull extends RuntimeException { 6 | @ResponseErrorProperty(includeIfNull = true) 7 | private final String myProperty; 8 | 9 | public ExceptionWithResponseErrorPropertyOnFieldWithIncludeIfNull(String myProperty) { 10 | this.myProperty = myProperty; 11 | } 12 | 13 | public String getMyProperty() { 14 | return myProperty; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/ExceptionWithResponseErrorPropertyOnMethod.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorProperty; 4 | 5 | public class ExceptionWithResponseErrorPropertyOnMethod extends RuntimeException { 6 | private final String myProperty; 7 | 8 | public ExceptionWithResponseErrorPropertyOnMethod(String message, String myProperty) { 9 | super(message); 10 | this.myProperty = myProperty; 11 | } 12 | 13 | public ExceptionWithResponseErrorPropertyOnMethod(String myProperty) { 14 | this.myProperty = myProperty; 15 | } 16 | 17 | @ResponseErrorProperty 18 | public String getMyProperty() { 19 | return myProperty; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/ExceptionWithResponseErrorPropertyOnMethodWithIncludeIfNull.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorProperty; 4 | 5 | public class ExceptionWithResponseErrorPropertyOnMethodWithIncludeIfNull extends RuntimeException { 6 | private final String myProperty; 7 | 8 | public ExceptionWithResponseErrorPropertyOnMethodWithIncludeIfNull(String myProperty) { 9 | this.myProperty = myProperty; 10 | } 11 | 12 | @ResponseErrorProperty(includeIfNull = true) 13 | public String getMyProperty() { 14 | return myProperty; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/MyCustomHttpResponseStatusException.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import org.springframework.http.HttpStatusCode; 4 | 5 | public class MyCustomHttpResponseStatusException extends RuntimeException { 6 | private final HttpStatusCode httpStatusCode; 7 | 8 | public MyCustomHttpResponseStatusException(HttpStatusCode httpStatusCode) { 9 | this.httpStatusCode = httpStatusCode; 10 | } 11 | 12 | public HttpStatusCode getHttpStatusCode() { 13 | return httpStatusCode; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/MyEntityNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | public class MyEntityNotFoundException extends RuntimeException { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/SubclassOfApplicationException.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | public class SubclassOfApplicationException extends ApplicationException { 4 | 5 | public SubclassOfApplicationException(String message) { 6 | super(message); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/SubclassOfExceptionWithResponseErrorPropertyOnField.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.exception.ExceptionWithResponseErrorPropertyOnField; 4 | 5 | public class SubclassOfExceptionWithResponseErrorPropertyOnField extends ExceptionWithResponseErrorPropertyOnField { 6 | public SubclassOfExceptionWithResponseErrorPropertyOnField(String message, 7 | String myProperty) { 8 | super(message, myProperty); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/exception/SubclassOfExceptionWithResponseErrorPropertyOnMethod.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.exception; 2 | 3 | public class SubclassOfExceptionWithResponseErrorPropertyOnMethod extends ExceptionWithResponseErrorPropertyOnMethod { 4 | public SubclassOfExceptionWithResponseErrorPropertyOnMethod(String message, 5 | String myProperty) { 6 | super(message, myProperty); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/CustomApiExceptionHandlerDocumentation.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 7 | import org.springframework.context.annotation.Import; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.security.test.context.support.WithMockUser; 10 | import org.springframework.test.annotation.DirtiesContext; 11 | import org.springframework.test.context.ContextConfiguration; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | import java.io.IOException; 18 | 19 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 23 | 24 | @WebMvcTest 25 | @ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, 26 | CustomApiExceptionHandlerDocumentation.TestController.class}) 27 | @Import(CustomExceptionApiExceptionHandler.class) 28 | @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) 29 | public class CustomApiExceptionHandlerDocumentation { 30 | 31 | @Autowired 32 | private MockMvc mockMvc; 33 | 34 | @Test 35 | @WithMockUser 36 | void testConstraintViolationException() throws Exception { 37 | mockMvc.perform(post("/test") 38 | .contentType(MediaType.APPLICATION_JSON) 39 | .with(csrf())) 40 | .andExpect(status().isInternalServerError()) 41 | .andDo(print()) 42 | ; 43 | } 44 | 45 | 46 | @RestController 47 | @RequestMapping("/test") 48 | public static class TestController { 49 | @PostMapping 50 | public void doPost() { 51 | throw new CustomException("parent exception message", new IOException("child IOException message")); 52 | } 53 | 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/CustomException.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | public class CustomException extends RuntimeException { 4 | public CustomException(String message, 5 | Throwable cause) { 6 | super(message, cause); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/CustomExceptionApiExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | @Component //<.> 12 | public class CustomExceptionApiExceptionHandler implements ApiExceptionHandler { //<.> 13 | @Override 14 | public boolean canHandle(Throwable exception) { 15 | return exception instanceof CustomException; //<.> 16 | } 17 | 18 | @Override 19 | public ApiErrorResponse handle(Throwable exception) { 20 | CustomException customException = (CustomException) exception; 21 | 22 | ApiErrorResponse response = new ApiErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, //<.> 23 | "MY_CUSTOM_EXCEPTION", 24 | exception.getMessage()); 25 | Throwable cause = customException.getCause(); 26 | Map nestedCause = new HashMap<>(); 27 | nestedCause.put("code", "CAUSE"); 28 | nestedCause.put("message", cause.getMessage()); 29 | response.addErrorProperty("cause", nestedCause); //<.> 30 | 31 | return response; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration; 5 | import jakarta.validation.*; 6 | import jakarta.validation.constraints.NotNull; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 10 | import org.springframework.http.HttpMethod; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.mock.web.MockPart; 13 | import org.springframework.security.test.context.support.WithMockUser; 14 | import org.springframework.test.annotation.DirtiesContext; 15 | import org.springframework.test.context.ContextConfiguration; 16 | import org.springframework.test.web.servlet.MockMvc; 17 | import org.springframework.web.bind.annotation.PutMapping; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.RequestPart; 20 | import org.springframework.web.bind.annotation.RestController; 21 | import org.springframework.web.multipart.MultipartFile; 22 | 23 | import java.lang.annotation.*; 24 | import java.nio.charset.StandardCharsets; 25 | import java.time.LocalDateTime; 26 | import java.util.List; 27 | 28 | import static org.hamcrest.Matchers.*; 29 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 30 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; 31 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 32 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 33 | 34 | 35 | @WebMvcTest 36 | @ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, 37 | HandlerMethodValidationExceptionHandlerTest.TestController.class}) 38 | @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) 39 | class HandlerMethodValidationExceptionHandlerTest { 40 | 41 | @Autowired 42 | private MockMvc mockMvc; 43 | 44 | @Test 45 | @WithMockUser 46 | void testHandlerMethodViolationException() throws Exception { 47 | mockMvc.perform(multipart("/test/update-event") 48 | .part(new MockPart("eventRequest", null, "{}".getBytes(StandardCharsets.UTF_8), MediaType.APPLICATION_JSON)) 49 | .part(new MockPart("file", "file.jpg", new byte[0], MediaType.IMAGE_JPEG)) 50 | .with(request -> { 51 | request.setMethod(HttpMethod.PUT.name()); 52 | return request; 53 | }) 54 | .with(csrf())) 55 | .andExpect(status().isBadRequest()) 56 | .andExpect(jsonPath("code").value("VALIDATION_FAILED")) 57 | .andExpect(jsonPath("message").value("There was a validation failure.")) 58 | .andExpect(jsonPath("fieldErrors", hasSize(1))) 59 | .andExpect(jsonPath("fieldErrors..code", allOf(hasItem("REQUIRED_NOT_NULL")))) 60 | .andExpect(jsonPath("fieldErrors..property", allOf(hasItem("dateTime")))) 61 | .andExpect(jsonPath("fieldErrors..message", allOf(hasItem("must not be null")))) 62 | .andExpect(jsonPath("fieldErrors..rejectedValue", allOf(hasItem(nullValue())))) 63 | .andExpect(jsonPath("globalErrors", hasSize(1))) 64 | .andExpect(jsonPath("globalErrors..code", allOf(hasItem("ValidFileType")))) 65 | .andExpect(jsonPath("globalErrors..message", allOf(hasItem("")))) 66 | ; 67 | } 68 | 69 | @Test 70 | @WithMockUser 71 | void testHandlerMethodViolationException_customValidationAnnotationOverride(@Autowired ErrorHandlingProperties properties) throws Exception { 72 | properties.getCodes().put("ValidFileType", "INVALID_FILE_TYPE"); 73 | properties.getMessages().put("ValidFileType", "The file type is invalid. Only text/plain and application/pdf allowed."); 74 | mockMvc.perform(multipart("/test/update-event") 75 | .part(new MockPart("eventRequest", null, "{}".getBytes(StandardCharsets.UTF_8), MediaType.APPLICATION_JSON)) 76 | .part(new MockPart("file", "file.jpg", new byte[0], MediaType.IMAGE_JPEG)) 77 | .with(request -> { 78 | request.setMethod(HttpMethod.PUT.name()); 79 | return request; 80 | }) 81 | .with(csrf())) 82 | .andExpect(status().isBadRequest()) 83 | .andExpect(jsonPath("code").value("VALIDATION_FAILED")) 84 | .andExpect(jsonPath("message").value("There was a validation failure.")) 85 | .andExpect(jsonPath("fieldErrors", hasSize(1))) 86 | .andExpect(jsonPath("fieldErrors..code", allOf(hasItem("REQUIRED_NOT_NULL")))) 87 | .andExpect(jsonPath("fieldErrors..property", allOf(hasItem("dateTime")))) 88 | .andExpect(jsonPath("fieldErrors..message", allOf(hasItem("must not be null")))) 89 | .andExpect(jsonPath("fieldErrors..rejectedValue", allOf(hasItem(nullValue())))) 90 | .andExpect(jsonPath("globalErrors", hasSize(1))) 91 | .andExpect(jsonPath("globalErrors..code", allOf(hasItem("INVALID_FILE_TYPE")))) 92 | .andExpect(jsonPath("globalErrors..message", allOf(hasItem("The file type is invalid. Only text/plain and application/pdf allowed.")))) 93 | ; 94 | } 95 | 96 | @RestController 97 | @RequestMapping 98 | static class TestController { 99 | 100 | @PutMapping("/test/update-event") 101 | public void updateEvent( 102 | @Valid @RequestPart EventRequest eventRequest, 103 | @Valid @ValidFileType @RequestPart MultipartFile file) { 104 | 105 | } 106 | } 107 | 108 | static class EventRequest { 109 | @NotNull 110 | private LocalDateTime dateTime; 111 | 112 | public LocalDateTime getDateTime() { 113 | return dateTime; 114 | } 115 | 116 | public void setDateTime(LocalDateTime dateTime) { 117 | this.dateTime = dateTime; 118 | } 119 | } 120 | 121 | @Documented 122 | @Constraint(validatedBy = MultiPartFileValidator.class) 123 | @Target(ElementType.PARAMETER) 124 | @Retention(RetentionPolicy.RUNTIME) 125 | @interface ValidFileType { 126 | 127 | // Default list of allowed file types 128 | String[] value() default { 129 | MediaType.TEXT_PLAIN_VALUE, 130 | MediaType.APPLICATION_PDF_VALUE 131 | }; 132 | 133 | String message() default ""; 134 | 135 | Class[] groups() default {}; 136 | 137 | Class[] payload() default {}; 138 | } 139 | 140 | static class MultiPartFileValidator implements ConstraintValidator { 141 | 142 | private List allowed; 143 | 144 | @Override 145 | public void initialize(ValidFileType constraintAnnotation) { 146 | allowed = List.of(constraintAnnotation.value()); 147 | } 148 | 149 | @Override 150 | public boolean isValid(MultipartFile file, ConstraintValidatorContext context) { 151 | return file == null || allowed.contains(file.getContentType()); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HttpMessageNotReadableApiExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration; 5 | import org.hamcrest.Matchers; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 9 | import org.springframework.http.MediaType; 10 | import org.springframework.security.test.context.support.WithMockUser; 11 | import org.springframework.test.context.ContextConfiguration; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | import jakarta.validation.Valid; 19 | import jakarta.validation.constraints.NotNull; 20 | import jakarta.validation.constraints.Size; 21 | 22 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 23 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 25 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 26 | 27 | @WebMvcTest 28 | @ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, 29 | HttpMessageNotReadableApiExceptionHandlerTest.TestController.class}) 30 | class HttpMessageNotReadableApiExceptionHandlerTest { 31 | 32 | @Autowired 33 | private MockMvc mockMvc; 34 | 35 | @Test 36 | @WithMockUser 37 | void testHttpMessageNotReadableException() throws Exception { 38 | mockMvc.perform(post("/test/validation") 39 | .contentType(MediaType.APPLICATION_JSON) 40 | .content("{invalidjsonhere}") 41 | .with(csrf())) 42 | .andExpect(status().isBadRequest()) 43 | .andExpect(jsonPath("code").value("MESSAGE_NOT_READABLE")) 44 | .andExpect(jsonPath("message", Matchers.startsWith("JSON parse error: Unexpected character ('i' (code 105))"))) 45 | ; 46 | } 47 | 48 | @RestController 49 | @RequestMapping("/test/validation") 50 | public static class TestController { 51 | @PostMapping 52 | public void doPost(@Valid @RequestBody TestRequestBody requestBody) { 53 | 54 | } 55 | 56 | } 57 | 58 | public static class TestRequestBody { 59 | @NotNull 60 | private String value; 61 | 62 | @NotNull 63 | @Size(min = 1, max = 255) 64 | private String value2; 65 | 66 | public String getValue() { 67 | return value; 68 | } 69 | 70 | public void setValue(String value) { 71 | this.value = value; 72 | } 73 | 74 | public String getValue2() { 75 | return value2; 76 | } 77 | 78 | public void setValue2(String value2) { 79 | this.value2 = value2; 80 | } 81 | } 82 | 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/MissingRequestValueExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.security.test.context.support.WithMockUser; 9 | import org.springframework.test.annotation.DirtiesContext; 10 | import org.springframework.test.context.ContextConfiguration; 11 | import org.springframework.test.web.servlet.MockMvc; 12 | import org.springframework.web.bind.annotation.*; 13 | 14 | import jakarta.validation.constraints.NotEmpty; 15 | 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 17 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 19 | 20 | 21 | @WebMvcTest 22 | @ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, 23 | MissingRequestValueExceptionHandlerTest.TestController.class}) 24 | @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) 25 | class MissingRequestValueExceptionHandlerTest { 26 | 27 | @Autowired 28 | private MockMvc mockMvc; 29 | 30 | @Test 31 | @WithMockUser 32 | void testMatrixVariable() throws Exception { 33 | mockMvc.perform(get("/matrix-variable")) 34 | .andExpect(status().isBadRequest()) 35 | .andExpect(jsonPath("code").value("VALIDATION_FAILED")) 36 | .andExpect(jsonPath("variableName").value("contactNumber")); 37 | } 38 | 39 | @Test 40 | @WithMockUser 41 | void testPathVariable() throws Exception { 42 | mockMvc.perform(get("/path-variable")) 43 | .andExpect(status().isInternalServerError()) 44 | .andExpect(jsonPath("code").value("MISSING_PATH_VARIABLE")) 45 | .andExpect(jsonPath("parameterName").value("id")) 46 | .andExpect(jsonPath("parameterType").value("String")); 47 | } 48 | 49 | @Test 50 | @WithMockUser 51 | void testRequestCookie() throws Exception { 52 | mockMvc.perform(get("/request-cookie")) 53 | .andExpect(status().isBadRequest()) 54 | .andExpect(jsonPath("code").value("VALIDATION_FAILED")) 55 | .andExpect(jsonPath("cookieName").value("favorite")) 56 | .andExpect(jsonPath("parameterName").value("favoriteCookie")) 57 | .andExpect(jsonPath("parameterType").value("String")); 58 | } 59 | 60 | @Test 61 | @WithMockUser 62 | void testRequestHeader() throws Exception { 63 | mockMvc.perform(get("/request-header")) 64 | .andExpect(status().isBadRequest()) 65 | .andExpect(jsonPath("code").value("VALIDATION_FAILED")) 66 | .andExpect(jsonPath("headerName").value("X-Custom-Header")) 67 | .andExpect(jsonPath("parameterName").value("customHeader")) 68 | .andExpect(jsonPath("parameterType").value("String")); 69 | } 70 | 71 | @Test 72 | @WithMockUser 73 | void testMissingServletRequestParameter() throws Exception { 74 | mockMvc.perform(get("/missing-servlet-request-parameter")) 75 | .andExpect(status().isBadRequest()) 76 | .andExpect(jsonPath("code").value("VALIDATION_FAILED")) 77 | .andExpect(jsonPath("parameterName").value("test")) 78 | .andExpect(jsonPath("parameterType").value("String")); 79 | } 80 | 81 | @RestController 82 | @RequestMapping 83 | public static class TestController { 84 | 85 | @GetMapping("/matrix-variable") 86 | public void matrixVariable(@MatrixVariable String contactNumber) { 87 | } 88 | 89 | @GetMapping("/path-variable") 90 | public void pathVariable(@PathVariable("id") String id) { 91 | } 92 | 93 | @GetMapping("/request-cookie") 94 | public void requestCookie(@CookieValue("favorite") String favoriteCookie) { 95 | } 96 | 97 | @GetMapping("/request-header") 98 | public void requestHeader(@RequestHeader("X-Custom-Header") String customHeader) { 99 | } 100 | 101 | @GetMapping("/missing-servlet-request-parameter") 102 | public ResponseEntity missingServletRequestParameter(@RequestParam @NotEmpty String test) { 103 | return ResponseEntity.ok(1); 104 | } 105 | 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ObjectOptimisticLockingFailureApiExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 8 | import org.springframework.orm.ObjectOptimisticLockingFailureException; 9 | import org.springframework.security.test.context.support.WithMockUser; 10 | import org.springframework.test.context.ContextConfiguration; 11 | import org.springframework.test.web.servlet.MockMvc; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 17 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 19 | 20 | @WebMvcTest 21 | @ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, 22 | ObjectOptimisticLockingFailureApiExceptionHandlerTest.TestController.class}) 23 | class ObjectOptimisticLockingFailureApiExceptionHandlerTest { 24 | 25 | @Autowired 26 | private MockMvc mockMvc; 27 | 28 | @Test 29 | @WithMockUser 30 | void testOOLF() throws Exception { 31 | mockMvc.perform(get("/test/object-optimistic-locking-failure")) 32 | .andExpect(status().isConflict()) 33 | .andExpect(jsonPath("code").value("OPTIMISTIC_LOCKING_ERROR")) 34 | .andExpect(jsonPath("message").value("Object of class [com.example.user.User] with identifier [1]: optimistic locking failed")) 35 | .andExpect(jsonPath("persistentClassName").value("com.example.user.User")) 36 | .andExpect(jsonPath("identifier").value("1")) 37 | ; 38 | } 39 | 40 | @RestController 41 | @RequestMapping("/test/object-optimistic-locking-failure") 42 | public static class TestController { 43 | 44 | @GetMapping 45 | public void throwObjectOptimisticLockingFailureException() { 46 | throw new ObjectOptimisticLockingFailureException("com.example.user.User", 1L); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ServerErrorExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | 4 | import org.hamcrest.Matchers; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.MediaType; 10 | import org.springframework.security.test.context.support.WithMockUser; 11 | import org.springframework.test.web.reactive.server.WebTestClient; 12 | 13 | @WebFluxTest( 14 | properties = { 15 | "spring.main.web-application-type=reactive", 16 | "spring.main.allow-bean-definition-overriding=true", 17 | "error.handling.full-stacktrace-http-statuses[0]=500" 18 | }, 19 | controllers = ServerErrorExceptionHandlerTestController.class 20 | ) 21 | class ServerErrorExceptionHandlerTest { 22 | 23 | @Autowired 24 | WebTestClient webTestClient; 25 | 26 | @Test 27 | @WithMockUser 28 | void testPathVariable() throws Exception { 29 | webTestClient.get() 30 | .uri("/path-variable") 31 | .accept(MediaType.APPLICATION_JSON) 32 | .exchange() 33 | .expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR) 34 | .expectBody() 35 | .consumeWith(System.out::println) 36 | .jsonPath("code").value(Matchers.equalTo("SERVER_ERROR")) 37 | .jsonPath("parameterName").value(Matchers.equalTo("id")) 38 | .jsonPath("parameterType").value(Matchers.equalTo("String")) 39 | .jsonPath("methodName").value(Matchers.equalTo("pathVariable")) 40 | .jsonPath("methodClassName").value(Matchers.equalTo("ServerErrorExceptionHandlerTestController")) 41 | ; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ServerErrorExceptionHandlerTestController.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import org.springframework.web.bind.annotation.GetMapping; 4 | import org.springframework.web.bind.annotation.PathVariable; 5 | import org.springframework.web.bind.annotation.RequestMapping; 6 | import org.springframework.web.bind.annotation.RestController; 7 | 8 | @RestController 9 | @RequestMapping 10 | public class ServerErrorExceptionHandlerTestController { 11 | 12 | @GetMapping("/path-variable") 13 | public void pathVariable(@PathVariable("id") String id) { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ServerWebInputExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | 4 | import org.hamcrest.Matchers; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.MediaType; 10 | import org.springframework.security.test.context.support.WithMockUser; 11 | import org.springframework.test.web.reactive.server.WebTestClient; 12 | 13 | @WebFluxTest( 14 | properties = { 15 | "spring.main.web-application-type=reactive", 16 | "spring.main.allow-bean-definition-overriding=true", 17 | "error.handling.full-stacktrace-http-statuses[0]=500" 18 | }, 19 | controllers = ServerWebInputExceptionHandlerTestController.class 20 | ) 21 | class ServerWebInputExceptionHandlerTest { 22 | 23 | @Autowired 24 | WebTestClient webTestClient; 25 | 26 | @Test 27 | @WithMockUser 28 | void testMatrixVariable() throws Exception { 29 | webTestClient.get() 30 | .uri("/matrix-variable") 31 | .accept(MediaType.APPLICATION_JSON) 32 | .exchange() 33 | .expectStatus().isEqualTo(HttpStatus.BAD_REQUEST) 34 | .expectBody() 35 | .jsonPath("code").value(Matchers.equalTo("VALIDATION_FAILED")) 36 | .jsonPath("parameterName").value(Matchers.equalTo("contactNumber")) 37 | .jsonPath("parameterType").value(Matchers.equalTo("String")) 38 | ; 39 | } 40 | 41 | @Test 42 | @WithMockUser 43 | void testRequestCookie() throws Exception { 44 | webTestClient.get() 45 | .uri("/request-cookie") 46 | .accept(MediaType.APPLICATION_JSON) 47 | .exchange() 48 | .expectStatus().isEqualTo(HttpStatus.BAD_REQUEST) 49 | .expectBody() 50 | .consumeWith(System.out::println) 51 | .jsonPath("code").value(Matchers.equalTo("VALIDATION_FAILED")) 52 | .jsonPath("message").value(Matchers.equalTo("400 BAD_REQUEST \"Required cookie 'favorite' is not present.\"")) 53 | .jsonPath("parameterName").value(Matchers.equalTo("favoriteCookie")) 54 | .jsonPath("parameterType").value(Matchers.equalTo("String")) 55 | ; 56 | } 57 | 58 | @Test 59 | @WithMockUser 60 | void testRequestHeader() throws Exception { 61 | webTestClient.get() 62 | .uri("/request-header") 63 | .accept(MediaType.APPLICATION_JSON) 64 | .exchange() 65 | .expectStatus().isEqualTo(HttpStatus.BAD_REQUEST) 66 | .expectBody() 67 | .consumeWith(System.out::println) 68 | .jsonPath("code").value(Matchers.equalTo("VALIDATION_FAILED")) 69 | .jsonPath("message").value(Matchers.equalTo("400 BAD_REQUEST \"Required header 'X-Custom-Header' is not present.\"")) 70 | .jsonPath("parameterName").value(Matchers.equalTo("customHeader")) 71 | .jsonPath("parameterType").value(Matchers.equalTo("String")) 72 | ; 73 | } 74 | 75 | @Test 76 | @WithMockUser 77 | void testRequestParameter() throws Exception { 78 | webTestClient.get() 79 | .uri("/request-parameter") 80 | .accept(MediaType.APPLICATION_JSON) 81 | .exchange() 82 | .expectStatus().isEqualTo(HttpStatus.BAD_REQUEST) 83 | .expectBody() 84 | .consumeWith(System.out::println) 85 | .jsonPath("code").value(Matchers.equalTo("VALIDATION_FAILED")) 86 | .jsonPath("message").value(Matchers.equalTo("400 BAD_REQUEST \"Required query parameter 'test' is not present.\"")) 87 | .jsonPath("parameterName").value(Matchers.equalTo("test")) 88 | .jsonPath("parameterType").value(Matchers.equalTo("String")); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ServerWebInputExceptionHandlerTestController.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import org.springframework.web.bind.annotation.*; 4 | import reactor.core.publisher.Mono; 5 | 6 | import jakarta.validation.constraints.NotEmpty; 7 | 8 | @RestController 9 | @RequestMapping 10 | public class ServerWebInputExceptionHandlerTestController { 11 | 12 | @GetMapping("/matrix-variable") 13 | public Mono matrixVariable(@MatrixVariable String contactNumber) { 14 | return Mono.empty(); 15 | } 16 | 17 | @GetMapping("/request-cookie") 18 | public void requestCookie(@CookieValue("favorite") String favoriteCookie) { 19 | } 20 | 21 | @GetMapping("/request-header") 22 | public void requestHeader(@RequestHeader("X-Custom-Header") String customHeader) { 23 | } 24 | 25 | @GetMapping("/request-parameter") 26 | public Mono requestParameter(@RequestParam @NotEmpty String test) { 27 | return Mono.just(1); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponseAccessDeniedHandler; 5 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.UnauthorizedEntryPoint; 6 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; 7 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; 8 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; 9 | import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 13 | import org.springframework.boot.test.context.TestConfiguration; 14 | import org.springframework.context.annotation.Bean; 15 | import org.springframework.security.access.AccessDeniedException; 16 | import org.springframework.security.access.annotation.Secured; 17 | import org.springframework.security.authentication.AccountExpiredException; 18 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 19 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 20 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 21 | import org.springframework.security.test.context.support.WithMockUser; 22 | import org.springframework.security.web.SecurityFilterChain; 23 | import org.springframework.security.web.access.AccessDeniedHandler; 24 | import org.springframework.test.context.ContextConfiguration; 25 | import org.springframework.test.web.servlet.MockMvc; 26 | import org.springframework.web.bind.annotation.GetMapping; 27 | import org.springframework.web.bind.annotation.RequestMapping; 28 | import org.springframework.web.bind.annotation.RestController; 29 | 30 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 31 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 32 | 33 | @WebMvcTest 34 | @ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, 35 | SpringSecurityApiExceptionHandlerTest.TestController.class, 36 | SpringSecurityApiExceptionHandlerTest.TestConfig.class}) 37 | class SpringSecurityApiExceptionHandlerTest { 38 | 39 | @Autowired 40 | private MockMvc mockMvc; 41 | 42 | @Test 43 | void testUnauthorized() throws Exception { 44 | mockMvc.perform(get("/test/spring-security/access-denied")) 45 | .andExpect(status().isUnauthorized()) 46 | .andExpect(header().string("Content-Type", "application/json;charset=UTF-8")) 47 | .andExpect(jsonPath("code").value("UNAUTHORIZED")) 48 | .andExpect(jsonPath("message").value("Full authentication is required to access this resource")); 49 | } 50 | 51 | @Test 52 | @WithMockUser 53 | void testForbiddenViaSecuredAnnotation() throws Exception { 54 | mockMvc.perform(get("/test/spring-security/admin")) 55 | .andExpect(status().isForbidden()) 56 | .andExpect(header().string("Content-Type", "application/json")) 57 | .andExpect(jsonPath("code").value("AUTHORIZATION_DENIED")) 58 | .andExpect(jsonPath("message").value("Access Denied")); 59 | } 60 | 61 | @Test 62 | @WithMockUser 63 | void testForbiddenViaGlobalSecurityConfig() throws Exception { 64 | mockMvc.perform(get("/test/spring-security/admin-global")) 65 | .andExpect(status().isForbidden()) 66 | .andExpect(header().string("Content-Type", "application/json;charset=UTF-8")) 67 | .andExpect(jsonPath("code").value("ACCESS_DENIED")) 68 | .andExpect(jsonPath("message").value("Access Denied")); 69 | } 70 | 71 | @Test 72 | @WithMockUser 73 | void testAccessDenied() throws Exception { 74 | mockMvc.perform(get("/test/spring-security/access-denied")) 75 | .andExpect(status().isForbidden()) 76 | .andExpect(jsonPath("code").value("ACCESS_DENIED")) 77 | .andExpect(jsonPath("message").value("Fake access denied")) 78 | ; 79 | } 80 | 81 | @Test 82 | @WithMockUser 83 | void testAccountExpired() throws Exception { 84 | mockMvc.perform(get("/test/spring-security/account-expired")) 85 | .andExpect(status().isBadRequest()) 86 | .andExpect(jsonPath("code").value("ACCOUNT_EXPIRED")) 87 | .andExpect(jsonPath("message").value("Fake account expired")) 88 | ; 89 | } 90 | 91 | @RestController 92 | @RequestMapping("/test/spring-security") 93 | public static class TestController { 94 | 95 | @GetMapping("/access-denied") 96 | public void throwAccessDenied() { 97 | throw new AccessDeniedException("Fake access denied"); 98 | } 99 | 100 | @GetMapping("/account-expired") 101 | public void throwAccountExpired() { 102 | throw new AccountExpiredException("Fake account expired"); 103 | } 104 | 105 | @GetMapping("/admin") 106 | @Secured("ADMIN") 107 | public void requiresAdminRole() { 108 | 109 | } 110 | 111 | @GetMapping("/admin-global") 112 | public void requiresAdminRoleViaGlobalConfig() { 113 | 114 | } 115 | } 116 | 117 | @TestConfiguration 118 | @EnableMethodSecurity(securedEnabled = true) 119 | static class TestConfig { 120 | @Bean 121 | public UnauthorizedEntryPoint unauthorizedEntryPoint(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) { 122 | return new UnauthorizedEntryPoint(httpStatusMapper, errorCodeMapper, errorMessageMapper, objectMapper); 123 | } 124 | 125 | @Bean 126 | public AccessDeniedHandler accessDeniedHandler(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) { 127 | return new ApiErrorResponseAccessDeniedHandler(objectMapper, httpStatusMapper, errorCodeMapper, errorMessageMapper); 128 | } 129 | 130 | @Bean 131 | public SecurityFilterChain securityFilterChain(HttpSecurity http, 132 | UnauthorizedEntryPoint unauthorizedEntryPoint, 133 | AccessDeniedHandler accessDeniedHandler) throws Exception { 134 | http.httpBasic(AbstractHttpConfigurer::disable); 135 | 136 | http.authorizeHttpRequests(customizer -> customizer 137 | .requestMatchers("/test/spring-security/admin-global").hasRole("ADMIN") 138 | .anyRequest().authenticated()); 139 | 140 | http.exceptionHandling(customizer -> customizer 141 | .authenticationEntryPoint(unauthorizedEntryPoint) 142 | .accessDeniedHandler(accessDeniedHandler)); 143 | 144 | return http.build(); 145 | } 146 | 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilterTest.java: -------------------------------------------------------------------------------- 1 | package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; 2 | 3 | import jakarta.servlet.FilterChain; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | import jakarta.servlet.http.HttpServletResponse; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 9 | import org.springframework.boot.test.context.TestConfiguration; 10 | import org.springframework.boot.web.servlet.FilterRegistrationBean; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.security.test.context.support.WithMockUser; 13 | import org.springframework.test.context.ContextConfiguration; 14 | import org.springframework.test.context.TestPropertySource; 15 | import org.springframework.test.web.servlet.MockMvc; 16 | import org.springframework.web.bind.annotation.GetMapping; 17 | import org.springframework.web.bind.annotation.RequestMapping; 18 | import org.springframework.web.bind.annotation.RestController; 19 | import org.springframework.web.filter.OncePerRequestFilter; 20 | 21 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 23 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 24 | 25 | @WebMvcTest 26 | @ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, 27 | FilterChainExceptionHandlerFilterTest.TestController.class, 28 | FilterChainExceptionHandlerFilterTest.TestConfig.class}) 29 | @TestPropertySource(properties = "error.handling.handle-filter-chain-exceptions=true") 30 | public class FilterChainExceptionHandlerFilterTest { 31 | 32 | @Autowired 33 | private MockMvc mockMvc; 34 | 35 | @Test 36 | @WithMockUser 37 | void test() throws Exception { 38 | mockMvc.perform(get("/test/filter-chain")) 39 | .andExpect(status().is5xxServerError()) 40 | .andExpect(jsonPath("code").value("RUNTIME")) 41 | .andExpect(jsonPath("message").value("Error in filter")); 42 | 43 | } 44 | 45 | @RestController 46 | @RequestMapping("/test/filter-chain") 47 | public static class TestController { 48 | 49 | @GetMapping 50 | public void doSomething() { 51 | } 52 | } 53 | 54 | @TestConfiguration 55 | static class TestConfig { 56 | 57 | @Bean 58 | public FilterRegistrationBean filter() { 59 | FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); 60 | registrationBean.setFilter(new ThrowErrorFilter()); 61 | registrationBean.addUrlPatterns("/test/filter-chain"); 62 | registrationBean.setOrder(2); 63 | 64 | return registrationBean; 65 | } 66 | } 67 | 68 | static class ThrowErrorFilter extends OncePerRequestFilter { 69 | 70 | @Override 71 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { 72 | throw new RuntimeException("Error in filter"); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/resources/io/github/wimdeblauwe/errorhandlingspringbootstarter/error-handling-properties-test.properties: -------------------------------------------------------------------------------- 1 | error.handling.enabled=false 2 | error.handling.json-field-names.code=kode 3 | error.handling.json-field-names.message=description 4 | error.handling.json-field-names.field-errors=veldFouten 5 | error.handling.json-field-names.global-errors=globaleFouten 6 | error.handling.exception-logging=with_stacktrace 7 | error.handling.default-error-code-strategy=all_caps 8 | error.handling.http-statuses.java.lang.IllegalArgumentException=BAD_REQUEST 9 | error.handling.codes.java.lang.NullPointerException=NPE 10 | error.handling.messages.java.lang.NullPointerException=A null pointer was thrown! 11 | error.handling.full-stacktrace-http-statuses[0]=500 12 | error.handling.full-stacktrace-http-statuses[1]=40x 13 | error.handling.full-stacktrace-http-statuses[2]=2xx 14 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %date{YYYY-MM-dd HH:mm:ss} %level [%thread] %logger{0} - %msg%n%ex 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | --------------------------------------------------------------------------------