From e7d266a6f83ed5e91f49bd677f22d6ffd88f6c79 Mon Sep 17 00:00:00 2001 From: Emmanuel Durin Date: Mon, 27 May 2024 16:18:36 -0300 Subject: [PATCH] Allow custom error pages to be used when an application behind the gateway returns an error. `GatewayFilterFactory` providing a `GatewayFilter` that throws a `ResponseStatusException` with the proxied response status code if the target responded with a `400` or `500` status code. Usage: to enable it globally, add this to application.yaml : ``` spring: cloud: gateway: default-filters: - ApplicationError ``` To enable it only on some routes, add this to concerned routes in `routes.yaml`: ``` filters: - name: ApplicationError ``` --- docs/custom-error-pages.adoc | 22 ++++ .../app/FiltersAutoConfiguration.java | 7 +- .../ApplicationErrorGatewayFilterFactory.java | 64 +++++++++++ ...licationErrorGatewayFilterFactoryTest.java | 103 ++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactoryTest.java diff --git a/docs/custom-error-pages.adoc b/docs/custom-error-pages.adoc index d3d1f42c..fa531e26 100644 --- a/docs/custom-error-pages.adoc +++ b/docs/custom-error-pages.adoc @@ -29,3 +29,25 @@ spring: 3. In the datadir create a folder from the root directory: "gateway/templates/error" 4. Place your error page files named as per the status code. For example for 404: 404.html 5. Restart georchestra gateway. + +== Using custom error pages for applications errors + +Custom error pages can also be used when an application behind the gateways returns an error. + +To enable it globally, add this to application.yaml : +[application.yaml] +---- +spring: + cloud: + gateway: + default-filters: + - ApplicationError +---- + +To enable it only on some routes, add this to concerned routes in routes.yaml : +[routes.yaml] +---- + filters: + - name: ApplicationError +---- + diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java index 4c2d19b4..8a1db74c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java @@ -20,6 +20,7 @@ import org.georchestra.gateway.filter.global.ResolveTargetGlobalFilter; import org.georchestra.gateway.filter.headers.HeaderFiltersConfiguration; +import org.georchestra.gateway.filter.global.ApplicationErrorGatewayFilterFactory; import org.georchestra.gateway.model.GatewayConfigProperties; import org.georchestra.gateway.model.GeorchestraTargetConfig; import org.geoserver.cloud.gateway.filter.RouteProfileGatewayFilterFactory; @@ -44,7 +45,7 @@ public class FiltersAutoConfiguration { * matched Route's GeorchestraTargetConfig for each HTTP request-response * interaction before other filters are applied. */ - public @Bean ResolveTargetGlobalFilter resolveTargetWebFilter(GatewayConfigProperties config) { + @Bean ResolveTargetGlobalFilter resolveTargetWebFilter(GatewayConfigProperties config) { return new ResolveTargetGlobalFilter(config); } @@ -64,4 +65,8 @@ public class FiltersAutoConfiguration { public @Bean StripBasePathGatewayFilterFactory stripBasePathGatewayFilterFactory() { return new StripBasePathGatewayFilterFactory(); } + + @Bean ApplicationErrorGatewayFilterFactory applicationErrorGatewayFilterFactory() { + return new ApplicationErrorGatewayFilterFactory(); + } } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java new file mode 100644 index 00000000..d35dbeb7 --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.filter.global; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * {@link GatewayFilterFactory} providing a {@link GatewayFilter} that throws a + * {@link ResponseStatusException} with the proxied response status code if the + * target responded with a {@code 400...} or {@code 500...} status code. + * + */ +public class ApplicationErrorGatewayFilterFactory extends AbstractGatewayFilterFactory { + + public ApplicationErrorGatewayFilterFactory() { + super(Object.class); + } + + @Override + public GatewayFilter apply(final Object config) { + return new ServiceErrorGatewayFilter(); + } + + private static class ServiceErrorGatewayFilter implements GatewayFilter, Ordered { + + public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + return chain.filter(exchange).then(Mono.fromRunnable(() -> { + HttpStatus statusCode = exchange.getResponse().getStatusCode(); + if (statusCode.is4xxClientError() || statusCode.is5xxServerError()) { + throw new ResponseStatusException(statusCode); + } + })); + } + + @Override + public int getOrder() { + return ResolveTargetGlobalFilter.ORDER + 1; + } + } +} diff --git a/gateway/src/test/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactoryTest.java b/gateway/src/test/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactoryTest.java new file mode 100644 index 00000000..7d0ef066 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactoryTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.filter.global; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR; + +import java.net.URI; +import java.util.List; + +import org.georchestra.gateway.model.HeaderMappings; +import org.georchestra.gateway.model.RoleBasedAccessRule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.route.Route; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ResponseStatusException; + +import reactor.core.publisher.Mono; + +class ApplicationErrorGatewayFilterFactoryTest { + + + private GatewayFilterChain chain; + private GatewayFilter filter; + private MockServerWebExchange exchange; + + final URI matchedURI = URI.create("http://fake.backend.com:8080"); + private Route matchedRoute; + + HeaderMappings defaultHeaders; + List defaultRules; + + @BeforeEach + void setUp() throws Exception { + var factory = new ApplicationErrorGatewayFilterFactory(); + filter = factory.apply(factory.newConfig()); + + matchedRoute = mock(Route.class); + when(matchedRoute.getUri()).thenReturn(matchedURI); + + chain = mock(GatewayFilterChain.class); + when(chain.filter(any())).thenReturn(Mono.empty()); + MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); + exchange = MockServerWebExchange.from(request); + exchange.getAttributes().put(GATEWAY_ROUTE_ATTR, matchedRoute); + } + + @Test + void testNotAnErrorResponse() { + exchange.getResponse().setStatusCode(HttpStatus.OK); + Mono result = filter.filter(exchange, chain); + result.block(); + assertThat(exchange.getResponse().getRawStatusCode()).isEqualTo(200); + } + + @Test + void test4xx() { + testApplicationError(HttpStatus.BAD_REQUEST); + testApplicationError(HttpStatus.UNAUTHORIZED); + testApplicationError(HttpStatus.FORBIDDEN); + testApplicationError(HttpStatus.NOT_FOUND); + } + + + @Test + void test5xx() { + testApplicationError(HttpStatus.INTERNAL_SERVER_ERROR); + testApplicationError(HttpStatus.SERVICE_UNAVAILABLE); + testApplicationError(HttpStatus.BAD_GATEWAY); + } + + private void testApplicationError(HttpStatus status) { + exchange.getResponse().setStatusCode(status); + Mono result = filter.filter(exchange, chain); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, ()-> result.block()); + assertThat(ex.getStatus()).isEqualTo(status); + } +}