Add metadata requestParam to /refresh specific routes (#2916)

* Add metadata requestParam to /refresh specific routes

* Added requestParam that will be included in the event as a map of key:value pairs
* ScopedRefreshRoutesEvent: RefreshRoutesEvent was extended hierarchically to add metadata for filtering
* Modify CachingRouteLocator to filter in case the incoming event is ScopedRefreshRoutesEvent

* Change refresh by group for covering deletion

When all the routes in a group were deleted, the refresh were not done

---------

Co-authored-by: Spencer Gibb <sgibb@pivotal.io>
This commit is contained in:
Ignacio Lozano
2023-04-26 19:41:34 +02:00
committed by GitHub
parent 23ca3721a6
commit c74d65aa14
9 changed files with 304 additions and 10 deletions

View File

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

View File

@@ -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<Void> refresh() {
this.publisher.publishEvent(new RefreshRoutesEvent(this));
public Mono<Void> refresh(@RequestParam(value = "metadata", required = false) List<String> byMetadata) {
publishRefreshEvent(byMetadata);
return Mono.empty();
}
private void publishRefreshEvent(List<String> byMetadata) {
RefreshRoutesEvent event;
if (!CollectionUtils.isEmpty(byMetadata)) {
event = new RefreshRoutesEvent(this, convertToMap(byMetadata));
}
else {
event = new RefreshRoutesEvent(this);
}
this.publisher.publishEvent(event);
}
private Map<String, Object> convertToMap(List<String> 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<HashMap<String, Object>> globalfilters() {
return getNamesToOrders(this.globalFilters);

View File

@@ -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<String, Object> 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<String, Object> metadata) {
super(source);
this.metadata = metadata;
}
public boolean isScoped() {
return !CollectionUtils.isEmpty(getMetadata());
}
public Map<String, Object> getMetadata() {
return metadata;
}
}

View File

@@ -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<Route> fetch(Map<String, Object> metadata) {
return this.delegate.getRoutesByMetadata(metadata).sort(AnnotationAwareOrderComparator.INSTANCE);
}
@Override
public Flux<Route> getRoutes() {
return this.routes;
@@ -71,24 +76,45 @@ public class CachingRouteLocator
* @return routes flux
*/
public Flux<Route> 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<List<Route>> 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<List<Route>> 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<Route> 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);

View File

@@ -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<Route> getRoutesByMetadata(Map<String, Object> metadata) {
return this.delegates.flatMapSequential(routeLocator -> routeLocator.getRoutesByMetadata(metadata));
}
}

View File

@@ -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<Route> getRoutesByMetadata(Map<String, Object> metadata) {
return getRoutes(this.routeDefinitionLocator.getRouteDefinitions()
.filter(routeDef -> RouteLocator.matchMetadata(routeDef.getMetadata(), metadata)));
}
@Override
public Flux<Route> getRoutes() {
Flux<Route> routes = this.routeDefinitionLocator.getRouteDefinitions().map(this::convertToRoute);
return getRoutes(this.routeDefinitionLocator.getRouteDefinitions());
}
private Flux<Route> getRoutes(Flux<RouteDefinition> routeDefinitions) {
Flux<Route> routes = routeDefinitions.map(this::convertToRoute);
if (!gatewayProperties.isFailOnRouteDefinitionError()) {
// instead of letting error bubble up, continue

View File

@@ -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<Route> 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<Route> getRoutesByMetadata(Map<String, Object> metadata) {
return getRoutes().filter(route -> matchMetadata(route.getMetadata(), metadata));
}
static boolean matchMetadata(Map<String, Object> toCheck, Map<String, Object> 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()));
}
}
}

View File

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

View File

@@ -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<Route> getRoutes() {
return Flux.just(routeFalse, routeFail, routeTrue).hide();
}
};
RoutePredicateHandlerMapping mapping = new RoutePredicateHandlerMapping(null, routeLocator,
new GlobalCorsProperties(), new MockEnvironment());