diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index d29f7a2a..b061ce2f 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -2850,6 +2850,37 @@ Note that the `null` value is due to an incomplete implementation of the endpoin To clear the routes cache, make a `POST` request to `/actuator/gateway/refresh`. The request returns a 200 without a response body. +To clear the routes with specific metadata values, add the Query parameter `metadata` specifying the `key:value` pairs that the routes to be cleared should match. +If an error is produced during the asynchronous refresh, the refresh will not modify the existing routes. + +Sending `POST` request to `/actuator/gateway/refresh?metadata=group:group-1` will only refresh the routes whose `group` metadata is `group-1`: `first_route` and `third_route`. +==== +[source,json] +---- +[{ + "route_id": "first_route", + "route_object": { + "predicate": "...", + }, + "metadata": { "group": "group-1" } +}, +{ + "route_id": "second_route", + "route_object": { + "predicate": "...", + }, + "metadata": { "group": "group-2" } +}, +{ + "route_id": "third_route", + "route_object": { + "predicate": "...", + }, + "metadata": { "group": "group-1" } +}] +---- +==== + === Retrieving the Routes Defined in the Gateway To retrieve the routes defined in the gateway, make a `GET` request to `/actuator/gateway/routes`. diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/actuate/AbstractGatewayControllerEndpoint.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/actuate/AbstractGatewayControllerEndpoint.java index 194d984a..3e1d81da 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/actuate/AbstractGatewayControllerEndpoint.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/actuate/AbstractGatewayControllerEndpoint.java @@ -19,6 +19,7 @@ package org.springframework.cloud.gateway.actuate; import java.net.URI; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -43,12 +44,14 @@ import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.server.ResponseStatusException; /** @@ -93,11 +96,28 @@ public class AbstractGatewayControllerEndpoint implements ApplicationEventPublis // TODO: Add uncommited or new but not active routes endpoint @PostMapping("/refresh") - public Mono refresh() { - this.publisher.publishEvent(new RefreshRoutesEvent(this)); + public Mono refresh(@RequestParam(value = "metadata", required = false) List byMetadata) { + publishRefreshEvent(byMetadata); return Mono.empty(); } + private void publishRefreshEvent(List byMetadata) { + RefreshRoutesEvent event; + if (!CollectionUtils.isEmpty(byMetadata)) { + event = new RefreshRoutesEvent(this, convertToMap(byMetadata)); + } + else { + event = new RefreshRoutesEvent(this); + } + + this.publisher.publishEvent(event); + } + + private Map convertToMap(List byMetadata) { + return byMetadata.stream().map(keyValueStr -> keyValueStr.split(":")) + .collect(Collectors.toMap(kv -> kv[0], kv -> kv.length > 1 ? kv[1] : null)); + } + @GetMapping("/globalfilters") public Mono> globalfilters() { return getNamesToOrders(this.globalFilters); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/event/RefreshRoutesEvent.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/event/RefreshRoutesEvent.java index 2f688406..bd1dd720 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/event/RefreshRoutesEvent.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/event/RefreshRoutesEvent.java @@ -16,19 +16,43 @@ package org.springframework.cloud.gateway.event; +import java.util.Map; + import org.springframework.context.ApplicationEvent; +import org.springframework.util.CollectionUtils; /** * @author Spencer Gibb */ public class RefreshRoutesEvent extends ApplicationEvent { + private final Map metadata; + /** * Create a new ApplicationEvent. * @param source the object on which the event initially occurred (never {@code null}) */ public RefreshRoutesEvent(Object source) { + this(source, null); + } + + /** + * Create a new ApplicationEvent that should refresh filtering by {@link #metadata}. + * @param source the object on which the event initially occurred (never {@code null}) + * @param metadata map of metadata the routes should match ({code null} is considered + * a global refresh) + */ + public RefreshRoutesEvent(Object source, Map metadata) { super(source); + this.metadata = metadata; + } + + public boolean isScoped() { + return !CollectionUtils.isEmpty(getMetadata()); + } + + public Map getMetadata() { + return metadata; } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java index 8c250a21..301e1e4c 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java @@ -25,6 +25,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import reactor.cache.CacheFlux; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import org.springframework.cloud.gateway.event.RefreshRoutesEvent; import org.springframework.cloud.gateway.event.RefreshRoutesResultEvent; @@ -61,6 +62,10 @@ public class CachingRouteLocator return this.delegate.getRoutes().sort(AnnotationAwareOrderComparator.INSTANCE); } + private Flux fetch(Map metadata) { + return this.delegate.getRoutesByMetadata(metadata).sort(AnnotationAwareOrderComparator.INSTANCE); + } + @Override public Flux getRoutes() { return this.routes; @@ -71,24 +76,45 @@ public class CachingRouteLocator * @return routes flux */ public Flux refresh() { - this.cache.clear(); + this.cache.remove(CACHE_KEY); return this.routes; } @Override public void onApplicationEvent(RefreshRoutesEvent event) { try { - fetch().collect(Collectors.toList()).subscribe( - list -> Flux.fromIterable(list).materialize().collect(Collectors.toList()).subscribe(signals -> { - applicationEventPublisher.publishEvent(new RefreshRoutesResultEvent(this)); - cache.put(CACHE_KEY, signals); - }, this::handleRefreshError), this::handleRefreshError); + if (this.cache.containsKey(CACHE_KEY) && event.isScoped()) { + final Mono> scopedRoutes = fetch(event.getMetadata()).collect(Collectors.toList()) + .onErrorResume(s -> Mono.just(List.of())); + + scopedRoutes.subscribe(scopedRoutesList -> { + Flux.concat(Flux.fromIterable(scopedRoutesList), getNonScopedRoutes(event)).materialize() + .collect(Collectors.toList()).subscribe(signals -> { + applicationEventPublisher.publishEvent(new RefreshRoutesResultEvent(this)); + cache.put(CACHE_KEY, signals); + }, this::handleRefreshError); + }, this::handleRefreshError); + } + else { + final Mono> allRoutes = fetch().collect(Collectors.toList()); + + allRoutes.subscribe(list -> Flux.fromIterable(list).materialize().collect(Collectors.toList()) + .subscribe(signals -> { + applicationEventPublisher.publishEvent(new RefreshRoutesResultEvent(this)); + cache.put(CACHE_KEY, signals); + }, this::handleRefreshError), this::handleRefreshError); + } } catch (Throwable e) { handleRefreshError(e); } } + private Flux getNonScopedRoutes(RefreshRoutesEvent scopedEvent) { + return this.getRoutes() + .filter(route -> !RouteLocator.matchMetadata(route.getMetadata(), scopedEvent.getMetadata())); + } + private void handleRefreshError(Throwable throwable) { if (log.isErrorEnabled()) { log.error("Refresh routes error !!!", throwable); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CompositeRouteLocator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CompositeRouteLocator.java index ff72a45a..60208571 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CompositeRouteLocator.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/CompositeRouteLocator.java @@ -16,6 +16,8 @@ package org.springframework.cloud.gateway.route; +import java.util.Map; + import reactor.core.publisher.Flux; /** @@ -34,4 +36,9 @@ public class CompositeRouteLocator implements RouteLocator { return this.delegates.flatMapSequential(RouteLocator::getRoutes); } + @Override + public Flux getRoutesByMetadata(Map metadata) { + return this.delegates.flatMapSequential(routeLocator -> routeLocator.getRoutesByMetadata(metadata)); + } + } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteDefinitionRouteLocator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteDefinitionRouteLocator.java index 0159c56a..f9d3aead 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteDefinitionRouteLocator.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteDefinitionRouteLocator.java @@ -91,9 +91,23 @@ public class RouteDefinitionRouteLocator implements RouteLocator { }); } + /** + * Filtering is done via {@link RouteDefinition} instead of {@link Route} to prevent + * creating Route instances that will be discarded. + */ + @Override + public Flux getRoutesByMetadata(Map metadata) { + return getRoutes(this.routeDefinitionLocator.getRouteDefinitions() + .filter(routeDef -> RouteLocator.matchMetadata(routeDef.getMetadata(), metadata))); + } + @Override public Flux getRoutes() { - Flux routes = this.routeDefinitionLocator.getRouteDefinitions().map(this::convertToRoute); + return getRoutes(this.routeDefinitionLocator.getRouteDefinitions()); + } + + private Flux getRoutes(Flux routeDefinitions) { + Flux routes = routeDefinitions.map(this::convertToRoute); if (!gatewayProperties.isFailOnRouteDefinitionError()) { // instead of letting error bubble up, continue diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteLocator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteLocator.java index d09cfb89..3b1812ce 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteLocator.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/RouteLocator.java @@ -16,8 +16,12 @@ package org.springframework.cloud.gateway.route; +import java.util.Map; + import reactor.core.publisher.Flux; +import org.springframework.util.CollectionUtils; + /** * @author Spencer Gibb */ @@ -26,4 +30,23 @@ public interface RouteLocator { Flux getRoutes(); + /** + * Gets routes whose {@link Route#getId()} matches with any of the ids passed by + * parameters. If an ID cannot be found, it will not return a route for that ID. + */ + default Flux getRoutesByMetadata(Map metadata) { + return getRoutes().filter(route -> matchMetadata(route.getMetadata(), metadata)); + } + + static boolean matchMetadata(Map toCheck, Map expectedMetadata) { + if (CollectionUtils.isEmpty(expectedMetadata)) { + return true; + } + else { + return toCheck != null + && expectedMetadata.entrySet().stream().allMatch(keyValue -> toCheck.containsKey(keyValue.getKey()) + && toCheck.get(keyValue.getKey()).equals(keyValue.getValue())); + } + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/actuate/GatewayControllerEndpointTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/actuate/GatewayControllerEndpointTests.java index 918dfe1a..d17d4d35 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/actuate/GatewayControllerEndpointTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/actuate/GatewayControllerEndpointTests.java @@ -163,6 +163,150 @@ public class GatewayControllerEndpointTests { } @Test + public void testRefreshByGroup() { + RouteDefinition testRouteDefinition = new RouteDefinition(); + testRouteDefinition.setUri(URI.create("http://example.org")); + String group1 = "group-1_" + UUID.randomUUID(); + testRouteDefinition.setMetadata(Map.of("groupBy", group1)); + + String routeId1 = "route-1_" + UUID.randomUUID(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId1) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition)).exchange() + .expectStatus().isCreated(); + + RouteDefinition testRouteDefinition2 = new RouteDefinition(); + testRouteDefinition2.setUri(URI.create("http://example.org")); + String group2 = "group-2_" + UUID.randomUUID(); + testRouteDefinition2.setMetadata(Map.of("groupBy", group2)); + String routeId2 = "route-2_" + UUID.randomUUID(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId2) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition2)).exchange() + .expectStatus().isCreated(); + + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/refresh?metadata=groupBy:" + group1) + .exchange().expectStatus().isOk(); + + testClient.get().uri("http://localhost:" + port + "/actuator/gateway/routes").exchange().expectStatus().isOk() + .expectBodyList(Map.class).consumeWith(result -> { + List responseBody = result.getResponseBody(); + assertThat(responseBody).extracting("route_id").contains(routeId1).doesNotContain(routeId2); + }); + } + + @Test + public void testRefreshByGroup_whenRouteDefinitionsAreDeleted() { + RouteDefinition testRouteDefinition = new RouteDefinition(); + testRouteDefinition.setUri(URI.create("http://example.org")); + String group1 = "group-1_" + UUID.randomUUID(); + testRouteDefinition.setMetadata(Map.of("groupBy", group1)); + + String routeId1 = "route-1_" + UUID.randomUUID(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId1) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition)).exchange() + .expectStatus().isCreated(); + + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/refresh?metadata=groupBy:" + group1) + .exchange().expectStatus().isOk(); + + testClient.delete().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId1).exchange() + .expectStatus().isOk(); + + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/refresh?metadata=groupBy:" + group1) + .exchange().expectStatus().isOk(); + + testClient.get().uri("http://localhost:" + port + "/actuator/gateway/routes").exchange().expectStatus().isOk() + .expectBodyList(Map.class).consumeWith(result -> { + List responseBody = result.getResponseBody(); + assertThat(responseBody).extracting("route_id").doesNotContain(routeId1); + }); + } + + @Test + public void testRefreshByGroupWithOneWrongFilterInSameGroup() { + RouteDefinition testRouteDefinition = new RouteDefinition(); + testRouteDefinition.setUri(URI.create("http://wrong.route")); + String group1 = "group-1_" + UUID.randomUUID(); + testRouteDefinition.setMetadata(Map.of("groupBy", group1)); + testRouteDefinition.setFilters(List.of(new FilterDefinition("StripPrefix=wrong"))); + + String routeId1 = UUID.randomUUID().toString(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId1) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition)).exchange() + .expectStatus().isCreated(); + + RouteDefinition testRouteDefinition2 = new RouteDefinition(); + testRouteDefinition2.setUri(URI.create("http://valid.route")); + testRouteDefinition2.setMetadata(Map.of("groupBy", group1)); + String routeId2 = UUID.randomUUID().toString(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId2) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition2)).exchange() + .expectStatus().isCreated(); + + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/refresh?metadata=groupBy:" + group1) + .exchange().expectStatus().isOk(); + + testClient.get().uri("http://localhost:" + port + "/actuator/gateway/routes").exchange().expectStatus().isOk() + .expectBodyList(Map.class).consumeWith(result -> { + List responseBody = result.getResponseBody(); + assertThat(responseBody).extracting("route_id").doesNotContain(routeId1, routeId2); + }); + } + + @Test + public void testRefreshByGroupDoesntImpactOthers() { + RouteDefinition testRouteDefinition = new RouteDefinition(); + String routeId1 = UUID.randomUUID().toString(); + testRouteDefinition.setId(routeId1); + testRouteDefinition.setUri(URI.create("http://wrong-group-1.route")); + String group1 = "group-1_" + UUID.randomUUID(); + testRouteDefinition.setMetadata(Map.of("groupBy", group1)); + testRouteDefinition.setFilters(List.of(new FilterDefinition("StripPrefix=wrong"))); + RouteDefinition testRouteDefinition2 = new RouteDefinition(); + String routeId2 = UUID.randomUUID().toString(); + testRouteDefinition2.setId(routeId2); + testRouteDefinition2.setUri(URI.create("http://valid-group-1.route")); + testRouteDefinition2.setMetadata(Map.of("groupBy", group1)); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId1) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition)).exchange() + .expectStatus().isCreated(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId2) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition2)).exchange() + .expectStatus().isCreated(); + + RouteDefinition testRouteDefinition3 = new RouteDefinition(); + String routeId3 = UUID.randomUUID().toString(); + testRouteDefinition3.setId(routeId3); + testRouteDefinition3.setUri(URI.create("http://valid-group-2.route")); + String group2 = "group-2_" + UUID.randomUUID(); + testRouteDefinition3.setMetadata(Map.of("groupBy", group2)); + RouteDefinition testRouteDefinition4 = new RouteDefinition(); + String routeId4 = UUID.randomUUID().toString(); + testRouteDefinition4.setId(routeId4); + testRouteDefinition4.setUri(URI.create("http://valid-group-2.route")); + testRouteDefinition4.setMetadata(Map.of("groupBy", group2)); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId3) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition3)).exchange() + .expectStatus().isCreated(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/routes/" + routeId4) + .accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(testRouteDefinition4)).exchange() + .expectStatus().isCreated(); + + // When + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/refresh?metadata=groupBy:" + group1) + .exchange().expectStatus().isOk(); + testClient.post().uri("http://localhost:" + port + "/actuator/gateway/refresh?metadata=groupBy:" + group2) + .exchange().expectStatus().isOk(); + + // Then + testClient.get().uri("http://localhost:" + port + "/actuator/gateway/routes").exchange().expectStatus().isOk() + .expectBodyList(Map.class).consumeWith(result -> { + List responseBody = result.getResponseBody(); + assertThat(responseBody).extracting("route_id").doesNotContain(routeId1, routeId2) + .contains(routeId3, routeId4); + + }); + } + public void testPostMultipleValidRouteDefinitions() { RouteDefinition testRouteDefinition = new RouteDefinition(); testRouteDefinition.setUri(URI.create("http://example.org")); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMappingTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMappingTests.java index 69acbbef..151e3c26 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMappingTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMappingTests.java @@ -45,7 +45,12 @@ public class RoutePredicateHandlerMappingTests { throw new IllegalStateException("boom"); }).build(); Route routeTrue = Route.async().id("routeTrue").uri("http://localhost").predicate(swe -> true).build(); - RouteLocator routeLocator = () -> Flux.just(routeFalse, routeFail, routeTrue).hide(); + RouteLocator routeLocator = new RouteLocator() { + @Override + public Flux getRoutes() { + return Flux.just(routeFalse, routeFail, routeTrue).hide(); + } + }; RoutePredicateHandlerMapping mapping = new RoutePredicateHandlerMapping(null, routeLocator, new GlobalCorsProperties(), new MockEnvironment());