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