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..da8073b --- /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.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' + 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 0000000..249e583 Binary files /dev/null and b/integration/exception-handling/gradle/wrapper/gradle-wrapper.jar differ diff --git a/integration/exception-handling/gradle/wrapper/gradle-wrapper.properties b/integration/exception-handling/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/integration/exception-handling/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/integration/exception-handling/gradlew b/integration/exception-handling/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/integration/exception-handling/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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/devonfw/java/integration/exceptionhandling/DemoApplication.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/DemoApplication.java new file mode 100644 index 0000000..07ef367 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/DemoApplication.java @@ -0,0 +1,13 @@ +package com.devonfw.java.integration.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/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java new file mode 100644 index 0000000..dd40b14 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/BookingManagement.java @@ -0,0 +1,23 @@ +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; + +/** + * Manages Bookings for our restaurant. + */ +@Component +public class BookingManagement { + + public Booking getBooking(Long id) { + 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/domain/model/Booking.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java new file mode 100644 index 0000000..21905c2 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/domain/model/Booking.java @@ -0,0 +1,20 @@ +package com.devonfw.java.integration.exceptionhandling.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Represents the booking of a table in our restaurant example. + */ +@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/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 new file mode 100644 index 0000000..0142295 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/general/exception/NotFoundException.java @@ -0,0 +1,15 @@ +package com.devonfw.java.integration.exceptionhandling.general.exception; + +/** + * 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 new file mode 100644 index 0000000..5bcd5e2 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/BookingService.java @@ -0,0 +1,37 @@ +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; +import java.util.List; +import java.util.Optional; +import org.springframework.http.ResponseEntity; +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 { + + public static final BookingToMapper TO_MAPPER = BookingToMapper.INSTANCE; + 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(TO_MAPPER.map(bookingManagement.createBooking(bookingTo))); + } + + @Override + public ResponseEntity getBookingById(Long 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 new file mode 100644 index 0000000..63824ba --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/CustomExceptionHandler.java @@ -0,0 +1,61 @@ +package com.devonfw.java.integration.exceptionhandling.service.exception; + +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 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 { + + /** + * 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); + + // 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. + 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 new file mode 100644 index 0000000..508ec9a --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapper.java @@ -0,0 +1,333 @@ +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.Function; +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 Function. + * This consumer takes the incoming exception and a 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 + * 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 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; + + /** + * 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(); + problemDetails.setType(type.apply(ex)); + problemDetails.setTitle(title.apply(ex)); + problemDetails.setDetail(details.apply(ex)); + problemDetails.setStatus(status.apply(ex)); + problemDetails.setInstance(instance.apply(ex)); + + if (this.additionalAttributeSetter != null) { + // Here the additionalFields are set on the problemDetails + // It can be argued that this is a code smell, because the lambda changes the + // parameter as call by reference. Sadly there's no generic way to handle this, because the + // attributes in problemDetails depend on the inherited class and are not known here. + this.additionalAttributeSetter.accept(ex, problemDetails); + } + return problemDetails; + } + + public Class getThrowableClass() { + return throwableClass; + } + + public static final class ExceptionMapperBuilder { + + private final Class throwableClass; + + 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 Function typeDefinition; + private Function titleDefinition; + private Function instanceDefinition; + private Function statusDefinition; + private Function detailDefinition; + private BiConsumer additionalAttributeSetter; + + + 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, Supplier

