From f10e6fde277c529c43154a6581e5e2597d9a40dc Mon Sep 17 00:00:00 2001 From: Fabian Baumeister Date: Wed, 2 Nov 2022 11:15:02 +0100 Subject: [PATCH 1/4] Create an exceptionhandler using generic handling with OpenAPI Exception Handling with REST always has a common problem. Somehow the exception needs to be mapped to an REST exception class. This often includes the status code of an exception, as well as further information. A naiv approach would be to add those details into the ExceptionClass, but this has two significant downsides. First of all the Rest specific information like the HTTP Status Code do not belong into the exception. They're part of the service layer. Second system specific exceptions do not contain those information. This approach here defines a generic mapper that can be controlled per exception class and that can use a behaviour per information on how to get the information. The mapper is part of the service layer and can easily be adjusted. --- integration/exception-handling/.gitignore | 37 +++ integration/exception-handling/build.gradle | 56 ++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + integration/exception-handling/gradlew | 240 ++++++++++++++++++ integration/exception-handling/gradlew.bat | 91 +++++++ .../exception-handling/settings.gradle | 1 + .../exceptionhandling/DemoApplication.java | 13 + .../domain/BookingManagement.java | 27 ++ .../domain/model/Booking.java | 17 ++ .../general/exception/NotFoundException.java | 8 + .../exception/ValidationException.java | 21 ++ .../service/BookingService.java | 41 +++ .../exception/CustomExceptionHandler.java | 21 ++ .../service/exception/ExceptionError.java | 14 + .../ProblemDetailsFactoryHelper.java | 150 +++++++++++ .../exception/ProblemDetailsMapper.java | 80 ++++++ .../service/mapper/BookingToMapper.java | 13 + .../src/main/openapi/BookingOpenApi.yaml | 158 ++++++++++++ .../src/main/resources/application.properties | 1 + .../DemoApplicationTests.java | 13 + .../service/BookingServiceTest.java | 49 ++++ 22 files changed, 1056 insertions(+) create mode 100644 integration/exception-handling/.gitignore create mode 100644 integration/exception-handling/build.gradle create mode 100644 integration/exception-handling/gradle/wrapper/gradle-wrapper.jar create mode 100644 integration/exception-handling/gradle/wrapper/gradle-wrapper.properties create mode 100755 integration/exception-handling/gradlew create mode 100644 integration/exception-handling/gradlew.bat create mode 100644 integration/exception-handling/settings.gradle create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/DemoApplication.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/BookingManagement.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/model/Booking.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/NotFoundException.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/BookingService.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java create mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/mapper/BookingToMapper.java create mode 100644 integration/exception-handling/src/main/openapi/BookingOpenApi.yaml create mode 100644 integration/exception-handling/src/main/resources/application.properties create mode 100644 integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/DemoApplicationTests.java create mode 100644 integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java diff --git a/integration/exception-handling/.gitignore b/integration/exception-handling/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/integration/exception-handling/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/integration/exception-handling/build.gradle b/integration/exception-handling/build.gradle new file mode 100644 index 0000000..e9c95bd --- /dev/null +++ b/integration/exception-handling/build.gradle @@ -0,0 +1,56 @@ +plugins { + id 'org.springframework.boot' version '2.7.2' + id 'io.spring.dependency-management' version '1.0.12.RELEASE' + id 'java' + id 'idea' + id "org.openapi.generator" version "6.2.0" +} + +group = 'com.baumeister.sndbx' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '18' + +repositories { + mavenCentral() +} + +sourceSets.main.java.srcDirs += 'build/generated-sources/server/src/main/java' + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'io.swagger.parser.v3:swagger-parser:2.1.6' + implementation 'org.openapitools:jackson-databind-nullable:0.2.4' + compileOnly 'org.projectlombok:lombok:1.18.24' + implementation 'org.mapstruct:mapstruct:1.5.3.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final' + annotationProcessor 'org.projectlombok:lombok:1.18.24' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok:1.18.24' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.24' +} + +openApiGenerate { + // other settings omitted + inputSpec = "${rootDir}/src/main/openapi/BookingOpenApi.yaml" + outputDir = "${buildDir}/generated-sources/server" + generatorName = "spring" + library = "spring-boot" + modelNameSuffix = "To" + apiPackage = "com.devonfw.devon4j.generated.api.service" + modelPackage = "com.devonfw.devon4j.generated.api.model" + invokerPackage = "com.devonfw.devon4j.generated.api.handler" + configOptions = [ + sourceFolder : "src/main/java", + interfaceOnly : "true", + serializableModel : "true", + singleContentTypes: "true", + legacyDiscriminatorBehavior:"true" + ] +} + +tasks.compileJava.dependsOn(tasks.openApiGenerate) + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/integration/exception-handling/gradle/wrapper/gradle-wrapper.jar b/integration/exception-handling/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/integration/exception-handling/gradlew.bat b/integration/exception-handling/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/integration/exception-handling/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/integration/exception-handling/settings.gradle b/integration/exception-handling/settings.gradle new file mode 100644 index 0000000..703ebed --- /dev/null +++ b/integration/exception-handling/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'exceptionhandling' diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/DemoApplication.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/DemoApplication.java new file mode 100644 index 0000000..db25d0d --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/DemoApplication.java @@ -0,0 +1,13 @@ +package com.baumeister.sndbx.exceptionhandling; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } + +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/BookingManagement.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/BookingManagement.java new file mode 100644 index 0000000..ed96b1b --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/BookingManagement.java @@ -0,0 +1,27 @@ +package com.baumeister.sndbx.exceptionhandling.domain; + +import com.baumeister.sndbx.exceptionhandling.domain.model.Booking; +import com.baumeister.sndbx.exceptionhandling.general.exception.NotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; + +@Component +public class BookingManagement { + + public Booking getBooking(Long id) { + ObjectUtils.isEmpty(id); + if(id == 404L) { + throw new NotFoundException("The element of id " + id + " could not be found"); + } + return Booking.builder() + .id(1L) + .email("m.mustermann@mail.com") + .description("No gluten") + .numberOfSeats(2) + .build(); + } + + public Booking createBooking(Booking booking) { + return booking; + } +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/model/Booking.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/model/Booking.java new file mode 100644 index 0000000..077ee0b --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/model/Booking.java @@ -0,0 +1,17 @@ +package com.baumeister.sndbx.exceptionhandling.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Booking { + private long id; + private int numberOfSeats; + private String description; + private String email; +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/NotFoundException.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/NotFoundException.java new file mode 100644 index 0000000..e62949a --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package com.baumeister.sndbx.exceptionhandling.general.exception; + +public class NotFoundException extends RuntimeException { + + public NotFoundException(String message) { + super(message); + } +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java new file mode 100644 index 0000000..c21911a --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java @@ -0,0 +1,21 @@ +package com.baumeister.sndbx.exceptionhandling.general.exception; + +import java.util.List; +import lombok.Getter; + +public class ValidationException extends RuntimeException{ + private List validationErrors; + + public ValidationException(String message, Throwable cause, List validationErrors) { + super(message, cause); + this.validationErrors = validationErrors; + } + + public List getValidationErrors() { + return validationErrors; + } + + public void setValidationErrors(List validationErrors) { + this.validationErrors = validationErrors; + } +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/BookingService.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/BookingService.java new file mode 100644 index 0000000..8ad555d --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/BookingService.java @@ -0,0 +1,41 @@ +package com.baumeister.sndbx.exceptionhandling.service; + +import com.baumeister.sndbx.exceptionhandling.domain.BookingManagement; +import com.baumeister.sndbx.exceptionhandling.service.mapper.BookingToMapper; +import com.devonfw.devon4j.generated.api.model.BookingTo; +import com.devonfw.devon4j.generated.api.service.BookingApi; +import java.util.List; +import java.util.Optional; +import javax.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.NativeWebRequest; + +@RestController +public class BookingService implements com.devonfw.devon4j.generated.api.service.BookingApi { + private BookingManagement bookingManagement; + + public BookingService(BookingManagement bookingManagement) { + this.bookingManagement = bookingManagement; + } + @Override + public Optional getRequest() { + return BookingApi.super.getRequest(); + } + + @Override + public ResponseEntity createBooking(BookingTo bookingTo) { + return ResponseEntity.accepted().body(bookingTo); + } + + @Override + public ResponseEntity> getBookingAll() { + return BookingApi.super.getBookingAll(); + } + + @Override + public ResponseEntity getBookingById(Long bookingId) { + return ResponseEntity.accepted().body(BookingToMapper.INSTANCE.map(bookingManagement.getBooking(bookingId))); + } +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java new file mode 100644 index 0000000..6dc9301 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java @@ -0,0 +1,21 @@ +package com.baumeister.sndbx.exceptionhandling.service.exception; + +import com.devonfw.devon4j.generated.api.model.ValidationProblemDetailsTo; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; + +@ControllerAdvice +public class CustomExceptionHandler { + + @ExceptionHandler(Throwable.class) + public ResponseEntity notFoundException(Throwable ex) { + ProblemDetailsTo problemDetails = ProblemDetailsMapper.getInstance().map(ex); + return new ResponseEntity(problemDetails, HttpStatus.valueOf(problemDetails.getStatus())); + } + +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java new file mode 100644 index 0000000..1ed08ad --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java @@ -0,0 +1,14 @@ +package com.baumeister.sndbx.exceptionhandling.service.exception; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.http.HttpStatus; + +@Data +@AllArgsConstructor +public class ExceptionError { + + private String type; + private String title; + private HttpStatus status; +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java new file mode 100644 index 0000000..04a3545 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java @@ -0,0 +1,150 @@ +package com.baumeister.sndbx.exceptionhandling.service.exception; + +import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +public class ProblemDetailsFactoryHelper { + + private Supplier

factory; + private String type = "urn:problem:internal-server-error"; + private String title = "Internal Server Error"; + private Integer status = 500; + private BiConsumer typeSetter = (ex, problem) -> problem.setType(this.type); + private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); + private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( + UUID.randomUUID().toString()); + private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); + private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); + private BiConsumer additionalAttributeSetter; + + public Supplier

