BAEL-7125: Global Exception Handling with Spring Cloud Gateway (#15566)

* BAEL-7125: Global Exception Handling with Spring Cloud Gateway

* Fix name

* Fix condition
This commit is contained in:
Thiago dos Santos Hora 2024-01-09 23:28:17 +01:00 committed by GitHub
parent a94bf971f5
commit 99832d0d4d
13 changed files with 544 additions and 0 deletions

View File

@ -36,6 +36,8 @@
<properties>
<spring.version>6.1.2</spring.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
<spring-boot.version>3.2.1</spring-boot.version>
</properties>
</project>

View File

@ -8,6 +8,7 @@
<artifactId>parent-modules</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>parent-modules</name>
<packaging>pom</packaging>
<dependencies>
@ -410,6 +411,7 @@
<module>parent-spring-6</module>
<module>spring-4</module>
<module>spring-6</module>
<module>spring-cloud-modules</module>
<!-- <module>spring-cloud-cli</module> --> <!-- Not a maven project -->

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baeldung</groupId>
<artifactId>parent-spring-6</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../parent-spring-6</relativePath>
</parent>
<artifactId>api-gateway</artifactId>
<name>api-gateway</name>
<packaging>jar</packaging>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<source.encoding>UTF-8</source.encoding>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<logback.version>1.4.14</logback.version>
<slf4j.version>2.0.9</slf4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>${logback.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<!-- Import dependency management from Spring Boot -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
</project>

View File

@ -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<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
}
private Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
ErrorAttributeOptions options = ErrorAttributeOptions.of(ErrorAttributeOptions.Include.MESSAGE);
Map<String, Object> 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;
}
}
}

View File

@ -0,0 +1,7 @@
package com.baeldung.errorhandling;
public class CustomRequestAuthException extends RuntimeException {
public CustomRequestAuthException(String message) {
super(message);
}
}

View File

@ -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<Void> 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);
}
}

View File

@ -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<Void> 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");
}
}

View File

@ -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<Void> 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);
}
}

View File

@ -0,0 +1,7 @@
package com.baeldung.errorhandling;
public class RateLimitRequestException extends RuntimeException {
public RateLimitRequestException(String message) {
super(message);
}
}

View File

@ -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

View File

@ -0,0 +1,13 @@
<configuration>
<appender name="out" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg %kvp%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="out"/>
</root>
</configuration>

View File

@ -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();
}
}

26
spring-6/pom.xml Normal file
View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baeldung</groupId>
<artifactId>parent-spring-6</artifactId>
<relativePath>../parent-spring-6</relativePath>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>spring-6</artifactId>
<name>spring-6</name>
<packaging>pom</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<modules>
<module>api-gateway</module>
</modules>
</project>