Browse Source

Adds GatewayRequestPredicates.readBody()

Sets up a gateway attributes map in request attributes because request attributes are cleared between predicates.

Added MvcUtils.getAttribute() and MvcUtils.putAttribute() to use the gateway attribute map for attributes that live across predicates.

Adds BodyFilterFunctions.adaptCachedBody(). This needs to be used in conjunction with readBody(), otherwise no body will be sent downstream.

See gh-2949
pull/3006/head
sgibb 1 year ago
parent
commit
79807d1ddd
No known key found for this signature in database
GPG Key ID: 7788A47380690861
  1. 31
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java
  2. 4
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctions.java
  3. 276
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BodyFilterFunctions.java
  4. 5
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java
  5. 2
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/GatewayDelegatingRouterFunction.java
  6. 86
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java
  7. 74
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java

31
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java

@ -35,11 +35,21 @@ import static org.springframework.web.servlet.function.RouterFunctions.URI_TEMPL @@ -35,11 +35,21 @@ import static org.springframework.web.servlet.function.RouterFunctions.URI_TEMPL
// TODO: maybe rename to ServerRequestUtils?
public abstract class MvcUtils {
/**
* Cached request body key.
*/
public static final String CACHED_REQUEST_BODY_ATTR = qualify("cachedRequestBody");
/**
* CircuitBreaker execution exception attribute name.
*/
public static final String CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR = qualify("circuitBreakerExecutionException");
/**
* Gateway route ID attribute name.
*/
public static final String GATEWAY_ATTRIBUTES_ATTR = qualify("gatewayAttributes");
/**
* Gateway request URL attribute name.
*/
@ -96,12 +106,33 @@ public abstract class MvcUtils { @@ -96,12 +106,33 @@ public abstract class MvcUtils {
return webApplicationContext;
}
@SuppressWarnings("unchecked")
public static <T> T getAttribute(ServerRequest request, String key) {
if (request.attributes().containsKey(key)) {
return (T) request.attributes().get(key);
}
return (T) getGatewayAttributes(request).get(key);
}
@SuppressWarnings("unchecked")
public static Map<String, Object> getGatewayAttributes(ServerRequest request) {
// This map is made in GatewayDelegatingRouterFunction.route() and persists across
// attribute resetting in RequestPredicates
Map<String, Object> attributes = (Map<String, Object>) request.attributes().get(GATEWAY_ATTRIBUTES_ATTR);
return attributes;
}
@SuppressWarnings("unchecked")
public static Map<String, Object> getUriTemplateVariables(ServerRequest request) {
return (Map<String, Object>) request.attributes().getOrDefault(URI_TEMPLATE_VARIABLES_ATTRIBUTE,
new HashMap<>());
}
public static void putAttribute(ServerRequest request, String key, Object value) {
request.attributes().put(key, value);
getGatewayAttributes(request).put(key, value);
}
@SuppressWarnings("unchecked")
public static void putUriTemplateVariables(ServerRequest request, Map<String, String> uriVariables) {
if (request.attributes().containsKey(URI_TEMPLATE_VARIABLES_ATTRIBUTE)) {

4
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctions.java

@ -83,6 +83,10 @@ public abstract class BeforeFilterFunctions { @@ -83,6 +83,10 @@ public abstract class BeforeFilterFunctions {
private BeforeFilterFunctions() {
}
public static Function<ServerRequest, ServerRequest> adaptCachedBody() {
return BodyFilterFunctions.adaptCachedBody();
}
public static Function<ServerRequest, ServerRequest> addRequestHeader(String name, String... values) {
return request -> {
String[] expandedValues = MvcUtils.expandMultiple(request, values);

276
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BodyFilterFunctions.java

@ -0,0 +1,276 @@ @@ -0,0 +1,276 @@
/*
* Copyright 2013-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.gateway.server.mvc.filter;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.security.Principal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.Part;
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.PathContainer;
import org.springframework.http.server.RequestPath;
import org.springframework.util.MultiValueMap;
import org.springframework.validation.BindException;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.util.UriBuilder;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.getAttribute;
public abstract class BodyFilterFunctions {
private BodyFilterFunctions() {
}
public static Function<ServerRequest, ServerRequest> adaptCachedBody() {
return request -> {
Object o = getAttribute(request, MvcUtils.CACHED_REQUEST_BODY_ATTR);
if (o instanceof ByteArrayInputStream body) {
ByteArrayServletInputStream inputStream = new ByteArrayServletInputStream(body);
HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request.servletRequest()) {
@Override
public ServletInputStream getInputStream() {
return inputStream;
}
};
return new ServerRequestWrapper(request) {
@Override
public HttpServletRequest servletRequest() {
return wrapper;
}
};
}
return request;
};
}
private static class ByteArrayServletInputStream extends ServletInputStream {
private final ByteArrayInputStream body;
ByteArrayServletInputStream(ByteArrayInputStream body) {
body.reset();
this.body = body;
}
@Override
public boolean isFinished() {
return body.available() <= 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
}
@Override
public int read() {
return body.read();
}
}
private static class ServerRequestWrapper implements ServerRequest {
private final ServerRequest delegate;
protected ServerRequestWrapper(ServerRequest delegate) {
this.delegate = delegate;
}
@Override
public <T> T bind(Class<T> bindType) throws BindException {
return delegate.bind(bindType);
}
@Override
public <T> T bind(Class<T> bindType, Consumer<WebDataBinder> dataBinderCustomizer) throws BindException {
return delegate.bind(bindType, dataBinderCustomizer);
}
@Override
public HttpMethod method() {
return delegate.method();
}
@Override
@Deprecated
public String methodName() {
return delegate.methodName();
}
@Override
public URI uri() {
return delegate.uri();
}
@Override
public UriBuilder uriBuilder() {
return delegate.uriBuilder();
}
@Override
public String path() {
return delegate.path();
}
@Override
@Deprecated
public PathContainer pathContainer() {
return delegate.pathContainer();
}
@Override
public RequestPath requestPath() {
return delegate.requestPath();
}
@Override
public Headers headers() {
return delegate.headers();
}
@Override
public MultiValueMap<String, Cookie> cookies() {
return delegate.cookies();
}
@Override
public Optional<InetSocketAddress> remoteAddress() {
return delegate.remoteAddress();
}
@Override
public List<HttpMessageConverter<?>> messageConverters() {
return delegate.messageConverters();
}
@Override
public <T> T body(Class<T> bodyType) throws ServletException, IOException {
return delegate.body(bodyType);
}
@Override
public <T> T body(ParameterizedTypeReference<T> bodyType) throws ServletException, IOException {
return delegate.body(bodyType);
}
@Override
public Optional<Object> attribute(String name) {
return delegate.attribute(name);
}
@Override
public Map<String, Object> attributes() {
return delegate.attributes();
}
@Override
public Optional<String> param(String name) {
return delegate.param(name);
}
@Override
public MultiValueMap<String, String> params() {
return delegate.params();
}
@Override
public MultiValueMap<String, Part> multipartData() throws IOException, ServletException {
return delegate.multipartData();
}
@Override
public String pathVariable(String name) {
return delegate.pathVariable(name);
}
@Override
public Map<String, String> pathVariables() {
return delegate.pathVariables();
}
@Override
public HttpSession session() {
return delegate.session();
}
@Override
public Optional<Principal> principal() {
return delegate.principal();
}
@Override
public HttpServletRequest servletRequest() {
return delegate.servletRequest();
}
@Override
public Optional<ServerResponse> checkNotModified(Instant lastModified) {
return delegate.checkNotModified(lastModified);
}
@Override
public Optional<ServerResponse> checkNotModified(String etag) {
return delegate.checkNotModified(etag);
}
@Override
public Optional<ServerResponse> checkNotModified(Instant lastModified, String etag) {
return delegate.checkNotModified(lastModified, etag);
}
public static ServerRequest create(HttpServletRequest servletRequest,
List<HttpMessageConverter<?>> messageReaders) {
return ServerRequest.create(servletRequest, messageReaders);
}
public static Builder from(ServerRequest other) {
return ServerRequest.from(other);
}
}
}

5
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java

@ -40,6 +40,11 @@ import static org.springframework.web.servlet.function.HandlerFilterFunction.ofR @@ -40,6 +40,11 @@ import static org.springframework.web.servlet.function.HandlerFilterFunction.ofR
// TODO: can Bucket4j, CircuitBreaker, Retry be here and not cause CNFE?
public interface FilterFunctions {
@Shortcut
static HandlerFilterFunction<ServerResponse, ServerResponse> adaptCachedBody() {
return ofRequestProcessor(BeforeFilterFunctions.adaptCachedBody());
}
@Shortcut
static HandlerFilterFunction<ServerResponse, ServerResponse> addRequestHeader(String name, String... values) {
return ofRequestProcessor(BeforeFilterFunctions.addRequestHeader(name, values));

2
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/GatewayDelegatingRouterFunction.java

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package org.springframework.cloud.gateway.server.mvc.handler;
import java.util.HashMap;
import java.util.Optional;
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
@ -41,6 +42,7 @@ public class GatewayDelegatingRouterFunction<T extends ServerResponse> implement @@ -41,6 +42,7 @@ public class GatewayDelegatingRouterFunction<T extends ServerResponse> implement
@Override
public Optional<HandlerFunction<T>> route(ServerRequest request) {
request.attributes().put(MvcUtils.GATEWAY_ROUTE_ID_ATTR, routeId);
request.attributes().computeIfAbsent(MvcUtils.GATEWAY_ATTRIBUTES_ATTR, s -> new HashMap<String, Object>());
Optional<HandlerFunction<T>> handlerFunction = delegate.route(request);
request.attributes().remove(MvcUtils.GATEWAY_ROUTE_ID_ATTR);
return handlerFunction;

86
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java

@ -16,6 +16,9 @@ @@ -16,6 +16,9 @@
package org.springframework.cloud.gateway.server.mvc.predicate;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.time.ZonedDateTime;
import java.util.Arrays;
@ -25,6 +28,7 @@ import java.util.List; @@ -25,6 +28,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import jakarta.servlet.http.Cookie;
@ -38,11 +42,14 @@ import org.springframework.cloud.gateway.server.mvc.common.Shortcut; @@ -38,11 +42,14 @@ import org.springframework.cloud.gateway.server.mvc.common.Shortcut;
import org.springframework.cloud.gateway.server.mvc.common.WeightConfig;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpMethod;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.PathContainer;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.servlet.function.HandlerFunction;
@ -56,6 +63,8 @@ import org.springframework.web.util.pattern.PathPatternParser; @@ -56,6 +63,8 @@ import org.springframework.web.util.pattern.PathPatternParser;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.GATEWAY_ROUTE_ID_ATTR;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.WEIGHT_ATTR;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.getAttribute;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.putAttribute;
public abstract class GatewayRequestPredicates {
@ -69,6 +78,8 @@ public abstract class GatewayRequestPredicates { @@ -69,6 +78,8 @@ public abstract class GatewayRequestPredicates {
private static final PathPatternParser DEFAULT_HOST_INSTANCE = new HostReadOnlyPathPatternParser();
private static final String READ_BODY_CACHE_OBJECT_KEY = "cachedRequestBodyObject";
private GatewayRequestPredicates() {
}
@ -154,6 +165,11 @@ public abstract class GatewayRequestPredicates { @@ -154,6 +165,11 @@ public abstract class GatewayRequestPredicates {
return RequestPredicates.path(pattern);
}
@SuppressWarnings("unchecked")
public static <T> RequestPredicate readBody(Class<T> inClass, Predicate<T> predicate) {
return new ReadBodyPredicate(inClass, (Predicate<Object>) predicate);
}
/**
* A predicate which will select a route based on its assigned weight.
* @param group the group the route belongs to
@ -410,6 +426,76 @@ public abstract class GatewayRequestPredicates { @@ -410,6 +426,76 @@ public abstract class GatewayRequestPredicates {
}
private static final class ReadBodyPredicate implements RequestPredicate {
private final Class toRead;
private final Predicate<Object> predicate;
<T> ReadBodyPredicate(Class toRead, Predicate<Object> predicate) {
this.toRead = toRead;
this.predicate = predicate;
}
@Override
public boolean test(ServerRequest request) {
try {
// TODO: RequestPredicates.restoreAttributes() erases this :-(
Object cachedBody = getAttribute(request, READ_BODY_CACHE_OBJECT_KEY);
if (cachedBody != null) {
return predicate.test(cachedBody);
}
}
catch (ClassCastException e) {
if (log.isDebugEnabled()) {
log.debug("Predicate test failed because class in predicate "
+ "does not match the cached body object", e);
}
}
try {
byte[] bytes = StreamUtils.copyToByteArray(request.servletRequest().getInputStream());
ByteArrayInputStream body = new ByteArrayInputStream(bytes);
putAttribute(request, MvcUtils.CACHED_REQUEST_BODY_ATTR, body);
HttpInputMessage inputMessage = new HttpInputMessage() {
@Override
public InputStream getBody() {
return body;
}
@Override
public HttpHeaders getHeaders() {
return request.headers().asHttpHeaders();
}
};
List<HttpMessageConverter<?>> httpMessageConverters = request.messageConverters();
for (HttpMessageConverter<?> messageConverter : httpMessageConverters) {
if (messageConverter.canRead(toRead, request.headers().contentType().orElse(null))) {
Object value = messageConverter.read(toRead, inputMessage);
putAttribute(request, READ_BODY_CACHE_OBJECT_KEY, value);
return predicate.test(value);
}
}
}
catch (IOException e) {
throw new RuntimeException(e);
}
return false;
}
@Override
public void accept(RequestPredicates.Visitor visitor) {
visitor.unknown(this);
}
@Override
public String toString() {
return String.format("ReadBody=%s predicate=%s", toRead.getSimpleName(), predicate);
}
}
private static final class WeightPredicate implements RequestPredicate, ArgumentSupplier<WeightConfig> {
final String group;

74
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java

@ -24,6 +24,7 @@ import java.util.List; @@ -24,6 +24,7 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import com.github.benmanes.caffeine.cache.Caffeine;
import io.github.bucket4j.caffeine.CaffeineProxyManager;
@ -62,6 +63,8 @@ import org.springframework.test.context.ContextConfiguration; @@ -62,6 +63,8 @@ import org.springframework.test.context.ContextConfiguration;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
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.bind.annotation.RestController;
import org.springframework.web.servlet.function.RouterFunction;
@ -81,6 +84,7 @@ import static org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFun @@ -81,6 +84,7 @@ import static org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFun
import static org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFunctions.setStatus;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.CB_EXECUTION_EXCEPTION_MESSAGE;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.CB_EXECUTION_EXCEPTION_TYPE;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.adaptCachedBody;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.fallbackHeaders;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.mapRequestHeader;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.preserveHost;
@ -110,6 +114,7 @@ import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequ @@ -110,6 +114,7 @@ import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequ
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.cookie;
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.header;
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.host;
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.readBody;
import static org.springframework.cloud.gateway.server.mvc.test.TestUtils.getMap;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
@ -537,6 +542,23 @@ public class ServerMvcIntegrationTests { @@ -537,6 +542,23 @@ public class ServerMvcIntegrationTests {
.valueEquals("Location", "https://test1.rewritelocationresponseheader.org/some/object/id");
}
@Test
public void readBodyWorks() {
Event messageEvent = new Event("message", "bar");
restClient.post().uri("/events").bodyValue(messageEvent).exchange().expectStatus().isOk().expectHeader()
.valueEquals("X-Foo", "message").expectBody(Event.class)
.consumeWith(res -> assertThat(res.getResponseBody()).isEqualTo(messageEvent));
Event messageChannelEvent = new Event("message.channel", "baz");
restClient.post().uri("/events").bodyValue(messageChannelEvent).exchange().expectStatus().isOk().expectHeader()
.valueEquals("X-Channel-Foo", "message.channel").expectBody(Event.class)
.consumeWith(res -> assertThat(res.getResponseBody()).isEqualTo(messageChannelEvent));
}
@SpringBootConfiguration
@EnableAutoConfiguration
@LoadBalancerClient(name = "httpbin", configuration = TestLoadBalancerConfig.Httpbin.class)
@ -552,6 +574,11 @@ public class ServerMvcIntegrationTests { @@ -552,6 +574,11 @@ public class ServerMvcIntegrationTests {
return new RetryController();
}
@Bean
EventController eventController() {
return new EventController();
}
@Bean
public AsyncProxyManager<String> caffeineProxyManager() {
Caffeine<String, RemoteBucketState> builder = (Caffeine) Caffeine.newBuilder().maximumSize(100);
@ -980,6 +1007,53 @@ public class ServerMvcIntegrationTests { @@ -980,6 +1007,53 @@ public class ServerMvcIntegrationTests {
// @formatter:on
}
@Bean
public RouterFunction<ServerResponse> gatewayRouterFunctionsReadBodyPredicate() {
// @formatter:of
return route("testreadbodypredicate")
.POST("/events", readBody(Event.class, eventPredicate("message")), http()).before(
new LocalServerPortUriResolver())
.filter(setPath("/do/events")).before(adaptCachedBody()).build()
.and(route("testreadbodypredicate2")
.POST("/events", readBody(Event.class, eventPredicate("message.channel")), http())
.before(new LocalServerPortUriResolver()).filter(setPath("/do/events/channel"))
.before(adaptCachedBody()).build());
// @formatter:on
}
private Predicate<Event> eventPredicate(String foo) {
return new Predicate<>() {
@Override
public boolean test(Event event) {
return event.foo().equals(foo);
}
@Override
public String toString() {
return "Event.foo == " + foo;
}
};
}
}
protected record Event(String foo, String bar) {
}
@RestController
protected static class EventController {
@PostMapping(path = "/do/events", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Event> messageEvents(@RequestBody Event e) {
return ResponseEntity.ok().header("X-Foo", e.foo()).body(e);
}
@PostMapping(path = "/do/events/channel", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Event> messageChannelEvents(@RequestBody Event e) {
return ResponseEntity.ok().header("X-Channel-Foo", e.foo()).body(e);
}
}
@RestController

Loading…
Cancel
Save