getFactory() { + return factory; + } + + public String getType() { + return type; + } + + public String getTitle() { + return title; + } + + public Integer getStatus() { + return status; + } + + public BiConsumer getTypeSetter() { + return typeSetter; + } + + public BiConsumer getTitleSetter() { + return titleSetter; + } + + public BiConsumer getInstanceSetter() { + return instanceSetter; + } + + public BiConsumer getStatusSetter() { + return statusSetter; + } + + public BiConsumer getDetailSetter() { + return detailSetter; + } + + public BiConsumer getAdditionalAttributeSetter() { + return additionalAttributeSetter; + } + + public static final class ProblemDetailsFactoryHelperBuilder { + + private Supplier

factory; + private String type = "urn:problem:internal-server-error"; + private String title = "Internal Server Error"; + private Integer status = 500; + private BiConsumer typeSetter = (ex, problem) -> problem.setType(this.type); + private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); + private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( + "urn:uuid:" + UUID.randomUUID().toString()); + private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); + private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); + private BiConsumer additionalAttributeSetter; + + private ProblemDetailsFactoryHelperBuilder() { + } + + public static ProblemDetailsFactoryHelperBuilder aProblemDetailsFactoryHelper( + Class throwableClass, Class

problemdetailsClazz) { + return new ProblemDetailsFactoryHelperBuilder(); + } + + public ProblemDetailsFactoryHelperBuilder withFactory(Supplier

factory) { + this.factory = factory; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withType(String type) { + this.type = type; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withTitle(String title) { + this.title = title; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withStatus(Integer status) { + this.status = status; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withTypeSetter(BiConsumer typeSetter) { + this.typeSetter = typeSetter; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withTitleSetter(BiConsumer titleSetter) { + this.titleSetter = titleSetter; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withInstanceSetter(BiConsumer instanceSetter) { + this.instanceSetter = instanceSetter; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withStatusSetter(BiConsumer statusSetter) { + this.statusSetter = statusSetter; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withDetailSetter(BiConsumer detailSetter) { + this.detailSetter = detailSetter; + return this; + } + + public ProblemDetailsFactoryHelperBuilder withAdditionalAttributeSetter( + BiConsumer additionalAttributeSetter) { + this.additionalAttributeSetter = additionalAttributeSetter; + return this; + } + + public ProblemDetailsFactoryHelper build() { + ProblemDetailsFactoryHelper problemDetailsFactoryHelper = new ProblemDetailsFactoryHelper(); + problemDetailsFactoryHelper.title = this.title; + problemDetailsFactoryHelper.instanceSetter = this.instanceSetter; + problemDetailsFactoryHelper.detailSetter = this.detailSetter; + problemDetailsFactoryHelper.statusSetter = this.statusSetter; + problemDetailsFactoryHelper.status = this.status; + problemDetailsFactoryHelper.titleSetter = this.titleSetter; + problemDetailsFactoryHelper.typeSetter = this.typeSetter; + problemDetailsFactoryHelper.additionalAttributeSetter = this.additionalAttributeSetter; + problemDetailsFactoryHelper.factory = this.factory; + problemDetailsFactoryHelper.type = this.type; + return problemDetailsFactoryHelper; + } + } +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java new file mode 100644 index 0000000..6a82333 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java @@ -0,0 +1,80 @@ +package com.baumeister.sndbx.exceptionhandling.service.exception; + +import com.baumeister.sndbx.exceptionhandling.general.exception.NotFoundException; +import com.baumeister.sndbx.exceptionhandling.general.exception.ValidationException; +import com.baumeister.sndbx.exceptionhandling.service.exception.ProblemDetailsFactoryHelper.ProblemDetailsFactoryHelperBuilder; +import com.devonfw.devon4j.generated.api.model.ValidationProblemDetailsTo; +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; +import org.mapstruct.factory.Mappers; +import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; + +public class ProblemDetailsMapper { + + private static ProblemDetailsMapper instance; + + private ProblemDetailsMapper() { + } + + public static ProblemDetailsMapper getInstance() { + if (instance == null) { + instance = new ProblemDetailsMapper(); + } + return instance; + } + + private static final ProblemDetailsFactoryHelper defaultFactory = + ProblemDetailsFactoryHelperBuilder.aProblemDetailsFactoryHelper(Throwable.class, + ProblemDetailsTo.class) + .withFactory(ProblemDetailsTo::new) + .build(); + Map, ProblemDetailsFactoryHelper> EXCEPTION_ERROR_MAP = Map.of( + + MethodArgumentNotValidException.class, + ProblemDetailsFactoryHelperBuilder.aProblemDetailsFactoryHelper( + MethodArgumentNotValidException.class, ValidationProblemDetailsTo.class) + .withFactory(ValidationProblemDetailsTo::new) + .withType("validation-error") + .withTitle("A validation failed") + .withStatus(HttpStatus.NOT_ACCEPTABLE.value()) + .withAdditionalAttributeSetter( + (ex, problem) -> { + problem.setFailedValidation(Arrays.asList(ex.getSuppressedFields())); + } + ) + + .build() + ); + + public ProblemDetailsTo map(Throwable ex) { + ProblemDetailsFactoryHelper helper = EXCEPTION_ERROR_MAP.get(ex.getClass()); + + if (helper == null) { + helper = defaultFactory; + } + + ProblemDetailsTo problemDetails = (ProblemDetailsTo) helper.getFactory().get(); + + + helper.getTypeSetter().accept(ex, problemDetails); + helper.getTitleSetter().accept(ex, problemDetails); + helper.getDetailSetter().accept(ex, problemDetails); + helper.getStatusSetter().accept(ex, problemDetails); + helper.getInstanceSetter().accept(ex, problemDetails); + if(helper.getAdditionalAttributeSetter() != null) { + helper.getAdditionalAttributeSetter().accept(ex, problemDetails); + } + + return problemDetails; + } + +} \ No newline at end of file diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/mapper/BookingToMapper.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/mapper/BookingToMapper.java new file mode 100644 index 0000000..e7af164 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/mapper/BookingToMapper.java @@ -0,0 +1,13 @@ +package com.baumeister.sndbx.exceptionhandling.service.mapper; + +import com.baumeister.sndbx.exceptionhandling.domain.model.Booking; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper +public interface BookingToMapper { + BookingToMapper INSTANCE = Mappers.getMapper(BookingToMapper.class); + + com.devonfw.devon4j.generated.api.model.BookingTo map(Booking source); +} diff --git a/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml b/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml new file mode 100644 index 0000000..f4f805e --- /dev/null +++ b/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml @@ -0,0 +1,158 @@ +openapi: '3.0.2' +info: + title: Booking REST API + description: |- + This API file is just an example to show the options when creating an OpenAPI file. + This Api is a small example for the My Thai Star + version: '1.0' + contact: + email: contact@mail.de +paths: + + /booking: + post: + tags: + - "Booking" + summary: Create a new Booking + description: Creates and returns a new Booking + operationId: createBooking + responses: + '201': + description: "Created" + content: + application/json: + schema: + $ref: '#/components/schemas/Booking' + '403': + $ref: '#/components/responses/ValidationError' + + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Booking' + + get: + tags: + - "Booking" + summary: Get all Bookings + description: Returns a list of bookings + operationId: getBookingAll + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Booking' + + + /booking/{bookingId}: + get: + tags: + - "Booking" + summary: Find a single booking by Id + description: Returns a single booking + operationId: getBookingById + parameters: + - name: bookingId + in: path + description: ID of booking to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Booking' + + '400': + description: Invalid ID supplied + '404': + $ref: '#/components/responses/NotFound' + +components: + + schemas: + + Booking: + type: object + properties: + id: + type: integer + format: int64 + example: 1 + numberOfSeats: + type: integer + example: 4 + minimum: 1 + maximum: 20 + description: + type: string + example: "Needs a child chair" + email: + type: string + example: "guest.email@email.com" + required: + - numberOfSeats + - email + + ProblemDetails: + discriminator: + propertyName: _schema + type: object + properties: + _schema: + type: string + description: This identifies the concrete ProblemDetails class from the OpenAPI spec. + type: + type: string + description: A URN that identifies the problem type + example: 'urn:problem:not_found' + title: + type: string + description: A human readable explanation of the problem + example: 'The specified resource was not found' + status: + type: integer + description: The HTTP status of the error + example: '404' + detail: + type: string + description: A detailed message of the problem + example: 'The Booking with id 404 was not found' + instance: + type: string + description: A UUID in a URN form identifying the concrete problem instance + example: 'urn:uuid:525ddaed-d2eb-4dd7-8fef-e45e3f1823b8' + ValidationProblemDetails: + allOf: + - $ref: '#/components/schemas/ProblemDetails' + - type: object + properties: + failedValidation: + type: array + items: + type: string + + responses: + ValidationError: + description: The specified resource was not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ValidationProblemDetails' + + NotFound: + description: The specified resource was not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + diff --git a/integration/exception-handling/src/main/resources/application.properties b/integration/exception-handling/src/main/resources/application.properties new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/integration/exception-handling/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/DemoApplicationTests.java b/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/DemoApplicationTests.java new file mode 100644 index 0000000..c4f8996 --- /dev/null +++ b/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/DemoApplicationTests.java @@ -0,0 +1,13 @@ +package com.baumeister.sndbx.exceptionhandling; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DemoApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java b/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java new file mode 100644 index 0000000..12cebd3 --- /dev/null +++ b/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java @@ -0,0 +1,49 @@ +package com.baumeister.sndbx.exceptionhandling.service; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class BookingServiceTest { + @LocalServerPort + private int port; + private final static ObjectMapper mapper = new ObjectMapper(); + + private final HttpClient client = HttpClient.newBuilder().build(); + private static final long NOT_FOUND_BOOKING_ID = 404L; + + @Test + void testNotFoundException () throws IOException, InterruptedException { + var response = client.send(getBookingHttpRequest(NOT_FOUND_BOOKING_ID), BodyHandlers.ofString()); + var body = response.body(); + var json = """ + { + "type": "urn:problems:not-found", + "title": "Resource not found", + "status": 404, + "detail": "The element of id 404 could not be found", + "instance": "%s" + } + """; + json = String.format(json, mapper.readTree(body).at("/instance").asText()); + assertEquals(mapper.readTree(json),mapper.readTree(body)); + } + + private HttpRequest getBookingHttpRequest(long id) { + String uri = "http://localhost:"+port+"/booking/"+id; + return HttpRequest.newBuilder() + .GET() + .uri(URI.create(uri)).build(); + } + +} \ No newline at end of file From 2a6a214890a2f7b16ce331946fba2261ca069a86 Mon Sep 17 00:00:00 2001 From: Fabian Baumeister Date: Fri, 4 Nov 2022 16:16:07 +0100 Subject: [PATCH 2/4] Refactor complete solution - Rename packages - Rename classes - Remove unused classes --- integration/exception-handling/build.gradle | 2 +- .../exception/ValidationException.java | 21 -- .../exception/CustomExceptionHandler.java | 21 -- .../service/exception/ExceptionError.java | 14 -- .../ProblemDetailsFactoryHelper.java | 150 --------------- .../exception/ProblemDetailsMapper.java | 80 -------- .../exceptionhandling/DemoApplication.java | 2 +- .../domain/BookingManagement.java | 6 +- .../domain/model/Booking.java | 2 +- .../general/exception/NotFoundException.java | 2 +- .../service/BookingService.java | 8 +- .../exception/CustomExceptionHandler.java | 41 ++++ .../service/exception/ExceptionMapper.java | 182 ++++++++++++++++++ .../service/exception/ExceptionMappers.java | 67 +++++++ .../service/mapper/BookingToMapper.java | 5 +- .../service/BookingServiceTest.java | 49 ----- .../DemoApplicationTests.java | 2 +- .../service/BookingServiceTest.java | 98 ++++++++++ .../testdata/to/BookingToBuilder.java | 47 +++++ 19 files changed, 448 insertions(+), 351 deletions(-) delete mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java delete mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java delete mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java delete mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java delete mode 100644 integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java rename integration/exception-handling/src/main/java/com/{baumeister/sndbx => devonfw/java/integration}/exceptionhandling/DemoApplication.java (83%) rename integration/exception-handling/src/main/java/com/{baumeister/sndbx => devonfw/java/integration}/exceptionhandling/domain/BookingManagement.java (71%) rename integration/exception-handling/src/main/java/com/{baumeister/sndbx => devonfw/java/integration}/exceptionhandling/domain/model/Booking.java (80%) rename integration/exception-handling/src/main/java/com/{baumeister/sndbx => devonfw/java/integration}/exceptionhandling/general/exception/NotFoundException.java (63%) rename integration/exception-handling/src/main/java/com/{baumeister/sndbx => devonfw/java/integration}/exceptionhandling/service/BookingService.java (80%) create mode 100644 integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java create mode 100644 integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java create mode 100644 integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java rename integration/exception-handling/src/main/java/com/{baumeister/sndbx => devonfw/java/integration}/exceptionhandling/service/mapper/BookingToMapper.java (61%) delete mode 100644 integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java rename integration/exception-handling/src/test/java/com/{baumeister/sndbx => devonfw/java/integration}/exceptionhandling/DemoApplicationTests.java (76%) create mode 100644 integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java create mode 100644 integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/testdata/to/BookingToBuilder.java diff --git a/integration/exception-handling/build.gradle b/integration/exception-handling/build.gradle index e9c95bd..da8073b 100644 --- a/integration/exception-handling/build.gradle +++ b/integration/exception-handling/build.gradle @@ -19,7 +19,7 @@ sourceSets.main.java.srcDirs += 'build/generated-sources/server/src/main/java' dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'io.swagger.parser.v3:swagger-parser:2.1.6' + implementation 'io.swagger.parser.v3:swagger-parser:2.1.7' implementation 'org.openapitools:jackson-databind-nullable:0.2.4' compileOnly 'org.projectlombok:lombok:1.18.24' implementation 'org.mapstruct:mapstruct:1.5.3.Final' diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java deleted file mode 100644 index c21911a..0000000 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/ValidationException.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.baumeister.sndbx.exceptionhandling.general.exception; - -import java.util.List; -import lombok.Getter; - -public class ValidationException extends RuntimeException{ - private List validationErrors; - - public ValidationException(String message, Throwable cause, List validationErrors) { - super(message, cause); - this.validationErrors = validationErrors; - } - - public List getValidationErrors() { - return validationErrors; - } - - public void setValidationErrors(List validationErrors) { - this.validationErrors = validationErrors; - } -} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java deleted file mode 100644 index 6dc9301..0000000 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/CustomExceptionHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.baumeister.sndbx.exceptionhandling.service.exception; - -import com.devonfw.devon4j.generated.api.model.ValidationProblemDetailsTo; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.util.MultiValueMap; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; -import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; - -@ControllerAdvice -public class CustomExceptionHandler { - - @ExceptionHandler(Throwable.class) - public ResponseEntity notFoundException(Throwable ex) { - ProblemDetailsTo problemDetails = ProblemDetailsMapper.getInstance().map(ex); - return new ResponseEntity(problemDetails, HttpStatus.valueOf(problemDetails.getStatus())); - } - -} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java deleted file mode 100644 index 1ed08ad..0000000 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ExceptionError.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.baumeister.sndbx.exceptionhandling.service.exception; - -import lombok.AllArgsConstructor; -import lombok.Data; -import org.springframework.http.HttpStatus; - -@Data -@AllArgsConstructor -public class ExceptionError { - - private String type; - private String title; - private HttpStatus status; -} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java deleted file mode 100644 index 04a3545..0000000 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsFactoryHelper.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.baumeister.sndbx.exceptionhandling.service.exception; - -import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; -import java.util.UUID; -import java.util.function.BiConsumer; -import java.util.function.Supplier; - -public class ProblemDetailsFactoryHelper { - - private Supplier

factory; - private String type = "urn:problem:internal-server-error"; - private String title = "Internal Server Error"; - private Integer status = 500; - private BiConsumer typeSetter = (ex, problem) -> problem.setType(this.type); - private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); - private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( - UUID.randomUUID().toString()); - private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); - private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); - private BiConsumer additionalAttributeSetter; - - public Supplier

getFactory() { - return factory; - } - - public String getType() { - return type; - } - - public String getTitle() { - return title; - } - - public Integer getStatus() { - return status; - } - - public BiConsumer getTypeSetter() { - return typeSetter; - } - - public BiConsumer getTitleSetter() { - return titleSetter; - } - - public BiConsumer getInstanceSetter() { - return instanceSetter; - } - - public BiConsumer getStatusSetter() { - return statusSetter; - } - - public BiConsumer getDetailSetter() { - return detailSetter; - } - - public BiConsumer getAdditionalAttributeSetter() { - return additionalAttributeSetter; - } - - public static final class ProblemDetailsFactoryHelperBuilder { - - private Supplier

factory; - private String type = "urn:problem:internal-server-error"; - private String title = "Internal Server Error"; - private Integer status = 500; - private BiConsumer typeSetter = (ex, problem) -> problem.setType(this.type); - private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); - private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( - "urn:uuid:" + UUID.randomUUID().toString()); - private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); - private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); - private BiConsumer additionalAttributeSetter; - - private ProblemDetailsFactoryHelperBuilder() { - } - - public static ProblemDetailsFactoryHelperBuilder aProblemDetailsFactoryHelper( - Class throwableClass, Class

problemdetailsClazz) { - return new ProblemDetailsFactoryHelperBuilder(); - } - - public ProblemDetailsFactoryHelperBuilder withFactory(Supplier

factory) { - this.factory = factory; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withType(String type) { - this.type = type; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withTitle(String title) { - this.title = title; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withStatus(Integer status) { - this.status = status; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withTypeSetter(BiConsumer typeSetter) { - this.typeSetter = typeSetter; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withTitleSetter(BiConsumer titleSetter) { - this.titleSetter = titleSetter; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withInstanceSetter(BiConsumer instanceSetter) { - this.instanceSetter = instanceSetter; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withStatusSetter(BiConsumer statusSetter) { - this.statusSetter = statusSetter; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withDetailSetter(BiConsumer detailSetter) { - this.detailSetter = detailSetter; - return this; - } - - public ProblemDetailsFactoryHelperBuilder withAdditionalAttributeSetter( - BiConsumer additionalAttributeSetter) { - this.additionalAttributeSetter = additionalAttributeSetter; - return this; - } - - public ProblemDetailsFactoryHelper build() { - ProblemDetailsFactoryHelper problemDetailsFactoryHelper = new ProblemDetailsFactoryHelper(); - problemDetailsFactoryHelper.title = this.title; - problemDetailsFactoryHelper.instanceSetter = this.instanceSetter; - problemDetailsFactoryHelper.detailSetter = this.detailSetter; - problemDetailsFactoryHelper.statusSetter = this.statusSetter; - problemDetailsFactoryHelper.status = this.status; - problemDetailsFactoryHelper.titleSetter = this.titleSetter; - problemDetailsFactoryHelper.typeSetter = this.typeSetter; - problemDetailsFactoryHelper.additionalAttributeSetter = this.additionalAttributeSetter; - problemDetailsFactoryHelper.factory = this.factory; - problemDetailsFactoryHelper.type = this.type; - return problemDetailsFactoryHelper; - } - } -} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java b/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java deleted file mode 100644 index 6a82333..0000000 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/exception/ProblemDetailsMapper.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.baumeister.sndbx.exceptionhandling.service.exception; - -import com.baumeister.sndbx.exceptionhandling.general.exception.NotFoundException; -import com.baumeister.sndbx.exceptionhandling.general.exception.ValidationException; -import com.baumeister.sndbx.exceptionhandling.service.exception.ProblemDetailsFactoryHelper.ProblemDetailsFactoryHelperBuilder; -import com.devonfw.devon4j.generated.api.model.ValidationProblemDetailsTo; -import java.util.Arrays; -import java.util.Map; -import java.util.UUID; -import java.util.function.BiConsumer; -import java.util.function.Function; -import java.util.function.Supplier; -import org.apache.commons.lang3.tuple.ImmutablePair; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.commons.lang3.tuple.Triple; -import org.mapstruct.factory.Mappers; -import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.MethodArgumentNotValidException; - -public class ProblemDetailsMapper { - - private static ProblemDetailsMapper instance; - - private ProblemDetailsMapper() { - } - - public static ProblemDetailsMapper getInstance() { - if (instance == null) { - instance = new ProblemDetailsMapper(); - } - return instance; - } - - private static final ProblemDetailsFactoryHelper defaultFactory = - ProblemDetailsFactoryHelperBuilder.aProblemDetailsFactoryHelper(Throwable.class, - ProblemDetailsTo.class) - .withFactory(ProblemDetailsTo::new) - .build(); - Map, ProblemDetailsFactoryHelper> EXCEPTION_ERROR_MAP = Map.of( - - MethodArgumentNotValidException.class, - ProblemDetailsFactoryHelperBuilder.aProblemDetailsFactoryHelper( - MethodArgumentNotValidException.class, ValidationProblemDetailsTo.class) - .withFactory(ValidationProblemDetailsTo::new) - .withType("validation-error") - .withTitle("A validation failed") - .withStatus(HttpStatus.NOT_ACCEPTABLE.value()) - .withAdditionalAttributeSetter( - (ex, problem) -> { - problem.setFailedValidation(Arrays.asList(ex.getSuppressedFields())); - } - ) - - .build() - ); - - public ProblemDetailsTo map(Throwable ex) { - ProblemDetailsFactoryHelper helper = EXCEPTION_ERROR_MAP.get(ex.getClass()); - - if (helper == null) { - helper = defaultFactory; - } - - ProblemDetailsTo problemDetails = (ProblemDetailsTo) helper.getFactory().get(); - - - helper.getTypeSetter().accept(ex, problemDetails); - helper.getTitleSetter().accept(ex, problemDetails); - helper.getDetailSetter().accept(ex, problemDetails); - helper.getStatusSetter().accept(ex, problemDetails); - helper.getInstanceSetter().accept(ex, problemDetails); - if(helper.getAdditionalAttributeSetter() != null) { - helper.getAdditionalAttributeSetter().accept(ex, problemDetails); - } - - return problemDetails; - } - -} \ No newline at end of file diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/DemoApplication.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/DemoApplication.java similarity index 83% rename from integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/DemoApplication.java rename to integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/DemoApplication.java index db25d0d..07ef367 100644 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/DemoApplication.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/DemoApplication.java @@ -1,4 +1,4 @@ -package com.baumeister.sndbx.exceptionhandling; +package com.devonfw.java.integration.exceptionhandling; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/BookingManagement.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java similarity index 71% rename from integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/BookingManagement.java rename to integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java index ed96b1b..633bc1f 100644 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/BookingManagement.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java @@ -1,7 +1,7 @@ -package com.baumeister.sndbx.exceptionhandling.domain; +package com.devonfw.java.integration.exceptionhandling.domain; -import com.baumeister.sndbx.exceptionhandling.domain.model.Booking; -import com.baumeister.sndbx.exceptionhandling.general.exception.NotFoundException; +import com.devonfw.java.integration.exceptionhandling.domain.model.Booking; +import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/model/Booking.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java similarity index 80% rename from integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/model/Booking.java rename to integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java index 077ee0b..70bad35 100644 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/domain/model/Booking.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java @@ -1,4 +1,4 @@ -package com.baumeister.sndbx.exceptionhandling.domain.model; +package com.devonfw.java.integration.exceptionhandling.domain.model; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/NotFoundException.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java similarity index 63% rename from integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/NotFoundException.java rename to integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java index e62949a..0b0dd69 100644 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/general/exception/NotFoundException.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java @@ -1,4 +1,4 @@ -package com.baumeister.sndbx.exceptionhandling.general.exception; +package com.devonfw.java.integration.exceptionhandling.general.exception; public class NotFoundException extends RuntimeException { diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/BookingService.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java similarity index 80% rename from integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/BookingService.java rename to integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java index 8ad555d..0cf4a6e 100644 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/BookingService.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java @@ -1,14 +1,12 @@ -package com.baumeister.sndbx.exceptionhandling.service; +package com.devonfw.java.integration.exceptionhandling.service; -import com.baumeister.sndbx.exceptionhandling.domain.BookingManagement; -import com.baumeister.sndbx.exceptionhandling.service.mapper.BookingToMapper; +import com.devonfw.java.integration.exceptionhandling.domain.BookingManagement; +import com.devonfw.java.integration.exceptionhandling.service.mapper.BookingToMapper; import com.devonfw.devon4j.generated.api.model.BookingTo; import com.devonfw.devon4j.generated.api.service.BookingApi; import java.util.List; import java.util.Optional; -import javax.validation.Valid; import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.NativeWebRequest; diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java new file mode 100644 index 0000000..13a8891 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java @@ -0,0 +1,41 @@ +package com.devonfw.java.integration.exceptionhandling.service.exception; + +import static com.devonfw.java.integration.exceptionhandling.service.exception.ExceptionMappers.EXCEPTION_ERRORS_MAP; + +import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; +import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class CustomExceptionHandler { + + private static ExceptionMapper getExceptionMapper( + Throwable ex) { + Class exceptionClazz = ex.getClass(); + ExceptionMapper mapper = EXCEPTION_ERRORS_MAP.get( + exceptionClazz); + while (Throwable.class.isAssignableFrom(exceptionClazz) && mapper == null) { + mapper = EXCEPTION_ERRORS_MAP.get(exceptionClazz); + exceptionClazz = exceptionClazz.getSuperclass(); + } + + if (mapper == null) { + mapper = EXCEPTION_ERRORS_MAP.get(Throwable.class); + } + + return mapper; + } + + @ExceptionHandler(Throwable.class) + public ResponseEntity catchThrowable(Throwable ex) { + ExceptionMapper mapper = getExceptionMapper(ex); + ProblemDetailsTo problemDetails = mapper.map(ex); + + return new ResponseEntity(problemDetails, + HttpStatus.valueOf(problemDetails.getStatus())); + } + +} diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java new file mode 100644 index 0000000..acee79f --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java @@ -0,0 +1,182 @@ +package com.devonfw.java.integration.exceptionhandling.service.exception; + +import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +public class ExceptionMapper { + + private Class throwableClass; + + private Supplier factory; + private String type = "urn:problem:internal-server-error"; + private String title = "Internal Server Error"; + private Integer status = 500; + private BiConsumer typeSetter = (ex, problem) -> problem.setType(this.type); + private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); + private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( + UUID.randomUUID().toString()); + private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); + private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); + private BiConsumer additionalAttributeSetter; + + public TO map(FROM ex) { + + TO problemDetails = this.factory.get(); + this.typeSetter.accept(ex, problemDetails); + this.titleSetter.accept(ex, problemDetails); + this.detailSetter.accept(ex, problemDetails); + this.statusSetter.accept(ex, problemDetails); + this.instanceSetter.accept(ex, problemDetails); + + if (this.additionalAttributeSetter != null) { + this.additionalAttributeSetter.accept(ex, problemDetails); + } + return problemDetails; + } + + public Supplier getFactory() { + return factory; + } + + public String getType() { + return type; + } + + public String getTitle() { + return title; + } + + public Integer getStatus() { + return status; + } + + public BiConsumer getTypeSetter() { + return typeSetter; + } + + public BiConsumer getTitleSetter() { + return titleSetter; + } + + public BiConsumer getInstanceSetter() { + return instanceSetter; + } + + public BiConsumer getStatusSetter() { + return statusSetter; + } + + public BiConsumer getDetailSetter() { + return detailSetter; + } + + public BiConsumer getAdditionalAttributeSetter() { + return additionalAttributeSetter; + } + + public Class getThrowableClass() { + return throwableClass; + } + + public static final class ExceptionMapperBuilder { + + private Class throwableClass; + + private Supplier factory; + private String type = "internal-server-error"; + private String title = "Internal Server Error"; + private Integer status = 500; + private BiConsumer typeSetter = (ex, problem) -> problem.setType( + "urn:problem:" + this.type); + private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); + private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( + "urn:uuid:" + UUID.randomUUID().toString()); + private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); + private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); + private BiConsumer additionalAttributeSetter; + + private ExceptionMapperBuilder(Class throwableClass) { + this.throwableClass = throwableClass; + } + + public static ExceptionMapperBuilder builder( + Class throwableClass, Class

problemdetailsClazz) { + return new ExceptionMapperBuilder(throwableClass); + } + + public ExceptionMapperBuilder withFactory(Supplier factory) { + this.factory = factory; + return this; + } + + public ExceptionMapperBuilder withType(String type) { + this.type = type; + return this; + } + + public ExceptionMapperBuilder withTitle(String title) { + this.title = title; + return this; + } + + public ExceptionMapperBuilder withStatus(Integer status) { + this.status = status; + return this; + } + + public ExceptionMapperBuilder withTypeSetter(BiConsumer typeSetter) { + this.typeSetter = typeSetter; + return this; + } + + public ExceptionMapperBuilder withTitleSetter(BiConsumer titleSetter) { + this.titleSetter = titleSetter; + return this; + } + + public ExceptionMapperBuilder withInstanceSetter( + BiConsumer instanceSetter) { + this.instanceSetter = instanceSetter; + return this; + } + + public ExceptionMapperBuilder withStatusSetter(BiConsumer statusSetter) { + this.statusSetter = statusSetter; + return this; + } + + public ExceptionMapperBuilder withDetailSetter(BiConsumer detailSetter) { + this.detailSetter = detailSetter; + return this; + } + + public ExceptionMapperBuilder withAdditionalAttributeSetter( + BiConsumer additionalAttributeSetter) { + this.additionalAttributeSetter = additionalAttributeSetter; + return this; + } + + public ExceptionMapper build() { + if (this.factory == null) { + // A factory needs to be defined for each ProblemDetailsTo. + // Otherwise, the correct ProblemDetailsTo could not be initialized by the mapper. + throw new IllegalArgumentException("Missing a factory for the ProblemDetails"); + } + ExceptionMapper problemDetailsFactoryHelper = new ExceptionMapper(); + problemDetailsFactoryHelper.throwableClass = this.throwableClass; + problemDetailsFactoryHelper.title = this.title; + problemDetailsFactoryHelper.instanceSetter = this.instanceSetter; + problemDetailsFactoryHelper.detailSetter = this.detailSetter; + problemDetailsFactoryHelper.statusSetter = this.statusSetter; + problemDetailsFactoryHelper.status = this.status; + problemDetailsFactoryHelper.titleSetter = this.titleSetter; + problemDetailsFactoryHelper.typeSetter = this.typeSetter; + problemDetailsFactoryHelper.additionalAttributeSetter = this.additionalAttributeSetter; + problemDetailsFactoryHelper.factory = this.factory; + problemDetailsFactoryHelper.type = this.type; + return problemDetailsFactoryHelper; + } + } +} diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java new file mode 100644 index 0000000..5d117f8 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java @@ -0,0 +1,67 @@ +package com.devonfw.java.integration.exceptionhandling.service.exception; + +import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; +import com.devonfw.devon4j.generated.api.model.ValidationProblemDetailsTo; +import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; +import com.devonfw.java.integration.exceptionhandling.service.exception.ExceptionMapper.ExceptionMapperBuilder; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; + +public abstract class ExceptionMappers { + + private static final ExceptionMapper DEFAULT_EXCEPTION_MAPPER = + ExceptionMapperBuilder.builder(Throwable.class, + ProblemDetailsTo.class) + .withFactory(ProblemDetailsTo::new) + .build(); + + private static final ExceptionMapper VALIDATION_EXCEPTION_MAPPER = + ExceptionMapperBuilder.builder( + MethodArgumentNotValidException.class, ValidationProblemDetailsTo.class) + .withFactory(ValidationProblemDetailsTo::new) + .withType("validation-error") + .withTitle("A validation failed") + .withStatus(HttpStatus.NOT_ACCEPTABLE.value()) + .withDetailSetter((ex, problem) -> { + problem.setDetail( + "Validation failed for " + ex.getBindingResult().getErrorCount() + " fields"); + }) + .withAdditionalAttributeSetter( + (ex, problem) -> { + List fields = ex.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getArguments) + .flatMap(Stream::of) + .filter(arg -> arg instanceof DefaultMessageSourceResolvable) + .map(arg -> (DefaultMessageSourceResolvable) arg) + .map(msg -> msg.getCode()) + .collect(Collectors.toList()); + problem.setFailedValidation(fields); + } + ) + .build(); + private static final ExceptionMapper NOT_FOUND_EXCEPTION_MAPPER = + ExceptionMapperBuilder.builder( + NotFoundException.class, ProblemDetailsTo.class) + .withFactory(ProblemDetailsTo::new) + .withType("not-found") + .withTitle("Resource not found") + .withStatus(HttpStatus.NOT_FOUND.value()) + .build(); + + private static final List> EXCEPTION_MAPPER_LIST = + List.of( + DEFAULT_EXCEPTION_MAPPER, + VALIDATION_EXCEPTION_MAPPER, + NOT_FOUND_EXCEPTION_MAPPER + ); + + + public static final Map, ExceptionMapper> EXCEPTION_ERRORS_MAP + = EXCEPTION_MAPPER_LIST.stream() + .collect(Collectors.toMap(ExceptionMapper::getThrowableClass, pdfh -> pdfh)); +} diff --git a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/mapper/BookingToMapper.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java similarity index 61% rename from integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/mapper/BookingToMapper.java rename to integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java index e7af164..686d56f 100644 --- a/integration/exception-handling/src/main/java/com/baumeister/sndbx/exceptionhandling/service/mapper/BookingToMapper.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java @@ -1,8 +1,7 @@ -package com.baumeister.sndbx.exceptionhandling.service.mapper; +package com.devonfw.java.integration.exceptionhandling.service.mapper; -import com.baumeister.sndbx.exceptionhandling.domain.model.Booking; +import com.devonfw.java.integration.exceptionhandling.domain.model.Booking; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; import org.mapstruct.factory.Mappers; @Mapper diff --git a/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java b/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java deleted file mode 100644 index 12cebd3..0000000 --- a/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/service/BookingServiceTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.baumeister.sndbx.exceptionhandling.service; - -import static org.junit.jupiter.api.Assertions.*; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse.BodyHandlers; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.server.LocalServerPort; - -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -class BookingServiceTest { - @LocalServerPort - private int port; - private final static ObjectMapper mapper = new ObjectMapper(); - - private final HttpClient client = HttpClient.newBuilder().build(); - private static final long NOT_FOUND_BOOKING_ID = 404L; - - @Test - void testNotFoundException () throws IOException, InterruptedException { - var response = client.send(getBookingHttpRequest(NOT_FOUND_BOOKING_ID), BodyHandlers.ofString()); - var body = response.body(); - var json = """ - { - "type": "urn:problems:not-found", - "title": "Resource not found", - "status": 404, - "detail": "The element of id 404 could not be found", - "instance": "%s" - } - """; - json = String.format(json, mapper.readTree(body).at("/instance").asText()); - assertEquals(mapper.readTree(json),mapper.readTree(body)); - } - - private HttpRequest getBookingHttpRequest(long id) { - String uri = "http://localhost:"+port+"/booking/"+id; - return HttpRequest.newBuilder() - .GET() - .uri(URI.create(uri)).build(); - } - -} \ No newline at end of file diff --git a/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/DemoApplicationTests.java b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/DemoApplicationTests.java similarity index 76% rename from integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/DemoApplicationTests.java rename to integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/DemoApplicationTests.java index c4f8996..9f4adf3 100644 --- a/integration/exception-handling/src/test/java/com/baumeister/sndbx/exceptionhandling/DemoApplicationTests.java +++ b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/DemoApplicationTests.java @@ -1,4 +1,4 @@ -package com.baumeister.sndbx.exceptionhandling; +package com.devonfw.java.integration.exceptionhandling; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java new file mode 100644 index 0000000..90f5fc9 --- /dev/null +++ b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java @@ -0,0 +1,98 @@ +package com.devonfw.java.integration.exceptionhandling.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.devonfw.devon4j.generated.api.model.BookingTo; +import com.devonfw.java.integration.exceptionhandling.testdata.to.BookingToBuilder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; +import org.apache.http.HttpStatus; +import org.apache.http.entity.ContentType; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class BookingServiceTest { + + private final static ObjectMapper mapper = new ObjectMapper(); + private static final long NOT_FOUND_BOOKING_ID = 404L; + private final HttpClient client = HttpClient.newBuilder().build(); + @LocalServerPort + private int port; + + /** + * Given a bookingTo is provided to the createBooking endpoint When the bookingTo is using a + * SeatNumber greater than 20, which is invalid according to the OpenAPI spec Then a ProblemDetail + * is returned informing about the invalid fields + */ + @Test + void validationError() throws IOException, InterruptedException { + final int expectedStatusCode = HttpStatus.SC_NOT_ACCEPTABLE; + BookingTo booking = BookingToBuilder.aBookingTo() + .withNumberOfSeats(21) + .build(); + var response = client.send(createBooking(booking), BodyHandlers.ofString()); + int actualStatusCode = response.statusCode(); + assertEquals(expectedStatusCode, actualStatusCode); + var body = response.body(); + var json = """ + { + "_schema":"ValidationProblemDetails", + "type": "urn:problem:validation-error", + "title": "A validation failed", + "status": %d, + "detail": "Validation failed for 1 fields", + "instance": "%s", + "failedValidation": [ + "numberOfSeats" + ] + } + """; + String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); + json = String.format(json, expectedStatusCode, actualInstanceUuid); + assertEquals(mapper.readTree(json), mapper.readTree(body)); + } + + @Test + void testNotFoundException() throws IOException, InterruptedException { + var response = client.send(getBookingHttpRequest(NOT_FOUND_BOOKING_ID), + BodyHandlers.ofString()); + var body = response.body(); + var json = """ + { + "_schema":"ProblemDetails", + "type": "urn:problem:not-found", + "title": "Resource not found", + "status": 404, + "detail": "The element of id 404 could not be found", + "instance": "%s" + } + """; + json = String.format(json, mapper.readTree(body).at("/instance").asText()); + assertEquals(mapper.readTree(json), mapper.readTree(body)); + } + + private HttpRequest createBooking(BookingTo booking) throws JsonProcessingException { + String uri = "http://localhost:" + port + "/booking"; + return HttpRequest.newBuilder() + .POST(BodyPublishers.ofString(mapper.writeValueAsString(booking))) + .setHeader("Content-Type", ContentType.APPLICATION_JSON.toString()) + .uri(URI.create(uri)).build(); + } + + private HttpRequest getBookingHttpRequest(long id) { + String uri = "http://localhost:" + port + "/booking/" + id; + return HttpRequest.newBuilder() + .GET() + .uri(URI.create(uri)).build(); + } + +} diff --git a/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/testdata/to/BookingToBuilder.java b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/testdata/to/BookingToBuilder.java new file mode 100644 index 0000000..00b723a --- /dev/null +++ b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/testdata/to/BookingToBuilder.java @@ -0,0 +1,47 @@ +package com.devonfw.java.integration.exceptionhandling.testdata.to; + +import com.devonfw.devon4j.generated.api.model.BookingTo; + +public final class BookingToBuilder { + + private Long id = 1L; + private Integer numberOfSeats = 10; + private String description = "Only veggi"; + private String email = "max.mustermann@mail.de"; + + private BookingToBuilder() { + } + + public static BookingToBuilder aBookingTo() { + return new BookingToBuilder(); + } + + public BookingToBuilder withId(Long id) { + this.id = id; + return this; + } + + public BookingToBuilder withNumberOfSeats(Integer numberOfSeats) { + this.numberOfSeats = numberOfSeats; + return this; + } + + public BookingToBuilder withDescription(String description) { + this.description = description; + return this; + } + + public BookingToBuilder withEmail(String email) { + this.email = email; + return this; + } + + public BookingTo build() { + BookingTo bookingTo = new BookingTo(); + bookingTo.setId(id); + bookingTo.setNumberOfSeats(numberOfSeats); + bookingTo.setDescription(description); + bookingTo.setEmail(email); + return bookingTo; + } +} From a7875426ecf2d1aed7987bb2a49498db9e6ce2ac Mon Sep 17 00:00:00 2001 From: Fabian Baumeister Date: Mon, 7 Nov 2022 11:11:58 +0100 Subject: [PATCH 3/4] Add documentation Added documentation of fields. Some are much easier now. --- .../domain/BookingManagement.java | 22 +- .../domain/model/Booking.java | 3 + .../general/exception/BusinessException.java | 12 + .../general/exception/NotFoundException.java | 9 +- .../exception/OverBookedException.java | 12 + .../service/BookingService.java | 9 +- .../exception/CustomExceptionHandler.java | 47 ++- .../service/exception/ExceptionMapper.java | 277 +++++++++++++----- .../service/exception/ExceptionMappers.java | 67 +++-- .../service/mapper/BookingToMapper.java | 3 + .../src/main/openapi/BookingOpenApi.yaml | 17 -- .../service/BookingServiceTest.java | 111 ++++++- 12 files changed, 449 insertions(+), 140 deletions(-) create mode 100644 integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/BusinessException.java create mode 100644 integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/OverBookedException.java diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java index 633bc1f..a796412 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java @@ -5,23 +5,33 @@ import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; +/** + * Manages Bookings for our restaurant. + */ @Component public class BookingManagement { + /** + * Returns a concrete Booking for a given id. + * Because this is an example implementation, always a new Booking is created and returned using the given id. + * + * @param id The id of the booking as identifier. + * @return The booking with the given id. + * @throws NotFoundException when the given id is 404 (HTTP not found ;-) ) then a NotFoundException is thrown. + * @throws IllegalArgumentException when the id parameter is not set. + */ public Booking getBooking(Long id) { - ObjectUtils.isEmpty(id); + if (ObjectUtils.isEmpty(id)) { + throw new IllegalArgumentException("No id was defined, when calling getBooking()"); + } if(id == 404L) { throw new NotFoundException("The element of id " + id + " could not be found"); } return Booking.builder() - .id(1L) + .id(id) .email("m.mustermann@mail.com") .description("No gluten") .numberOfSeats(2) .build(); } - - public Booking createBooking(Booking booking) { - return booking; - } } diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java index 70bad35..21905c2 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java @@ -5,6 +5,9 @@ import lombok.Data; import lombok.NoArgsConstructor; +/** + * Represents the booking of a table in our restaurant example. + */ @Data @NoArgsConstructor @AllArgsConstructor diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/BusinessException.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/BusinessException.java new file mode 100644 index 0000000..7715c6f --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/BusinessException.java @@ -0,0 +1,12 @@ +package com.devonfw.java.integration.exceptionhandling.general.exception; + +public abstract class BusinessException extends RuntimeException{ + + public BusinessException(String message) { + super(message); + } + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java index 0b0dd69..0142295 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java @@ -1,8 +1,15 @@ package com.devonfw.java.integration.exceptionhandling.general.exception; -public class NotFoundException extends RuntimeException { +/** + * Exception when a certain element was not found. + */ +public class NotFoundException extends BusinessException { public NotFoundException(String message) { super(message); } + + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/OverBookedException.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/OverBookedException.java new file mode 100644 index 0000000..ddce36c --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/OverBookedException.java @@ -0,0 +1,12 @@ +package com.devonfw.java.integration.exceptionhandling.general.exception; + +public class OverBookedException extends BusinessException{ + + public OverBookedException(String message) { + super(message); + } + + public OverBookedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java index 0cf4a6e..f39dac4 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java @@ -1,6 +1,7 @@ package com.devonfw.java.integration.exceptionhandling.service; import com.devonfw.java.integration.exceptionhandling.domain.BookingManagement; +import com.devonfw.java.integration.exceptionhandling.general.exception.OverBookedException; import com.devonfw.java.integration.exceptionhandling.service.mapper.BookingToMapper; import com.devonfw.devon4j.generated.api.model.BookingTo; import com.devonfw.devon4j.generated.api.service.BookingApi; @@ -24,12 +25,8 @@ public Optional getRequest() { @Override public ResponseEntity createBooking(BookingTo bookingTo) { - return ResponseEntity.accepted().body(bookingTo); - } - - @Override - public ResponseEntity> getBookingAll() { - return BookingApi.super.getBookingAll(); + // Throwing this exception always, when the validation is ok + throw new OverBookedException("Sadly there's no free table at the moment"); } @Override diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java index 13a8891..369c1c1 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java @@ -4,38 +4,63 @@ import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +/** + * Catches the exceptions and maps them to Problemdetails (see RFC7807). + */ @ControllerAdvice +@Slf4j public class CustomExceptionHandler { - private static ExceptionMapper getExceptionMapper( - Throwable ex) { + /** + * This exceptionHandler catches all Throwables, uses the possible next mapper and returns the according + * ProblemdetailsTo. Problemdetails follow the RFC 7807. + * + * @param ex The thrown exception. + * @return A {@link ProblemDetailsTo} as {@link ResponseEntity} and the according {@link HttpStatus} + */ + @ExceptionHandler(Throwable.class) + public ResponseEntity catchThrowable(Throwable ex) { + ExceptionMapper mapper = getExceptionMapper(ex); + ProblemDetailsTo problemDetails = mapper.map(ex); + + return new ResponseEntity(problemDetails, + HttpStatus.valueOf(problemDetails.getStatus())); + } + + private static ExceptionMapper + getExceptionMapper(Throwable ex) { + Class exceptionClazz = ex.getClass(); ExceptionMapper mapper = EXCEPTION_ERRORS_MAP.get( exceptionClazz); + + if(! Throwable.class.isAssignableFrom(exceptionClazz)) { + // This should never happen. + // If this happens something is wrong in the implementation and should be checked. + log.warn("Class {} was thrown, but not element of Throwable", exceptionClazz); + } + + // Check if an ExceptionMapper exists that is applicable. + // This should in the last instance always be Throwable while (Throwable.class.isAssignableFrom(exceptionClazz) && mapper == null) { mapper = EXCEPTION_ERRORS_MAP.get(exceptionClazz); exceptionClazz = exceptionClazz.getSuperclass(); } if (mapper == null) { + // defining a default. + // But this should also never happen, as every exception should be derivable from Throwable. + // Anyway as we're in the exception mapping phase, we're implementing it paranoid. mapper = EXCEPTION_ERRORS_MAP.get(Throwable.class); } return mapper; } - @ExceptionHandler(Throwable.class) - public ResponseEntity catchThrowable(Throwable ex) { - ExceptionMapper mapper = getExceptionMapper(ex); - ProblemDetailsTo problemDetails = mapper.map(ex); - - return new ResponseEntity(problemDetails, - HttpStatus.valueOf(problemDetails.getStatus())); - } - } diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java index acee79f..6c716e1 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java @@ -5,22 +5,74 @@ import java.util.function.BiConsumer; import java.util.function.Supplier; +/** + * This Mapper Class is a high customizable implementation, allowing to define custom mappings from + * any exception to any ProblemDetails derivation. + *

+ * According to RFC 7807 a ProblemDetail consists at least of the fields: + *

    + *
  • type
  • + *
  • title
  • + *
  • status
  • + *
  • instance
  • + *
  • detail
  • + *
+ * If necessary additional fields can be added. + *

+ * For each of the fields this MapperClass contains a Lambda Consumer. + * This consumer takes the incoming exception and a problemDetail. + * It defines a function how to set the depending field in the problemDetail. + * The implementation might use the fields from the exception. + *

+ * For example the detailSetter might use the message from the exception and set it to the detail field + * in the problemDetails. + *

+ *

+ * For each exception that needs a mapping an own ExceptionMapper instance should be created. + * This should define how to map the given exception to the according ProblemDetails derivation. + *

+ * An inner builder is available to easily create ExceptionMappers. + * This builder defines certain defaults that make it easy to only specify the additional mapping definitions + *

    + *
  • A type can be set directly as variable and will be included in the problemDetails
  • + *
  • A title can be set directly as variable and will be included in the problemDetails
  • + *
  • A status can be set directly as variable and will be included in the problemDetails
  • + *
  • The instance creates a new random UUID
  • + *
  • The detail field will be set from the exception message
  • + *
  • Additional fields are not set at all
  • + *
+ * + * @param Mapping from the given {@link Throwable} + * @param To the given {@link ProblemDetailsTo} + */ public class ExceptionMapper { + /** + * Stores the class of the throwable, this way an easy mapping is possible when searching for the + * right mapper + */ private Class throwableClass; + /** + * A factory supplier is necessary to create the {@link ProblemDetailsTo} class. The factory is + * mandatory as it cannot be created otherwise at runtime (except for reflection, which we wanted + * to avoid here) A factory is usual the {@link ProblemDetailsTo} derivation added by `::new` for + * example: `ValidationProblemDetailsTo::new` + */ private Supplier factory; - private String type = "urn:problem:internal-server-error"; - private String title = "Internal Server Error"; - private Integer status = 500; - private BiConsumer typeSetter = (ex, problem) -> problem.setType(this.type); - private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); - private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( - UUID.randomUUID().toString()); - private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); - private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); + private BiConsumer typeSetter; + private BiConsumer titleSetter; + private BiConsumer instanceSetter; + private BiConsumer statusSetter; + private BiConsumer detailSetter; private BiConsumer additionalAttributeSetter; + /** + * Maps the exception to the {@link ProblemDetailsTo}. + * + * @param ex The exception that was thrown. + * @return A new {@link ProblemDetailsTo} (or inherited) instance + */ public TO map(FROM ex) { TO problemDetails = this.factory.get(); @@ -36,122 +88,191 @@ public TO map(FROM ex) { return problemDetails; } - public Supplier getFactory() { - return factory; - } - - public String getType() { - return type; - } - - public String getTitle() { - return title; - } - - public Integer getStatus() { - return status; - } - - public BiConsumer getTypeSetter() { - return typeSetter; - } - - public BiConsumer getTitleSetter() { - return titleSetter; - } - - public BiConsumer getInstanceSetter() { - return instanceSetter; - } - - public BiConsumer getStatusSetter() { - return statusSetter; - } - - public BiConsumer getDetailSetter() { - return detailSetter; - } - - public BiConsumer getAdditionalAttributeSetter() { - return additionalAttributeSetter; - } - public Class getThrowableClass() { return throwableClass; } public static final class ExceptionMapperBuilder { - private Class throwableClass; + private final Class throwableClass; - private Supplier factory; - private String type = "internal-server-error"; - private String title = "Internal Server Error"; + private final Supplier factory; + private String type = "urn:problem:internal-server-error"; + private String title = "An internal server error occurred"; private Integer status = 500; - private BiConsumer typeSetter = (ex, problem) -> problem.setType( - "urn:problem:" + this.type); - private BiConsumer titleSetter = (ex, problem) -> problem.setTitle(this.title); - private BiConsumer instanceSetter = (ex, problem) -> problem.setInstance( - "urn:uuid:" + UUID.randomUUID().toString()); - private BiConsumer statusSetter = (ex, problem) -> problem.setStatus(this.status); - private BiConsumer detailSetter = (ex, problem) -> problem.setDetail(ex.getMessage()); + private BiConsumer typeSetter; + private BiConsumer titleSetter; + private BiConsumer instanceSetter; + private BiConsumer statusSetter; + private BiConsumer detailSetter; private BiConsumer additionalAttributeSetter; - private ExceptionMapperBuilder(Class throwableClass) { + + public ExceptionMapperBuilder(Class throwableClass, Supplier problemDetailsFactory) { + this.factory = problemDetailsFactory; this.throwableClass = throwableClass; } + /** + * Create a Builder. + * + * @param throwableClass The Class of the {@link Throwable} that needs to be mapped. + * @param problemDetailsFactory A Supplier that creates a new instance of the + * {@link ProblemDetailsTo} or an inherited class. This supplier + * usually is the constructor call of the Class. + * @param The concrete {@link Throwable} + * @param

The concrete {@link ProblemDetailsTo} + * @return A new builder instance + */ public static ExceptionMapperBuilder builder( - Class throwableClass, Class

problemdetailsClazz) { - return new ExceptionMapperBuilder(throwableClass); + Class throwableClass, Supplier

problemDetailsFactory) { + return new ExceptionMapperBuilder(throwableClass, problemDetailsFactory); } - public ExceptionMapperBuilder withFactory(Supplier factory) { - this.factory = factory; - return this; - } + /** + * A comfort function that allows to easily change the type without defining a setter. A default + * setter uses this type field to set it in the problemDetails. + * + * @param type The type that wil be written into the ProblemDetails + * @return ExceptionMapperBuilder for chaining. + */ public ExceptionMapperBuilder withType(String type) { this.type = type; return this; } + /** + * A comfort function that allows to easily change the title without defining a setter. A + * default setter uses this title field to set it in the problemDetails. The title should + * describe the type in a human understandable manner in a static manner. The title should not + * name details that are specific for the instance of the error (like the id of a resource) + * + * @param title The title that wil be written into the ProblemDetails + * @return ExceptionMapperBuilder for chaining. + */ public ExceptionMapperBuilder withTitle(String title) { this.title = title; return this; } + /** + * A comfort function that allows to easily change the status without defining a setter. A + * default setter uses this status field to set it in the problemDetails. The status is the Http + * Status of the response. + * + * @param status The status that wil be written into the ProblemDetails + * @return ExceptionMapperBuilder for chaining. + */ public ExceptionMapperBuilder withStatus(Integer status) { this.status = status; return this; } + /** + * Define how to set types in the problemDetails. This lambda BiConsumer takes the original + * throwable and the newly created problemDetails. The problemDetails type field can then be set + * with static values or with information from the throwable. + * + *

+     *   ...
+     *   .withTypeSetter((throwable, problemDetails) -> {problemDetails.setType("urn:problem:not-found")})
+     *   .build()
+     * 
+ */ public ExceptionMapperBuilder withTypeSetter(BiConsumer typeSetter) { this.typeSetter = typeSetter; return this; } + /** + * Define how to set title in the problemDetails. This lambda BiConsumer takes the original + * throwable and the newly created problemDetails. The problemDetails title field can then be + * set with static values or with information from the throwable. + * + *
+     *   ...
+     *   .withTitleSetter((throwable, problemDetails) -> {problemDetails.setTitle("Resource not found")})
+     *   .build()
+     * 
+ */ public ExceptionMapperBuilder withTitleSetter(BiConsumer titleSetter) { this.titleSetter = titleSetter; return this; } + /** + * Define how to set the instance in the problemDetails. This lambda BiConsumer takes the + * original throwable and the newly created problemDetails. The problemDetails instance field + * can then be set with static values or with information from the throwable. + *

+ * As a default, a random UUID is generated. But it could also be used to map to the correlation + * id. Or anything that identifies the error instance. + * + *

+     *   ...
+     *    // Use the correlation id as instance
+     *   .withInstanceSetter((throwable, problemDetails) -> {problemDetails.setInstance(MDC.get("correlationId"))})
+     *   .build()
+     * 
+ */ public ExceptionMapperBuilder withInstanceSetter( BiConsumer instanceSetter) { this.instanceSetter = instanceSetter; return this; } + /** + * Define how to set the status in the problemDetails. This lambda BiConsumer takes the original + * throwable and the newly created problemDetails. The problemDetails status field can then be + * set with static values or with information from the throwable. + * + *
+     *   ...
+     *   .withStatusSetter((throwable, problemDetails) -> {problemDetails.setStatus(404)})
+     *   .build()
+     * 
+ */ public ExceptionMapperBuilder withStatusSetter(BiConsumer statusSetter) { this.statusSetter = statusSetter; return this; } + /** + * Define how to set the detail in the problemDetails. This lambda BiConsumer takes the original + * throwable and the newly created problemDetails. The problemDetails detail field can then be + * set with static values or with information from the throwable. + * + *
+     *   ...
+     *   .withDetailSetter((throwable, problemDetails) -> {problemDetails.setDetail(ex.getMessage())})
+     *   .build()
+     * 
+ */ public ExceptionMapperBuilder withDetailSetter(BiConsumer detailSetter) { this.detailSetter = detailSetter; return this; } + /** + * Define how to set the additionalFields in the problemDetails. This lambda BiConsumer takes + * the original throwable and the newly created problemDetails. The problemDetails + * additionalFields field can then be set with static values or with information from the + * throwable. + *

+ * By default, those are empty. The inherited class of problemDetailsTo needs to have the + * additionalFields. Like e.g. + * {@link com.devonfw.devon4j.generated.api.model.ValidationProblemDetailsTo} contains an String + * array for further details of the failed validation. + * + *

+     *   ...
+     *   // PseudoCode! the Validation Throwable does not necessarily have an easy usage of the reason list.
+     *   // That might need more implementation!
+     *   .withAdditionalAttributeSetter((throwable, problemDetails) -> {problemDetails.setFailedValidations(ex.getListOfValidationErrors())})
+     *   .build()
+     * 
+ */ public ExceptionMapperBuilder withAdditionalAttributeSetter( BiConsumer additionalAttributeSetter) { this.additionalAttributeSetter = additionalAttributeSetter; @@ -165,17 +286,31 @@ public ExceptionMapper build() { throw new IllegalArgumentException("Missing a factory for the ProblemDetails"); } ExceptionMapper problemDetailsFactoryHelper = new ExceptionMapper(); + problemDetailsFactoryHelper.factory = this.factory; problemDetailsFactoryHelper.throwableClass = this.throwableClass; - problemDetailsFactoryHelper.title = this.title; problemDetailsFactoryHelper.instanceSetter = this.instanceSetter; + if (this.instanceSetter == null) { + problemDetailsFactoryHelper.instanceSetter = (ex, problem) -> problem.setInstance( + "urn:uuid:" + UUID.randomUUID()); + } problemDetailsFactoryHelper.detailSetter = this.detailSetter; + if (this.detailSetter == null) { + problemDetailsFactoryHelper.detailSetter = (ex, problem) -> problem.setDetail( + ex.getMessage()); + } problemDetailsFactoryHelper.statusSetter = this.statusSetter; - problemDetailsFactoryHelper.status = this.status; + if (this.statusSetter == null) { + problemDetailsFactoryHelper.statusSetter = (ex, problem) -> problem.setStatus(this.status); + } problemDetailsFactoryHelper.titleSetter = this.titleSetter; + if (this.titleSetter == null) { + problemDetailsFactoryHelper.titleSetter = (ex, problem) -> problem.setTitle(this.title); + } problemDetailsFactoryHelper.typeSetter = this.typeSetter; + if (this.typeSetter == null) { + problemDetailsFactoryHelper.typeSetter = (ex, problem) -> problem.setType(this.type); + } problemDetailsFactoryHelper.additionalAttributeSetter = this.additionalAttributeSetter; - problemDetailsFactoryHelper.factory = this.factory; - problemDetailsFactoryHelper.type = this.type; return problemDetailsFactoryHelper; } } diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java index 5d117f8..f5a3a4b 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java @@ -2,60 +2,85 @@ import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; import com.devonfw.devon4j.generated.api.model.ValidationProblemDetailsTo; +import com.devonfw.java.integration.exceptionhandling.general.exception.BusinessException; import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; import com.devonfw.java.integration.exceptionhandling.service.exception.ExceptionMapper.ExceptionMapperBuilder; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import java.util.stream.Stream; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; +/** + * Contains all defined Mappers for an Exception to the corresponding ProblemDetailsTos + *

+ * In this class define all Mappers as constant with a good name to understand the purpose. + *

+ * Add them to the list at the bottom. A static Map will be created from that list for easier access + * of the right mapper for a given exception. + */ public abstract class ExceptionMappers { + /** + * Complex mapping to get the reason for the failure as list of string. Sadly the + * MethodArgumentNotValidException works a lot with wrappers making it hard to simply get the + * cause. This is just a showcase. Depending on the use case the message alone could be enough + */ + private static final BiConsumer VALIDATION_PROBLEM_DETAILS_BI_CONSUMER = + (ex, problem) -> { + List fields = ex.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getArguments) + .flatMap(Stream::of) + .filter(arg -> arg instanceof DefaultMessageSourceResolvable) + .map(arg -> (DefaultMessageSourceResolvable) arg) + .map(msg -> msg.getCode()) + .collect(Collectors.toList()); + problem.setFailedValidation(fields); + }; + private static final ExceptionMapper DEFAULT_EXCEPTION_MAPPER = - ExceptionMapperBuilder.builder(Throwable.class, - ProblemDetailsTo.class) - .withFactory(ProblemDetailsTo::new) + ExceptionMapperBuilder.builder(Throwable.class, ProblemDetailsTo::new) + .withDetailSetter(((throwable, problemDetailsTo) -> problemDetailsTo.setDetail( + "An unexpected error has occurred! We apologize any inconvenience. Please try again later."))) .build(); private static final ExceptionMapper VALIDATION_EXCEPTION_MAPPER = ExceptionMapperBuilder.builder( - MethodArgumentNotValidException.class, ValidationProblemDetailsTo.class) - .withFactory(ValidationProblemDetailsTo::new) - .withType("validation-error") + MethodArgumentNotValidException.class, ValidationProblemDetailsTo::new) + .withType("urn:problem:validation-error") .withTitle("A validation failed") .withStatus(HttpStatus.NOT_ACCEPTABLE.value()) .withDetailSetter((ex, problem) -> { problem.setDetail( "Validation failed for " + ex.getBindingResult().getErrorCount() + " fields"); }) - .withAdditionalAttributeSetter( - (ex, problem) -> { - List fields = ex.getBindingResult().getAllErrors().stream() - .map(DefaultMessageSourceResolvable::getArguments) - .flatMap(Stream::of) - .filter(arg -> arg instanceof DefaultMessageSourceResolvable) - .map(arg -> (DefaultMessageSourceResolvable) arg) - .map(msg -> msg.getCode()) - .collect(Collectors.toList()); - problem.setFailedValidation(fields); - } - ) + .withAdditionalAttributeSetter(VALIDATION_PROBLEM_DETAILS_BI_CONSUMER) // more complex mapping functions can be extracted + .build(); + private static final ExceptionMapper BUSINESS_DEFAULT_EXCEPTION_MAPPER = + ExceptionMapperBuilder.builder( + BusinessException.class, ProblemDetailsTo::new) + .withType("urn:problem:bad-request") + .withTitle("Bad Request") + .withStatus(HttpStatus.BAD_REQUEST.value()) .build(); private static final ExceptionMapper NOT_FOUND_EXCEPTION_MAPPER = ExceptionMapperBuilder.builder( - NotFoundException.class, ProblemDetailsTo.class) - .withFactory(ProblemDetailsTo::new) - .withType("not-found") + NotFoundException.class, ProblemDetailsTo::new) + .withType("urn:problem:not-found") .withTitle("Resource not found") .withStatus(HttpStatus.NOT_FOUND.value()) .build(); + /** + * Add all Mappers here! + */ private static final List> EXCEPTION_MAPPER_LIST = List.of( DEFAULT_EXCEPTION_MAPPER, + BUSINESS_DEFAULT_EXCEPTION_MAPPER, VALIDATION_EXCEPTION_MAPPER, NOT_FOUND_EXCEPTION_MAPPER ); diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java index 686d56f..8decb40 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java @@ -4,6 +4,9 @@ import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; +/** + * Mapstruct mapper for To to Model of the booking entity mapping. + */ @Mapper public interface BookingToMapper { BookingToMapper INSTANCE = Mappers.getMapper(BookingToMapper.class); diff --git a/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml b/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml index f4f805e..222efd2 100644 --- a/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml +++ b/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml @@ -32,23 +32,6 @@ paths: schema: $ref: '#/components/schemas/Booking' - get: - tags: - - "Booking" - summary: Get all Bookings - description: Returns a list of bookings - operationId: getBookingAll - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Booking' - - /booking/{bookingId}: get: tags: diff --git a/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java index 90f5fc9..1fe6dfe 100644 --- a/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java +++ b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java @@ -29,12 +29,15 @@ class BookingServiceTest { private int port; /** - * Given a bookingTo is provided to the createBooking endpoint When the bookingTo is using a - * SeatNumber greater than 20, which is invalid according to the OpenAPI spec Then a ProblemDetail - * is returned informing about the invalid fields + * Given a bookingTo is provided to the createBooking endpoint + *

+ * When the bookingTo is using a SeatNumber greater than 20, which is invalid according to the + * OpenAPI spec + *

+ * Then a ProblemDetail is returned informing about the invalid fields */ @Test - void validationError() throws IOException, InterruptedException { + void testValidationError() throws IOException, InterruptedException { final int expectedStatusCode = HttpStatus.SC_NOT_ACCEPTABLE; BookingTo booking = BookingToBuilder.aBookingTo() .withNumberOfSeats(21) @@ -61,22 +64,116 @@ void validationError() throws IOException, InterruptedException { assertEquals(mapper.readTree(json), mapper.readTree(body)); } + /** + * When an exception is thrown, it's next mapped superclass should be mapped. This is tested using + * the{@link com.devonfw.java.integration.exceptionhandling.general.exception.OverBookedException} + * (not explicitly mapped) and the + * {@link com.devonfw.java.integration.exceptionhandling.general.exception.BusinessException} that + * is mapped. + *

+ * Given a booking should be created. + *

+ * When an + * {@link com.devonfw.java.integration.exceptionhandling.general.exception.OverBookedException} is + * thrown + *

+ * Then it should be mapped implicitly to a + * {@link com.devonfw.devon4j.generated.api.model.ProblemDetailsTo} using the abstract + * {@link com.devonfw.java.integration.exceptionhandling.general.exception.BusinessException} + */ + @Test + void testBusinessException() throws IOException, InterruptedException { + final int expectedStatusCode = HttpStatus.SC_BAD_REQUEST; + BookingTo booking = BookingToBuilder.aBookingTo() + .withNumberOfSeats(12) + .build(); + var response = client.send(createBooking(booking), BodyHandlers.ofString()); + int actualStatusCode = response.statusCode(); + assertEquals(expectedStatusCode, actualStatusCode); + var body = response.body(); + var json = """ + { + "_schema":"ProblemDetails", + "type": "urn:problem:bad-request", + "title": "Bad Request", + "status": %d, + "detail": "Sadly there's no free table at the moment", + "instance": "%s" + } + """; + String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); + json = String.format(json, expectedStatusCode, actualInstanceUuid); + assertEquals(mapper.readTree(json), mapper.readTree(body)); + } + + /** + * Test the explicit mapping of a business exception. The expectation is that a + * {@link com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException} + * (child of + * {@link com.devonfw.java.integration.exceptionhandling.general.exception.BusinessException} is + * thrown and the correct mapper is used to map it to a + * {@link com.devonfw.devon4j.generated.api.model.ProblemDetailsTo} using the right messages. + *

+ * Given a Booking with id 404 should be returned + *

+ * When this is not available + *

+ * Then a {@link com.devonfw.devon4j.generated.api.model.ProblemDetailsTo} is returned describing + * the error. + */ @Test void testNotFoundException() throws IOException, InterruptedException { + final int expectedStatusCode = HttpStatus.SC_NOT_FOUND; var response = client.send(getBookingHttpRequest(NOT_FOUND_BOOKING_ID), BodyHandlers.ofString()); + int actualStatusCode = response.statusCode(); + assertEquals(expectedStatusCode, actualStatusCode); var body = response.body(); var json = """ { "_schema":"ProblemDetails", "type": "urn:problem:not-found", "title": "Resource not found", - "status": 404, + "status": %d, "detail": "The element of id 404 could not be found", "instance": "%s" } """; - json = String.format(json, mapper.readTree(body).at("/instance").asText()); + String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); + json = String.format(json, expectedStatusCode, actualInstanceUuid); + assertEquals(mapper.readTree(json), mapper.readTree(body)); + } + + /** + * Test the scenario of a not explicitly mapped technical exception. + *

+ * Given a Booking should be returned without an id provided + *

+ * When no booking id is provided + *

+ * Then an exception is thrown and should be mapped to a generic internal server error exception + * not exposing technical details. + */ + @Test + void testInvalidArgumentException() throws IOException, InterruptedException { + final int expectedStatusCode = HttpStatus.SC_INTERNAL_SERVER_ERROR; + var response = client.send(getBookingHttpRequest(null), + BodyHandlers.ofString()); + int actualStatusCode = response.statusCode(); + assertEquals(expectedStatusCode, actualStatusCode); + var body = response.body(); + var json = """ + { + "_schema":"ProblemDetails", + "type": "urn:problem:internal-server-error", + "title": "An internal server error occurred", + "status": %d, + "detail": "An unexpected error has occurred! We apologize any inconvenience. Please try again later.", + "instance": "%s" + } + """; + String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); + json = String.format(json, expectedStatusCode, actualInstanceUuid); assertEquals(mapper.readTree(json), mapper.readTree(body)); } @@ -88,7 +185,7 @@ private HttpRequest createBooking(BookingTo booking) throws JsonProcessingExcept .uri(URI.create(uri)).build(); } - private HttpRequest getBookingHttpRequest(long id) { + private HttpRequest getBookingHttpRequest(Long id) { String uri = "http://localhost:" + port + "/booking/" + id; return HttpRequest.newBuilder() .GET() From 65774e0baec6e2d50bbb485e1dc0b0e2111fa6e3 Mon Sep 17 00:00:00 2001 From: Fabian Baumeister Date: Wed, 9 Nov 2022 15:30:27 +0100 Subject: [PATCH 4/4] Change ExceptionMapper to use definition Functions instead of setter The Setters in the exceptionMappers directly put values into the ProblemDetailsTo. This can be considered a code smell, because the ProblemDetailsTo is changed hidden as a reference. Therefore, now Functions are used, that return the value that should be added to the ProblemDetailsTo. The map method created the ProblemDetailsTo and adds the values from the Functions. --- .../domain/BookingManagement.java | 28 ++--- .../service/BookingService.java | 7 +- .../exception/CustomExceptionHandler.java | 13 +- .../service/exception/ExceptionMapper.java | 118 ++++++++++-------- ...s.java => ExceptionMapperDefinitions.java} | 12 +- .../service/BookingServiceTest.java | 65 ++++++++-- 6 files changed, 142 insertions(+), 101 deletions(-) rename integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/{ExceptionMappers.java => ExceptionMapperDefinitions.java} (86%) diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java index a796412..dd40b14 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java @@ -1,7 +1,9 @@ package com.devonfw.java.integration.exceptionhandling.domain; +import com.devonfw.devon4j.generated.api.model.BookingTo; import com.devonfw.java.integration.exceptionhandling.domain.model.Booking; import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; +import org.apache.commons.lang3.NotImplementedException; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; @@ -11,27 +13,11 @@ @Component public class BookingManagement { - /** - * Returns a concrete Booking for a given id. - * Because this is an example implementation, always a new Booking is created and returned using the given id. - * - * @param id The id of the booking as identifier. - * @return The booking with the given id. - * @throws NotFoundException when the given id is 404 (HTTP not found ;-) ) then a NotFoundException is thrown. - * @throws IllegalArgumentException when the id parameter is not set. - */ public Booking getBooking(Long id) { - if (ObjectUtils.isEmpty(id)) { - throw new IllegalArgumentException("No id was defined, when calling getBooking()"); - } - if(id == 404L) { - throw new NotFoundException("The element of id " + id + " could not be found"); - } - return Booking.builder() - .id(id) - .email("m.mustermann@mail.com") - .description("No gluten") - .numberOfSeats(2) - .build(); + throw new NotImplementedException("Not implemented. The class only exists for mocking reasons in Unit Test"); + } + + public Booking createBooking(BookingTo bookingTo) { + throw new NotImplementedException("Not implemented. The class only exists for mocking reasons in Unit Test"); } } diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java index f39dac4..5bcd5e2 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java @@ -13,6 +13,8 @@ @RestController public class BookingService implements com.devonfw.devon4j.generated.api.service.BookingApi { + + public static final BookingToMapper TO_MAPPER = BookingToMapper.INSTANCE; private BookingManagement bookingManagement; public BookingService(BookingManagement bookingManagement) { @@ -25,12 +27,11 @@ public Optional getRequest() { @Override public ResponseEntity createBooking(BookingTo bookingTo) { - // Throwing this exception always, when the validation is ok - throw new OverBookedException("Sadly there's no free table at the moment"); + return ResponseEntity.accepted().body(TO_MAPPER.map(bookingManagement.createBooking(bookingTo))); } @Override public ResponseEntity getBookingById(Long bookingId) { - return ResponseEntity.accepted().body(BookingToMapper.INSTANCE.map(bookingManagement.getBooking(bookingId))); + return ResponseEntity.accepted().body(TO_MAPPER.map(bookingManagement.getBooking(bookingId))); } } diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java index 369c1c1..63824ba 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java @@ -1,9 +1,9 @@ package com.devonfw.java.integration.exceptionhandling.service.exception; -import static com.devonfw.java.integration.exceptionhandling.service.exception.ExceptionMappers.EXCEPTION_ERRORS_MAP; +import static com.devonfw.java.integration.exceptionhandling.service.exception.ExceptionMapperDefinitions.DEFAULT_EXCEPTION_MAPPER; +import static com.devonfw.java.integration.exceptionhandling.service.exception.ExceptionMapperDefinitions.EXCEPTION_ERRORS_MAP; import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; -import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -40,12 +40,6 @@ public ResponseEntity catchThrowable(Throwable ex) { ExceptionMapper mapper = EXCEPTION_ERRORS_MAP.get( exceptionClazz); - if(! Throwable.class.isAssignableFrom(exceptionClazz)) { - // This should never happen. - // If this happens something is wrong in the implementation and should be checked. - log.warn("Class {} was thrown, but not element of Throwable", exceptionClazz); - } - // Check if an ExceptionMapper exists that is applicable. // This should in the last instance always be Throwable while (Throwable.class.isAssignableFrom(exceptionClazz) && mapper == null) { @@ -57,7 +51,8 @@ public ResponseEntity catchThrowable(Throwable ex) { // defining a default. // But this should also never happen, as every exception should be derivable from Throwable. // Anyway as we're in the exception mapping phase, we're implementing it paranoid. - mapper = EXCEPTION_ERRORS_MAP.get(Throwable.class); + log.warn("Could not find an accurate exception mapper for {}", ex.getClass()); + mapper = DEFAULT_EXCEPTION_MAPPER; } return mapper; diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java index 6c716e1..508ec9a 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java @@ -3,6 +3,7 @@ import com.devonfw.devon4j.generated.api.model.ProblemDetailsTo; import java.util.UUID; import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.function.Supplier; /** @@ -19,9 +20,9 @@ * * If necessary additional fields can be added. *

- * For each of the fields this MapperClass contains a Lambda Consumer. + * For each of the fields this MapperClass contains a Lambda Function. * This consumer takes the incoming exception and a problemDetail. - * It defines a function how to set the depending field in the problemDetail. + * It defines a function how to set the corresponding field in the problemDetail. * The implementation might use the fields from the exception. *

* For example the detailSetter might use the message from the exception and set it to the detail field @@ -60,11 +61,24 @@ public class ExceptionMapper factory; - private BiConsumer typeSetter; - private BiConsumer titleSetter; - private BiConsumer instanceSetter; - private BiConsumer statusSetter; - private BiConsumer detailSetter; + private Function type; + private Function title; + private Function instance; + private Function status; + private Function details; + + /** + * Additional fields are one or more attributes on the specialized class inherited from + * ProblemDetailsTo Therefore, a BICOnsumer is necessary. It's expected that the BiConsumer sets + * the corresponding field in the ProblemDetailsTo directly + * + *

+   *   additionalFieldsSetter = (ex, problem) -> {
+   *     problem.setFailedValidations(ex.getFailedValidationList);
+   *     problem.setFurtherField(ex.furtherInformation);
+   *    }
+   * 
+ */ private BiConsumer additionalAttributeSetter; /** @@ -76,13 +90,17 @@ public class ExceptionMapper typeSetter; - private BiConsumer titleSetter; - private BiConsumer instanceSetter; - private BiConsumer statusSetter; - private BiConsumer detailSetter; + private Function typeDefinition; + private Function titleDefinition; + private Function instanceDefinition; + private Function statusDefinition; + private Function detailDefinition; private BiConsumer additionalAttributeSetter; @@ -176,12 +194,12 @@ public ExceptionMapperBuilder withStatus(Integer status) { * *
      *   ...
-     *   .withTypeSetter((throwable, problemDetails) -> {problemDetails.setType("urn:problem:not-found")})
+     *   .withTypeDefinition((throwable, problemDetails) -> {problemDetails.setType("urn:problem:not-found")})
      *   .build()
      * 
*/ - public ExceptionMapperBuilder withTypeSetter(BiConsumer typeSetter) { - this.typeSetter = typeSetter; + public ExceptionMapperBuilder withTypeDefinition(Function typeDefinition) { + this.typeDefinition = typeDefinition; return this; } @@ -192,12 +210,12 @@ public ExceptionMapperBuilder withTypeSetter(BiConsumer type * *
      *   ...
-     *   .withTitleSetter((throwable, problemDetails) -> {problemDetails.setTitle("Resource not found")})
+     *   .withTitleDefinition((throwable, problemDetails) -> {problemDetails.setTitle("Resource not found")})
      *   .build()
      * 
*/ - public ExceptionMapperBuilder withTitleSetter(BiConsumer titleSetter) { - this.titleSetter = titleSetter; + public ExceptionMapperBuilder withTitleDefinition(Function titleDefinition) { + this.titleDefinition = titleDefinition; return this; } @@ -212,13 +230,13 @@ public ExceptionMapperBuilder withTitleSetter(BiConsumer tit *
      *   ...
      *    // Use the correlation id as instance
-     *   .withInstanceSetter((throwable, problemDetails) -> {problemDetails.setInstance(MDC.get("correlationId"))})
+     *   .withInstanceDefinition((throwable, problemDetails) -> {problemDetails.setInstance(MDC.get("correlationId"))})
      *   .build()
      * 
*/ - public ExceptionMapperBuilder withInstanceSetter( - BiConsumer instanceSetter) { - this.instanceSetter = instanceSetter; + public ExceptionMapperBuilder withInstanceDefinition( + Function instanceDefinition) { + this.instanceDefinition = instanceDefinition; return this; } @@ -229,12 +247,12 @@ public ExceptionMapperBuilder withInstanceSetter( * *
      *   ...
-     *   .withStatusSetter((throwable, problemDetails) -> {problemDetails.setStatus(404)})
+     *   .withStatusDefinition((throwable, problemDetails) -> {problemDetails.setStatus(404)})
      *   .build()
      * 
*/ - public ExceptionMapperBuilder withStatusSetter(BiConsumer statusSetter) { - this.statusSetter = statusSetter; + public ExceptionMapperBuilder withStatusDefinition(Function statusDefinition) { + this.statusDefinition = statusDefinition; return this; } @@ -245,12 +263,12 @@ public ExceptionMapperBuilder withStatusSetter(BiConsumer st * *
      *   ...
-     *   .withDetailSetter((throwable, problemDetails) -> {problemDetails.setDetail(ex.getMessage())})
+     *   .withDetailDefinition((throwable, problemDetails) -> {problemDetails.setDetail(ex.getMessage())})
      *   .build()
      * 
*/ - public ExceptionMapperBuilder withDetailSetter(BiConsumer detailSetter) { - this.detailSetter = detailSetter; + public ExceptionMapperBuilder withDetailDefinition(Function detailDefinition) { + this.detailDefinition = detailDefinition; return this; } @@ -285,30 +303,28 @@ public ExceptionMapper build() { // Otherwise, the correct ProblemDetailsTo could not be initialized by the mapper. throw new IllegalArgumentException("Missing a factory for the ProblemDetails"); } - ExceptionMapper problemDetailsFactoryHelper = new ExceptionMapper(); + ExceptionMapper problemDetailsFactoryHelper = new ExceptionMapper<>(); problemDetailsFactoryHelper.factory = this.factory; problemDetailsFactoryHelper.throwableClass = this.throwableClass; - problemDetailsFactoryHelper.instanceSetter = this.instanceSetter; - if (this.instanceSetter == null) { - problemDetailsFactoryHelper.instanceSetter = (ex, problem) -> problem.setInstance( - "urn:uuid:" + UUID.randomUUID()); + problemDetailsFactoryHelper.instance = this.instanceDefinition; + if (this.instanceDefinition == null) { + problemDetailsFactoryHelper.instance = (ex) -> "urn:uuid:" + UUID.randomUUID(); } - problemDetailsFactoryHelper.detailSetter = this.detailSetter; - if (this.detailSetter == null) { - problemDetailsFactoryHelper.detailSetter = (ex, problem) -> problem.setDetail( - ex.getMessage()); + problemDetailsFactoryHelper.details = this.detailDefinition; + if (this.detailDefinition == null) { + problemDetailsFactoryHelper.details = Throwable::getMessage; } - problemDetailsFactoryHelper.statusSetter = this.statusSetter; - if (this.statusSetter == null) { - problemDetailsFactoryHelper.statusSetter = (ex, problem) -> problem.setStatus(this.status); + problemDetailsFactoryHelper.status = this.statusDefinition; + if (this.statusDefinition == null) { + problemDetailsFactoryHelper.status = (ex) -> this.status; } - problemDetailsFactoryHelper.titleSetter = this.titleSetter; - if (this.titleSetter == null) { - problemDetailsFactoryHelper.titleSetter = (ex, problem) -> problem.setTitle(this.title); + problemDetailsFactoryHelper.title = this.titleDefinition; + if (this.titleDefinition == null) { + problemDetailsFactoryHelper.title = (ex) -> this.title; } - problemDetailsFactoryHelper.typeSetter = this.typeSetter; - if (this.typeSetter == null) { - problemDetailsFactoryHelper.typeSetter = (ex, problem) -> problem.setType(this.type); + problemDetailsFactoryHelper.type = this.typeDefinition; + if (this.typeDefinition == null) { + problemDetailsFactoryHelper.type = (ex) -> this.type; } problemDetailsFactoryHelper.additionalAttributeSetter = this.additionalAttributeSetter; return problemDetailsFactoryHelper; diff --git a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapperDefinitions.java similarity index 86% rename from integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java rename to integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapperDefinitions.java index f5a3a4b..d193f8d 100644 --- a/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMappers.java +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapperDefinitions.java @@ -22,7 +22,7 @@ * Add them to the list at the bottom. A static Map will be created from that list for easier access * of the right mapper for a given exception. */ -public abstract class ExceptionMappers { +public abstract class ExceptionMapperDefinitions { /** * Complex mapping to get the reason for the failure as list of string. Sadly the @@ -41,10 +41,9 @@ public abstract class ExceptionMappers { problem.setFailedValidation(fields); }; - private static final ExceptionMapper DEFAULT_EXCEPTION_MAPPER = + public static final ExceptionMapper DEFAULT_EXCEPTION_MAPPER = ExceptionMapperBuilder.builder(Throwable.class, ProblemDetailsTo::new) - .withDetailSetter(((throwable, problemDetailsTo) -> problemDetailsTo.setDetail( - "An unexpected error has occurred! We apologize any inconvenience. Please try again later."))) + .withDetailDefinition((throwable) -> "An unexpected error has occurred! We apologize any inconvenience. Please try again later.") .build(); private static final ExceptionMapper VALIDATION_EXCEPTION_MAPPER = @@ -53,10 +52,7 @@ public abstract class ExceptionMappers { .withType("urn:problem:validation-error") .withTitle("A validation failed") .withStatus(HttpStatus.NOT_ACCEPTABLE.value()) - .withDetailSetter((ex, problem) -> { - problem.setDetail( - "Validation failed for " + ex.getBindingResult().getErrorCount() + " fields"); - }) + .withDetailDefinition((ex) -> "Validation failed for " + ex.getBindingResult().getErrorCount() + " fields") .withAdditionalAttributeSetter(VALIDATION_PROBLEM_DETAILS_BI_CONSUMER) // more complex mapping functions can be extracted .build(); private static final ExceptionMapper BUSINESS_DEFAULT_EXCEPTION_MAPPER = diff --git a/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java index 1fe6dfe..c2d67b6 100644 --- a/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java +++ b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java @@ -3,6 +3,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import com.devonfw.devon4j.generated.api.model.BookingTo; +import com.devonfw.java.integration.exceptionhandling.domain.BookingManagement; +import com.devonfw.java.integration.exceptionhandling.general.exception.NotFoundException; +import com.devonfw.java.integration.exceptionhandling.general.exception.OverBookedException; import com.devonfw.java.integration.exceptionhandling.testdata.to.BookingToBuilder; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,18 +18,21 @@ import org.apache.http.HttpStatus; import org.apache.http.entity.ContentType; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.server.LocalServerPort; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class BookingServiceTest { private final static ObjectMapper mapper = new ObjectMapper(); - private static final long NOT_FOUND_BOOKING_ID = 404L; private final HttpClient client = HttpClient.newBuilder().build(); @LocalServerPort private int port; + @MockBean + private BookingManagement mockedManagement; /** * Given a bookingTo is provided to the createBooking endpoint @@ -38,14 +44,23 @@ class BookingServiceTest { */ @Test void testValidationError() throws IOException, InterruptedException { + // No mock of the BookingManagement needed here, because the validation is done earlier. + final int expectedStatusCode = HttpStatus.SC_NOT_ACCEPTABLE; + // Given: Invalid Booking (21 seats > 20 max)) BookingTo booking = BookingToBuilder.aBookingTo() .withNumberOfSeats(21) .build(); + + // When: Calling the REST interface var response = client.send(createBooking(booking), BodyHandlers.ofString()); int actualStatusCode = response.statusCode(); + // Then: an error is returned assertEquals(expectedStatusCode, actualStatusCode); - var body = response.body(); + + // A json is expected. + // The instance is a generated UUID and cannot be predicted + // The status is filled with the variable expectedStatusCode later. var json = """ { "_schema":"ValidationProblemDetails", @@ -59,6 +74,7 @@ void testValidationError() throws IOException, InterruptedException { ] } """; + var body = response.body(); String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); json = String.format(json, expectedStatusCode, actualInstanceUuid); assertEquals(mapper.readTree(json), mapper.readTree(body)); @@ -83,14 +99,24 @@ void testValidationError() throws IOException, InterruptedException { */ @Test void testBusinessException() throws IOException, InterruptedException { - final int expectedStatusCode = HttpStatus.SC_BAD_REQUEST; + + // Given a valid Booking BookingTo booking = BookingToBuilder.aBookingTo() .withNumberOfSeats(12) .build(); + // When an overbooked exception is thrown + Mockito.when(mockedManagement.createBooking(Mockito.any())) + .thenThrow( + new OverBookedException("Sadly there's no free table at the moment")); var response = client.send(createBooking(booking), BodyHandlers.ofString()); + + // Then the response contains the error details + final int expectedStatusCode = HttpStatus.SC_BAD_REQUEST; int actualStatusCode = response.statusCode(); assertEquals(expectedStatusCode, actualStatusCode); - var body = response.body(); + // A json is expected. + // The instance is a generated UUID and cannot be predicted + // The status is filled with the variable expectedStatusCode later. var json = """ { "_schema":"ProblemDetails", @@ -101,6 +127,7 @@ void testBusinessException() throws IOException, InterruptedException { "instance": "%s" } """; + var body = response.body(); String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); json = String.format(json, expectedStatusCode, actualInstanceUuid); assertEquals(mapper.readTree(json), mapper.readTree(body)); @@ -123,12 +150,24 @@ void testBusinessException() throws IOException, InterruptedException { */ @Test void testNotFoundException() throws IOException, InterruptedException { - final int expectedStatusCode = HttpStatus.SC_NOT_FOUND; - var response = client.send(getBookingHttpRequest(NOT_FOUND_BOOKING_ID), + // Given a booking id of a non-existing booking + final long not_found_booking_id = 404L; + + Mockito.when(mockedManagement.getBooking(not_found_booking_id)) + .thenThrow(new NotFoundException( + "The element of id " + not_found_booking_id + " could not be found")); + + // When the booking should be returned + var response = client.send(getBookingHttpRequest(not_found_booking_id), BodyHandlers.ofString()); + + // Then a not-found problem is returned + final int expectedStatusCode = HttpStatus.SC_NOT_FOUND; int actualStatusCode = response.statusCode(); assertEquals(expectedStatusCode, actualStatusCode); - var body = response.body(); + // A json is expected. + // The instance is a generated UUID and cannot be predicted + // The status is filled with the variable expectedStatusCode later. var json = """ { "_schema":"ProblemDetails", @@ -139,6 +178,7 @@ void testNotFoundException() throws IOException, InterruptedException { "instance": "%s" } """; + var body = response.body(); String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); json = String.format(json, expectedStatusCode, actualInstanceUuid); assertEquals(mapper.readTree(json), mapper.readTree(body)); @@ -156,12 +196,18 @@ void testNotFoundException() throws IOException, InterruptedException { */ @Test void testInvalidArgumentException() throws IOException, InterruptedException { - final int expectedStatusCode = HttpStatus.SC_INTERNAL_SERVER_ERROR; + + // When a request is done with unspecified id var response = client.send(getBookingHttpRequest(null), BodyHandlers.ofString()); + + // Then some error is returned as internal-server-error + final int expectedStatusCode = HttpStatus.SC_INTERNAL_SERVER_ERROR; int actualStatusCode = response.statusCode(); assertEquals(expectedStatusCode, actualStatusCode); - var body = response.body(); + // A json is expected. + // The instance is a generated UUID and cannot be predicted + // The status is filled with the variable expectedStatusCode later. var json = """ { "_schema":"ProblemDetails", @@ -172,6 +218,7 @@ void testInvalidArgumentException() throws IOException, InterruptedException { "instance": "%s" } """; + var body = response.body(); String actualInstanceUuid = mapper.readTree(body).at("/instance").asText(); json = String.format(json, expectedStatusCode, actualInstanceUuid); assertEquals(mapper.readTree(json), mapper.readTree(body));