problemDetailsFactory) { + return new ExceptionMapperBuilder(throwableClass, problemDetailsFactory); + } + + + /** + * 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. + * + *

+     *   ...
+     *   .withTypeDefinition((throwable, problemDetails) -> {problemDetails.setType("urn:problem:not-found")})
+     *   .build()
+     * 
+ */ + public ExceptionMapperBuilder withTypeDefinition(Function typeDefinition) { + this.typeDefinition = typeDefinition; + 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. + * + *
+     *   ...
+     *   .withTitleDefinition((throwable, problemDetails) -> {problemDetails.setTitle("Resource not found")})
+     *   .build()
+     * 
+ */ + public ExceptionMapperBuilder withTitleDefinition(Function titleDefinition) { + this.titleDefinition = titleDefinition; + 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
+     *   .withInstanceDefinition((throwable, problemDetails) -> {problemDetails.setInstance(MDC.get("correlationId"))})
+     *   .build()
+     * 
+ */ + public ExceptionMapperBuilder withInstanceDefinition( + Function instanceDefinition) { + this.instanceDefinition = instanceDefinition; + 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. + * + *
+     *   ...
+     *   .withStatusDefinition((throwable, problemDetails) -> {problemDetails.setStatus(404)})
+     *   .build()
+     * 
+ */ + public ExceptionMapperBuilder withStatusDefinition(Function statusDefinition) { + this.statusDefinition = statusDefinition; + 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. + * + *
+     *   ...
+     *   .withDetailDefinition((throwable, problemDetails) -> {problemDetails.setDetail(ex.getMessage())})
+     *   .build()
+     * 
+ */ + public ExceptionMapperBuilder withDetailDefinition(Function detailDefinition) { + this.detailDefinition = detailDefinition; + 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; + 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.factory = this.factory; + problemDetailsFactoryHelper.throwableClass = this.throwableClass; + problemDetailsFactoryHelper.instance = this.instanceDefinition; + if (this.instanceDefinition == null) { + problemDetailsFactoryHelper.instance = (ex) -> "urn:uuid:" + UUID.randomUUID(); + } + problemDetailsFactoryHelper.details = this.detailDefinition; + if (this.detailDefinition == null) { + problemDetailsFactoryHelper.details = Throwable::getMessage; + } + problemDetailsFactoryHelper.status = this.statusDefinition; + if (this.statusDefinition == null) { + problemDetailsFactoryHelper.status = (ex) -> this.status; + } + problemDetailsFactoryHelper.title = this.titleDefinition; + if (this.titleDefinition == null) { + problemDetailsFactoryHelper.title = (ex) -> this.title; + } + 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/ExceptionMapperDefinitions.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapperDefinitions.java new file mode 100644 index 0000000..d193f8d --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/exception/ExceptionMapperDefinitions.java @@ -0,0 +1,88 @@ +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.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 ExceptionMapperDefinitions { + + /** + * 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); + }; + + public static final ExceptionMapper DEFAULT_EXCEPTION_MAPPER = + ExceptionMapperBuilder.builder(Throwable.class, ProblemDetailsTo::new) + .withDetailDefinition((throwable) -> "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::new) + .withType("urn:problem:validation-error") + .withTitle("A validation failed") + .withStatus(HttpStatus.NOT_ACCEPTABLE.value()) + .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 = + 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::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 + ); + + + 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/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java new file mode 100644 index 0000000..8decb40 --- /dev/null +++ b/integration/exception-handling/src/main/java/com/devonfw/java/integration/exceptionhandling/service/mapper/BookingToMapper.java @@ -0,0 +1,15 @@ +package com.devonfw.java.integration.exceptionhandling.service.mapper; + +import com.devonfw.java.integration.exceptionhandling.domain.model.Booking; +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); + + 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..222efd2 --- /dev/null +++ b/integration/exception-handling/src/main/openapi/BookingOpenApi.yaml @@ -0,0 +1,141 @@ +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' + + /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/devonfw/java/integration/exceptionhandling/DemoApplicationTests.java b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/DemoApplicationTests.java new file mode 100644 index 0000000..9f4adf3 --- /dev/null +++ b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/DemoApplicationTests.java @@ -0,0 +1,13 @@ +package com.devonfw.java.integration.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/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..c2d67b6 --- /dev/null +++ b/integration/exception-handling/src/test/java/com/devonfw/java/integration/exceptionhandling/service/BookingServiceTest.java @@ -0,0 +1,242 @@ +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.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; +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.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 final HttpClient client = HttpClient.newBuilder().build(); + @LocalServerPort + private int port; + @MockBean + private BookingManagement mockedManagement; + + /** + * 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 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); + + // 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", + "type": "urn:problem:validation-error", + "title": "A validation failed", + "status": %d, + "detail": "Validation failed for 1 fields", + "instance": "%s", + "failedValidation": [ + "numberOfSeats" + ] + } + """; + 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)); + } + + /** + * 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 { + + // 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); + // 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", + "type": "urn:problem:bad-request", + "title": "Bad Request", + "status": %d, + "detail": "Sadly there's no free table at the moment", + "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)); + } + + /** + * 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 { + // 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); + // 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", + "type": "urn:problem:not-found", + "title": "Resource not found", + "status": %d, + "detail": "The element of id 404 could not be found", + "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)); + } + + /** + * 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 { + + // 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); + // 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", + "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" + } + """; + 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)); + } + + 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; + } +}