From 99832d0d4d26eb406fd14144ab41841daaa85be1 Mon Sep 17 00:00:00 2001 From: Thiago dos Santos Hora Date: Tue, 9 Jan 2024 23:28:17 +0100 Subject: [PATCH] BAEL-7125: Global Exception Handling with Spring Cloud Gateway (#15566) * BAEL-7125: Global Exception Handling with Spring Cloud Gateway * Fix name * Fix condition --- parent-spring-6/pom.xml | 2 + pom.xml | 2 + spring-6/api-gateway/pom.xml | 100 ++++++++++ .../CustomGlobalExceptionHandler.java | 70 +++++++ .../CustomRequestAuthException.java | 7 + .../java/com/baeldung/errorhandling/Main.java | 75 ++++++++ .../errorhandling/MyCustomFilter.java | 29 +++ .../errorhandling/MyGlobalFilter.java | 27 +++ .../RateLimitRequestException.java | 7 + .../src/main/resources/application.properties | 12 ++ .../src/main/resources/logback.xml | 13 ++ .../baeldung/errorhandling/RouteUnitTest.java | 174 ++++++++++++++++++ spring-6/pom.xml | 26 +++ 13 files changed, 544 insertions(+) create mode 100644 spring-6/api-gateway/pom.xml create mode 100644 spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomGlobalExceptionHandler.java create mode 100644 spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomRequestAuthException.java create mode 100644 spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/Main.java create mode 100644 spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyCustomFilter.java create mode 100644 spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyGlobalFilter.java create mode 100644 spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/RateLimitRequestException.java create mode 100644 spring-6/api-gateway/src/main/resources/application.properties create mode 100644 spring-6/api-gateway/src/main/resources/logback.xml create mode 100644 spring-6/api-gateway/src/test/java/com/baeldung/errorhandling/RouteUnitTest.java create mode 100644 spring-6/pom.xml diff --git a/parent-spring-6/pom.xml b/parent-spring-6/pom.xml index 7418c019c4..4665030baf 100644 --- a/parent-spring-6/pom.xml +++ b/parent-spring-6/pom.xml @@ -36,6 +36,8 @@ 6.1.2 + 2023.0.0 + 3.2.1 diff --git a/pom.xml b/pom.xml index 5b87978a86..e0e9629b98 100644 --- a/pom.xml +++ b/pom.xml @@ -8,6 +8,7 @@ parent-modules 1.0.0-SNAPSHOT parent-modules + pom @@ -410,6 +411,7 @@ parent-spring-6 spring-4 + spring-6 spring-cloud-modules diff --git a/spring-6/api-gateway/pom.xml b/spring-6/api-gateway/pom.xml new file mode 100644 index 0000000000..0245d2d610 --- /dev/null +++ b/spring-6/api-gateway/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + + com.baeldung + parent-spring-6 + 0.0.1-SNAPSHOT + ../../parent-spring-6 + + + api-gateway + api-gateway + jar + 1.0.0-SNAPSHOT + + + 17 + 17 + UTF-8 + UTF-8 + 1.4.14 + 2.0.9 + + + + + + org.springframework.cloud + spring-cloud-starter + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + org.springframework.cloud + spring-cloud-starter-contract-stub-runner + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.projectreactor + reactor-test + test + + + + ch.qos.logback + logback-core + ${logback.version} + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + + \ No newline at end of file diff --git a/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomGlobalExceptionHandler.java b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomGlobalExceptionHandler.java new file mode 100644 index 0000000000..1289d20a19 --- /dev/null +++ b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomGlobalExceptionHandler.java @@ -0,0 +1,70 @@ +package com.baeldung.errorhandling; + +import org.springframework.boot.autoconfigure.web.WebProperties; +import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.reactive.error.ErrorAttributes; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@Component +@Order(Integer.MIN_VALUE) +class CustomGlobalExceptionHandler extends AbstractErrorWebExceptionHandler { + + public CustomGlobalExceptionHandler(final ErrorAttributes errorAttributes, + final WebProperties.Resources resources, + final ApplicationContext applicationContext, + final ServerCodecConfigurer configurer) { + super(errorAttributes, resources, applicationContext); + setMessageReaders(configurer.getReaders()); + setMessageWriters(configurer.getWriters()); + } + + @Override + protected RouterFunction getRoutingFunction(ErrorAttributes errorAttributes) { + return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse); + } + + private Mono renderErrorResponse(ServerRequest request) { + + ErrorAttributeOptions options = ErrorAttributeOptions.of(ErrorAttributeOptions.Include.MESSAGE); + Map errorPropertiesMap = getErrorAttributes(request, options); + Throwable throwable = getError(request); + HttpStatusCode httpStatus = determineHttpStatus(throwable); + + errorPropertiesMap.put("status", httpStatus.value()); + errorPropertiesMap.remove("error"); + + return ServerResponse.status(httpStatus) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .body(BodyInserters.fromObject(errorPropertiesMap)); + } + + private HttpStatusCode determineHttpStatus(Throwable throwable) { + + if (throwable instanceof ResponseStatusException) { + return ((ResponseStatusException) throwable).getStatusCode(); + } else if (throwable instanceof CustomRequestAuthException) { + return HttpStatus.UNAUTHORIZED; + } else if (throwable instanceof RateLimitRequestException) { + return HttpStatus.TOO_MANY_REQUESTS; + } else { + return HttpStatus.INTERNAL_SERVER_ERROR; + } + } +} diff --git a/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomRequestAuthException.java b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomRequestAuthException.java new file mode 100644 index 0000000000..114601640d --- /dev/null +++ b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/CustomRequestAuthException.java @@ -0,0 +1,7 @@ +package com.baeldung.errorhandling; + +public class CustomRequestAuthException extends RuntimeException { + public CustomRequestAuthException(String message) { + super(message); + } +} diff --git a/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/Main.java b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/Main.java new file mode 100644 index 0000000000..606b44939c --- /dev/null +++ b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/Main.java @@ -0,0 +1,75 @@ +package com.baeldung.errorhandling; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.web.WebProperties; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl; + +@SpringBootApplication +public class Main { + + @Bean + public WebProperties.Resources resources() { + return new WebProperties.Resources(); + } + + @Bean + public RouteLocator customRouteLocator(RouteLocatorBuilder builder, + @Value("${httpbin}") String httpbin, + MyCustomFilter myCustomFilter) { + return builder.routes() + .route("error_404", r -> r.path("/test/error_404") + .filters(f -> f.filter(myCustomFilter).filter((exchange, chain) -> switchRequestUri(exchange, chain, "error_404", "404"))) + .uri(httpbin + "/status/404")) + .route("error_500", r -> r.path("/test/error_500") + .filters(f -> f.filter(myCustomFilter).filter((exchange, chain) -> switchRequestUri(exchange, chain, "error_500", "500"))) + .uri(httpbin+"/status/500")) + .route("error_400", r -> r.path("/test/error_400") + .filters(f -> f.filter(myCustomFilter).filter((exchange, chain) -> switchRequestUri(exchange, chain, "error_400", "400"))) + .uri(httpbin+"/status/400")) + .route("error_409", r -> r.path("/test/error_409") + .filters(f -> f.filter(myCustomFilter).filter((exchange, chain) -> switchRequestUri(exchange, chain, "error_409", "409"))) + .uri(httpbin+"/status/409")) + .route("custom_rate_limit", r -> r.path("/test/custom_rate_limit").filters(f -> f.filter(myCustomFilter)).uri(httpbin+"/uuid")) + .route("custom_auth", r -> r.path("/test/custom_auth").filters(f -> f.filter(myCustomFilter)).uri(httpbin+"/api/custom_auth")) + .route("anything", r -> r.path("/test/anything") + .filters(f -> f.changeRequestUri((exchange) -> Optional.of(UriComponentsBuilder.fromUri(exchange.getRequest().getURI()) + .host("httpbin.org") + .port(80) + .replacePath("/anything").build().toUri()))) + .uri("http://httpbin.org")) + .build(); + + + } + + private Mono switchRequestUri(ServerWebExchange exchange, + GatewayFilterChain chain, + String externalUri, + String internalUri) { + ServerHttpRequest req = exchange.getRequest(); + addOriginalRequestUrl(exchange, req.getURI()); + String path = req.getURI().getRawPath(); + String newPath = path.replaceAll("/test/" + externalUri, "/status/" + internalUri); + ServerHttpRequest request = req.mutate().path(newPath).build(); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, request.getURI()); + return chain.filter(exchange.mutate().request(request).build()); + } + + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } +} \ No newline at end of file diff --git a/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyCustomFilter.java b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyCustomFilter.java new file mode 100644 index 0000000000..ef26e886b3 --- /dev/null +++ b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyCustomFilter.java @@ -0,0 +1,29 @@ +package com.baeldung.errorhandling; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class MyCustomFilter implements GatewayFilter { + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + + if (isAuthRoute(exchange) && !isAuthorization(exchange)) { + throw new CustomRequestAuthException("Not authorized"); + } + + return chain.filter(exchange); + } + + private static boolean isAuthorization(ServerWebExchange exchange) { + return exchange.getRequest().getHeaders().containsKey("Authorization"); + } + + private static boolean isAuthRoute(ServerWebExchange exchange) { + return exchange.getRequest().getURI().getPath().equals("/test/custom_auth"); + } +} \ No newline at end of file diff --git a/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyGlobalFilter.java b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyGlobalFilter.java new file mode 100644 index 0000000000..f365170d1d --- /dev/null +++ b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/MyGlobalFilter.java @@ -0,0 +1,27 @@ +package com.baeldung.errorhandling; + +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +class MyGlobalFilter implements GlobalFilter { + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + + if (hasReachedRateLimit(exchange)) { + throw new RateLimitRequestException("Too many requests"); + } + + return chain.filter(exchange); + } + + private boolean hasReachedRateLimit(ServerWebExchange exchange) { + // Simulates the rate limit being reached + return exchange.getRequest().getURI().getPath().contains("/test/custom_rate_limit") && (!exchange.getRequest().getHeaders().containsKey("X-RateLimit-Remaining") || + Integer.parseInt(exchange.getRequest().getHeaders().getFirst("X-RateLimit-Remaining")) <= 0); + } +} diff --git a/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/RateLimitRequestException.java b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/RateLimitRequestException.java new file mode 100644 index 0000000000..2ec21facad --- /dev/null +++ b/spring-6/api-gateway/src/main/java/com/baeldung/errorhandling/RateLimitRequestException.java @@ -0,0 +1,7 @@ +package com.baeldung.errorhandling; + +public class RateLimitRequestException extends RuntimeException { + public RateLimitRequestException(String message) { + super(message); + } +} diff --git a/spring-6/api-gateway/src/main/resources/application.properties b/spring-6/api-gateway/src/main/resources/application.properties new file mode 100644 index 0000000000..2b5719b6a0 --- /dev/null +++ b/spring-6/api-gateway/src/main/resources/application.properties @@ -0,0 +1,12 @@ +spring.application.name: api-gateway +spring.main.web-application-type=reactive +eureka.instance.hostname=localhost +spring.cloud.discovery.enabled=true +#eureka.instance.prefer-ip-address=true +httpbin=http://httpbin.org + +#### DEBUG #### +#logging.level.org.springframework.cloud.gateway=DEBUG +#spring.cloud.gateway.httpclient.wiretap=true +#spring.cloud.gateway.httpserver.wiretap=true +#logging.level.reactor.netty=DEBUG \ No newline at end of file diff --git a/spring-6/api-gateway/src/main/resources/logback.xml b/spring-6/api-gateway/src/main/resources/logback.xml new file mode 100644 index 0000000000..f653adabfd --- /dev/null +++ b/spring-6/api-gateway/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg %kvp%n + + + + + + + + \ No newline at end of file diff --git a/spring-6/api-gateway/src/test/java/com/baeldung/errorhandling/RouteUnitTest.java b/spring-6/api-gateway/src/test/java/com/baeldung/errorhandling/RouteUnitTest.java new file mode 100644 index 0000000000..9b43626e74 --- /dev/null +++ b/spring-6/api-gateway/src/test/java/com/baeldung/errorhandling/RouteUnitTest.java @@ -0,0 +1,174 @@ +package com.baeldung.errorhandling; + +import com.github.tomakehurst.wiremock.http.Body; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.BodyInserters; + +import java.nio.charset.StandardCharsets; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; + + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"httpbin=http://localhost:${wiremock.server.port}/api"} +) +@AutoConfigureWireMock(port = 0) +class RouteUnitTest { + + @Value("${httpbin}") + private String httpbin; + + @Value(value="${local.server.port}") + private int port; + + private WebTestClient webTestClient; + + @BeforeEach + void before() { + webTestClient = WebTestClient + .bindToServer() + .baseUrl("http://localhost:" + port + "/test") + .build(); + } + + @Test + void whenRouteResponseStatusCodeIs404_thenApiGatewayShouldHandleResponse() { + stubFor(post(urlEqualTo("/status/404")) + .willReturn( + aResponse() + .withStatus(404) + .withResponseBody(Body.fromJsonBytes("{\"code\": 404, \"reason\": \"Not Found\"}".getBytes(StandardCharsets.UTF_8))) + )); + + webTestClient.post() + .uri("/error_404") + .accept(MediaType.valueOf("application/json")) + .contentType(MediaType.valueOf("application/json")) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.code").isEqualTo(404) + .jsonPath("$.reason").isEqualTo("Not Found"); + } + + @Test + void whenRouteResponseStatusCodeIs400_thenApiGatewayShouldHandleResponse() { + stubFor(post(urlEqualTo("/status/400")) + .willReturn( + aResponse() + .withStatus(400) + .withResponseBody(Body.fromJsonBytes("{\"code\": 400, \"reason\": \"Bad Request\"}".getBytes(StandardCharsets.UTF_8))) + )); + + webTestClient.post() + .uri("/error_400") + .accept(MediaType.valueOf("application/json")) + .contentType(MediaType.valueOf("application/json")) + .exchange() + .expectStatus().isBadRequest() + .expectBody() + .jsonPath("$.code").isEqualTo(400) + .jsonPath("$.reason").isEqualTo("Bad Request"); + } + + @Test + void whenRouteResponseStatusCodeIs409_thenApiGatewayShouldHandleResponse() { + stubFor(post(urlEqualTo("/status/409")) + .willReturn( + aResponse() + .withStatus(409) + .withResponseBody(Body.fromJsonBytes("{\"code\": 409, \"reason\": \"Conflict\"}".getBytes(StandardCharsets.UTF_8))) + )); + + webTestClient.post() + .uri("/error_409") + .accept(MediaType.valueOf("application/json")) + .contentType(MediaType.valueOf("application/json")) + .exchange() + .expectStatus().isEqualTo(409) + .expectBody() + .jsonPath("$.code").isEqualTo(409) + .jsonPath("$.reason").isEqualTo("Conflict"); + } + + @Test + void whenRouteResponseStatusCodeIs500_thenApiGatewayShouldHandleResponse() { + stubFor(post(urlEqualTo("/status/500")) + .willReturn( + aResponse() + .withStatus(500) + .withResponseBody(Body.fromJsonBytes("{\"code\": 500, \"reason\": \"Error\"}".getBytes(StandardCharsets.UTF_8))) + )); + + webTestClient.post() + .uri("/error_500") + .accept(MediaType.valueOf("application/json")) + .contentType(MediaType.valueOf("application/json")) + .exchange() + .expectStatus().isEqualTo(500) + .expectBody() + .jsonPath("$.code").isEqualTo(500) + .jsonPath("$.reason").isEqualTo("Error"); + } + + @Test + void whenRouteAnythingRequest_thenApiGatewayShouldHandleResponse() { + webTestClient.post() + .uri("/anything") + .contentType(MediaType.valueOf("application/json")) + .body(BodyInserters.fromValue("{\"code\": 500, \"reason\": \"Error\"}".getBytes(StandardCharsets.UTF_8))) + .accept(MediaType.valueOf("application/json")) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data").isEqualTo("{\"code\": 500, \"reason\": \"Error\"}"); + } + + @Test + void whenRouteCustomRequestAuth_thenApiGatewayShouldHandleResponse() { + webTestClient.post() + .uri("/custom_auth") + .contentType(MediaType.valueOf("application/json")) + .body(BodyInserters.fromValue("{\"test\": \"test\", \"message\": \"test\"}".getBytes(StandardCharsets.UTF_8))) + .accept(MediaType.valueOf("application/json")) + .exchange() + .expectStatus() + .isUnauthorized() + .expectBody() + .jsonPath("$.path").isNotEmpty() + .jsonPath("$.message").isEqualTo("Not authorized") + .jsonPath("$.status").isEqualTo(401) + .jsonPath("$.requestId").isNotEmpty() + .jsonPath("$.timestamp").isNotEmpty(); + } + + @Test + void whenRouteCustomRequestRateLimit_thenApiGatewayShouldHandleResponse() { + webTestClient.post() + .uri("/custom_rate_limit") + .contentType(MediaType.valueOf("application/json")) + .body(BodyInserters.fromValue("{\"test\": \"test\", \"message\": \"test\"}".getBytes(StandardCharsets.UTF_8))) + .accept(MediaType.valueOf("application/json")) + .exchange() + .expectStatus() + .isEqualTo(429) + .expectBody() + .jsonPath("$.path").isNotEmpty() + .jsonPath("$.message").isEqualTo("Too many requests") + .jsonPath("$.status").isEqualTo(429) + .jsonPath("$.requestId").isNotEmpty() + .jsonPath("$.timestamp").isNotEmpty(); + } + +} diff --git a/spring-6/pom.xml b/spring-6/pom.xml new file mode 100644 index 0000000000..2ba668a4fe --- /dev/null +++ b/spring-6/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + com.baeldung + parent-spring-6 + ../parent-spring-6 + 0.0.1-SNAPSHOT + + + spring-6 + spring-6 + pom + + + 17 + 17 + UTF-8 + + + + api-gateway + + \ No newline at end of file