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:
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user