Merge branch '3.1.x'

This commit is contained in:
spencergibb
2022-07-18 12:56:15 -04:00
17 changed files with 685 additions and 27 deletions

View File

@@ -14,6 +14,7 @@
|spring.cloud.gateway.filter.add-request-header.enabled | `+++true+++` | Enables the add-request-header filter.
|spring.cloud.gateway.filter.add-request-parameter.enabled | `+++true+++` | Enables the add-request-parameter filter.
|spring.cloud.gateway.filter.add-response-header.enabled | `+++true+++` | Enables the add-response-header filter.
|spring.cloud.gateway.filter.json-to-grpc.enabled | `+++true+++` | Enables the JSON to gRPC filter.
|spring.cloud.gateway.filter.circuit-breaker.enabled | `+++true+++` | Enables the circuit-breaker filter.
|spring.cloud.gateway.filter.dedupe-response-header.enabled | `+++true+++` | Enables the dedupe-response-header filter.
|spring.cloud.gateway.filter.fallback-headers.enabled | `+++true+++` | Enables the fallback-headers filter.
@@ -25,7 +26,7 @@
|spring.cloud.gateway.filter.preserve-host-header.enabled | `+++true+++` | Enables the preserve-host-header filter.
|spring.cloud.gateway.filter.redirect-to.enabled | `+++true+++` | Enables the redirect-to filter.
|spring.cloud.gateway.filter.remove-hop-by-hop.headers | |
|spring.cloud.gateway.filter.remove-hop-by-hop.order | `+++0+++` |
|spring.cloud.gateway.filter.remove-hop-by-hop.order | `+++0+++` |
|spring.cloud.gateway.filter.remove-request-header.enabled | `+++true+++` | Enables the remove-request-header filter.
|spring.cloud.gateway.filter.remove-request-parameter.enabled | `+++true+++` | Enables the remove-request-parameter filter.
|spring.cloud.gateway.filter.remove-response-header.enabled | `+++true+++` | Enables the remove-response-header filter.
@@ -41,16 +42,16 @@
|spring.cloud.gateway.filter.rewrite-path.enabled | `+++true+++` | Enables the rewrite-path filter.
|spring.cloud.gateway.filter.rewrite-response-header.enabled | `+++true+++` | Enables the rewrite-response-header filter.
|spring.cloud.gateway.filter.save-session.enabled | `+++true+++` | Enables the save-session filter.
|spring.cloud.gateway.filter.secure-headers.content-security-policy | `+++default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'+++` |
|spring.cloud.gateway.filter.secure-headers.content-type-options | `+++nosniff+++` |
|spring.cloud.gateway.filter.secure-headers.content-security-policy | `+++default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'+++` |
|spring.cloud.gateway.filter.secure-headers.content-type-options | `+++nosniff+++` |
|spring.cloud.gateway.filter.secure-headers.disable | |
|spring.cloud.gateway.filter.secure-headers.download-options | `+++noopen+++` |
|spring.cloud.gateway.filter.secure-headers.download-options | `+++noopen+++` |
|spring.cloud.gateway.filter.secure-headers.enabled | `+++true+++` | Enables the secure-headers filter.
|spring.cloud.gateway.filter.secure-headers.frame-options | `+++DENY+++` |
|spring.cloud.gateway.filter.secure-headers.permitted-cross-domain-policies | `+++none+++` |
|spring.cloud.gateway.filter.secure-headers.referrer-policy | `+++no-referrer+++` |
|spring.cloud.gateway.filter.secure-headers.strict-transport-security | `+++max-age=631138519+++` |
|spring.cloud.gateway.filter.secure-headers.xss-protection-header | `+++1 ; mode=block+++` |
|spring.cloud.gateway.filter.secure-headers.frame-options | `+++DENY+++` |
|spring.cloud.gateway.filter.secure-headers.permitted-cross-domain-policies | `+++none+++` |
|spring.cloud.gateway.filter.secure-headers.referrer-policy | `+++no-referrer+++` |
|spring.cloud.gateway.filter.secure-headers.strict-transport-security | `+++max-age=631138519+++` |
|spring.cloud.gateway.filter.secure-headers.xss-protection-header | `+++1 ; mode=block+++` |
|spring.cloud.gateway.filter.set-path.enabled | `+++true+++` | Enables the set-path filter.
|spring.cloud.gateway.filter.set-request-header.enabled | `+++true+++` | Enables the set-request-header filter.
|spring.cloud.gateway.filter.set-request-host-header.enabled | `+++true+++` | Enables the set-request-host-header filter.
@@ -105,7 +106,7 @@
|spring.cloud.gateway.httpclient.websocket.proxy-ping | `+++true+++` | Proxy ping frames to downstream services, defaults to true.
|spring.cloud.gateway.httpclient.wiretap | `+++false+++` | Enables wiretap debugging for Netty HttpClient.
|spring.cloud.gateway.httpserver.wiretap | `+++false+++` | Enables wiretap debugging for Netty HttpServer.
|spring.cloud.gateway.loadbalancer.use404 | `+++false+++` |
|spring.cloud.gateway.loadbalancer.use404 | `+++false+++` |
|spring.cloud.gateway.metrics.enabled | `+++false+++` | Enables the collection of metrics data.
|spring.cloud.gateway.metrics.prefix | `+++spring.cloud.gateway+++` | The prefix of all metrics emitted by gateway.
|spring.cloud.gateway.metrics.tags | | Tags map that added to metrics.

View File

@@ -1830,6 +1830,86 @@ spring:
NOTE: This filter only works with http request (including https).
=== The `JsonToGrpc` `GatewayFilter` Factory
The JSONToGRPCFilter GatewayFilter Factory converts a JSON payload to a gRPC request.
The filter takes the following arguments:
* `protoDescriptor` Proto descriptor file.
This file can be generated using `protoc` specifying the `--descriptor_set_out` flag:
[source,bash]
----
protoc --proto_path=src/main/resources/proto/ \
--descriptor_set_out=src/main/resources/proto/hello.pb \
src/main/resources/proto/hello.proto
----
* `protoFile` Proto definition file.
* `service` Fully qualified name of the service that will handle the request.
* `method` Method name in the service that will handle the request.
NOTE: `streaming` is not supported.
*application.yml.*
[source,java]
----
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("json-grpc", r -> r.path("/json/hello").filters(f -> {
String protoDescriptor = "file:src/main/proto/hello.pb";
String protoFile = "file:src/main/proto/hello.proto";
String service = "HelloService";
String method = "hello";
return f.jsonToGRPC(protoDescriptor, protoFile, service, method);
}).uri(uri))
----
[source,yaml]
----
spring:
cloud:
gateway:
routes:
- id: json-grpc
uri: https://localhost:6565/testhello
predicates:
- Path=/json/**
filters:
- name: JsonToGrpc
args:
protoDescriptor: file:proto/hello.pb
protoFile: file:proto/hello.proto
service: com.example.grpcserver.hello.HelloService
method: hello
----
When a request is made through the gateway to `/json/hello` the request will be transformed using the definition provided in `hello.proto`, sent to `com.example.grpcserver.hello.HelloService/hello`, and transform the response back to JSON.
By default, it will create a `NettyChannel` using the default `TrustManagerFactory`. However, this `TrustManager` can be customized by creating a bean of type `GRPCSSLContext`:
[source,java]
----
@Configuration
public class GRPCLocalConfiguration {
@Bean
public GRPCSSLContext sslContext() {
TrustManager trustManager = trustAllCerts();
return new GRPCSSLContext(trustManager);
}
}
----
=== Default Filters
To add a filter and apply it to all routes, you can use `spring.cloud.gateway.default-filters`.

View File

@@ -11,6 +11,7 @@
<description>Spring Cloud Gateway gRPC Integration Test</description>
<properties>
<grpc.version>1.47.0</grpc.version>
</properties>
<parent>
@@ -29,26 +30,38 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.44.0</version>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.44.0</version>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.44.0</version>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-protobuf</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-tcnative-boringssl-static</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>annotations-api</artifactId>

View File

@@ -18,8 +18,13 @@ package org.springframework.cloud.gateway.tests.grpc;
import java.io.File;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import io.grpc.Grpc;
import io.grpc.Server;
import io.grpc.ServerCredentials;
@@ -31,6 +36,7 @@ import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.cloud.gateway.config.GRPCSSLContext;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.test.TestSocketUtils;
@@ -53,8 +59,31 @@ public class GRPCApplication {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes().route("grpc", r -> r.predicate(p -> true).uri("https://localhost:" + GRPC_SERVER_PORT))
.build();
return builder.routes().route("json-grpc", r -> r.path("/json/hello").filters(f -> {
String protoDescriptor = "file:src/main/proto/hello.pb";
String protoFile = "file:src/main/proto/hello.proto";
String service = "HelloService";
String method = "hello";
return f.jsonToGRPC(protoDescriptor, protoFile, service, method);
}).uri("https://localhost:" + GRPC_SERVER_PORT))
.route("grpc", r -> r.predicate(p -> true).uri("https://localhost:" + GRPC_SERVER_PORT)).build();
}
@Bean
public GRPCSSLContext sslContext() throws SSLException {
TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) {
}
} };
return new GRPCSSLContext(trustAllCerts[0]);
}
@Component
@@ -74,7 +103,7 @@ public class GRPCApplication {
server = Grpc.newServerBuilderForPort(GRPC_SERVER_PORT, creds).addService(new HelloService()).build()
.start();
System.out.println("Starting server in port " + GRPC_SERVER_PORT);
System.out.println("Starting gRPC server in port " + GRPC_SERVER_PORT);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
@@ -104,7 +133,7 @@ public class GRPCApplication {
public void hello(HelloRequest request, StreamObserver<HelloResponse> responseObserver) {
String greeting = "Hello, " + request.getFirstName() + " " + request.getLastName();
System.out.println(greeting);
System.out.println("Sending response: " + greeting);
HelloResponse response = HelloResponse.newBuilder().setGreeting(greeting).build();

View File

@@ -0,0 +1,11 @@
<EFBFBD>
hello.proto"H
HelloRequest
firstName ( R firstName
lastName ( RlastName"+
HelloResponse
greeting ( Rgreeting26
HelloService&
hello

View File

@@ -3,12 +3,12 @@ option java_multiple_files = true;
option java_package = "org.springframework.cloud.gateway.tests.grpc";
message HelloRequest {
string firstName = 1;
string lastName = 2;
optional string firstName = 1;
optional string lastName = 2;
}
message HelloResponse {
string greeting = 1;
optional string greeting = 1;
}
service HelloService {

View File

@@ -27,10 +27,8 @@ import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.annotation.DirtiesContext;
@@ -40,7 +38,6 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen
/**
* @author Alberto C. Ríos
*/
@ExtendWith(OutputCaptureExtension.class)
@SpringBootTest(classes = GRPCApplication.class, webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext
public class GRPCApplicationTests {

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2013-2021 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.tests.grpc;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import javax.net.ssl.SSLContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.TrustStrategy;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.BasicHttpClientConnectionManager;
import org.apache.http.ssl.SSLContexts;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
/**
* @author Alberto C. Ríos
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class JsonToGrpcApplicationTests {
@LocalServerPort
private int port;
private RestTemplate restTemplate;
@BeforeEach
void setUp() {
restTemplate = createUnsecureClient();
}
@Test
public void shouldConvertFromJSONToGRPC() {
String response = restTemplate.postForEntity("https://localhost:" + port + "/json/hello",
"{\"firstName\":\"Duff\", \"lastName\":\"McKagan\"}", String.class).getBody();
Assertions.assertThat(response).isNotNull();
Assertions.assertThat(response).contains("{\"greeting\":\"Hello, Duff McKagan\"}");
}
private RestTemplate createUnsecureClient() {
TrustStrategy acceptingTrustStrategy = (cert, authType) -> true;
SSLContext sslContext;
try {
sslContext = SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
}
catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
throw new RuntimeException(e);
}
SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(sslContext,
NoopHostnameVerifier.INSTANCE);
Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
.register("https", sslSocketFactory).register("http", new PlainConnectionSocketFactory()).build();
BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager(
socketFactoryRegistry);
CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(sslSocketFactory)
.setConnectionManager(connectionManager).build();
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
return new RestTemplate(requestFactory);
}
}

View File

@@ -24,4 +24,4 @@ spring:
logging:
level:
reactor.netty: DEBUG
org.springframework.cloud.gateway.filter: TRACE
org.springframework.cloud.gateway.filter: DEBUG

View File

@@ -16,6 +16,7 @@
<description>Spring Cloud Gateway Server</description>
<properties>
<main.basedir>${basedir}/..</main.basedir>
<grpc.version>1.47.0</grpc.version>
</properties>
<dependencies>
@@ -81,6 +82,34 @@
<groupId>io.projectreactor.addons</groupId>
<artifactId>reactor-extra</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-protobuf</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<optional>true</optional>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<optional>true</optional>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<optional>true</optional>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2013-2020 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.config;
import javax.net.ssl.TrustManager;
/**
* @author Alberto C. Ríos
*/
public class GRPCSSLContext {
private final TrustManager trustManager;
public GRPCSSLContext(TrustManager trustManager) {
this.trustManager = trustManager;
}
public TrustManager getTrustManager() {
return trustManager;
}
}

View File

@@ -16,10 +16,16 @@
package org.springframework.cloud.gateway.config;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
import javax.net.ssl.TrustManagerFactory;
import io.grpc.Channel;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
@@ -68,6 +74,7 @@ import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGateway
import org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.JsonToGrpcGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactory;
@@ -147,6 +154,7 @@ import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager;
@@ -292,6 +300,27 @@ public class GatewayAutoConfiguration {
return new GRPCResponseHeadersFilter();
}
@Bean
@ConditionalOnEnabledFilter
@ConditionalOnProperty(name = "server.http2.enabled", matchIfMissing = true)
@ConditionalOnClass(Channel.class)
public JsonToGrpcGatewayFilterFactory jsonToGRPCFilterFactory(GRPCSSLContext gRPCSSLContext,
ResourceLoader resourceLoader) {
return new JsonToGrpcGatewayFilterFactory(gRPCSSLContext, resourceLoader);
}
@Bean
@ConditionalOnEnabledFilter(JsonToGrpcGatewayFilterFactory.class)
@ConditionalOnMissingBean(GRPCSSLContext.class)
@ConditionalOnClass(Channel.class)
public GRPCSSLContext gRPCSSLContext() throws KeyStoreException, NoSuchAlgorithmException {
TrustManagerFactory trustManagerFactory = TrustManagerFactory
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(KeyStore.getInstance(KeyStore.getDefaultType()));
return new GRPCSSLContext(trustManagerFactory.getTrustManagers()[0]);
}
@Bean
public TransferEncodingNormalizationHeadersFilter transferEncodingNormalizationHeadersFilter() {
return new TransferEncodingNormalizationHeadersFilter();

View File

@@ -0,0 +1,314 @@
/*
* Copyright 2013-2020 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.filter.factory;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.function.Function;
import javax.net.ssl.SSLException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.dataformat.protobuf.ProtobufFactory;
import com.fasterxml.jackson.dataformat.protobuf.schema.ProtobufSchema;
import com.fasterxml.jackson.dataformat.protobuf.schema.ProtobufSchemaLoader;
import com.google.protobuf.DescriptorProtos;
import com.google.protobuf.Descriptors;
import com.google.protobuf.DynamicMessage;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ManagedChannel;
import io.grpc.MethodDescriptor;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.protobuf.ProtoUtils;
import io.grpc.stub.ClientCalls;
import io.netty.buffer.PooledByteBufAllocator;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.cloud.gateway.config.GRPCSSLContext;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.NettyDataBufferFactory;
import org.springframework.http.codec.json.Jackson2JsonDecoder;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.web.server.ServerWebExchange;
import static io.grpc.netty.shaded.io.grpc.netty.NegotiationType.TLS;
import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator;
/**
* This filter takes a JSON payload, transform it into a protobuf object, send it to a
* given gRPC channel, and transform the response back to JSON.
*
* Making it transparent for the consumer that the service under the gateway is a gRPC
* one.
*
* @author Alberto C. Ríos
*/
public class JsonToGrpcGatewayFilterFactory
extends AbstractGatewayFilterFactory<JsonToGrpcGatewayFilterFactory.Config> {
private final GRPCSSLContext sslContext;
private final ResourceLoader resourceLoader;
public JsonToGrpcGatewayFilterFactory(GRPCSSLContext sslContext, ResourceLoader resourceLoader) {
super(Config.class);
this.sslContext = sslContext;
this.resourceLoader = resourceLoader;
}
@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList("protoDescriptor", "protoFile", "service", "method");
}
@Override
public GatewayFilter apply(Config config) {
GatewayFilter filter = new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
GRPCResponseDecorator modifiedResponse = new GRPCResponseDecorator(exchange, config);
ServerWebExchangeUtils.setAlreadyRouted(exchange);
return modifiedResponse.writeWith(exchange.getRequest().getBody())
.then(chain.filter(exchange.mutate().response(modifiedResponse).build()));
}
@Override
public String toString() {
return filterToStringCreator(JsonToGrpcGatewayFilterFactory.this).toString();
}
};
int order = NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
return new OrderedGatewayFilter(filter, order);
}
public static class Config {
private String protoDescriptor;
private String protoFile;
private String service;
private String method;
public String getProtoDescriptor() {
return protoDescriptor;
}
public Config setProtoDescriptor(String protoDescriptor) {
this.protoDescriptor = protoDescriptor;
return this;
}
public String getProtoFile() {
return protoFile;
}
public Config setProtoFile(String protoFile) {
this.protoFile = protoFile;
return this;
}
public String getService() {
return service;
}
public Config setService(String service) {
this.service = service;
return this;
}
public String getMethod() {
return method;
}
public Config setMethod(String method) {
this.method = method;
return this;
}
}
class GRPCResponseDecorator extends ServerHttpResponseDecorator {
private final ServerWebExchange exchange;
private final Descriptors.Descriptor descriptor;
private final ObjectWriter objectWriter;
private final ObjectReader objectReader;
private final ClientCall<DynamicMessage, DynamicMessage> clientCall;
private final ObjectNode objectNode;
GRPCResponseDecorator(ServerWebExchange exchange, Config config) {
super(exchange.getResponse());
this.exchange = exchange;
try {
Resource descriptorFile = resourceLoader.getResource(config.getProtoDescriptor());
Resource protoFile = resourceLoader.getResource(config.getProtoFile());
descriptor = DescriptorProtos.FileDescriptorProto.parseFrom(descriptorFile.getInputStream())
.getDescriptorForType();
Descriptors.Descriptor outputType = getOutputTypeDescriptor(config, descriptorFile.getInputStream());
clientCall = createClientCallForType(config, outputType);
ProtobufSchema schema = ProtobufSchemaLoader.std.load(protoFile.getInputStream());
ProtobufSchema responseType = schema.withRootType(outputType.getName());
ObjectMapper objectMapper = new ObjectMapper(new ProtobufFactory());
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
objectWriter = objectMapper.writer(schema);
objectReader = objectMapper.readerFor(JsonNode.class).with(responseType);
objectNode = objectMapper.createObjectNode();
}
catch (IOException | Descriptors.DescriptorValidationException e) {
throw new RuntimeException(e);
}
}
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
exchange.getResponse().getHeaders().set("Content-Type", "application/json");
return getDelegate().writeWith(deserializeJSONRequest().map(callGRPCServer()).map(serialiseGRPCResponse())
.map(wrapGRPCResponse()).cast(DataBuffer.class).last());
}
private ClientCall<DynamicMessage, DynamicMessage> createClientCallForType(Config config,
Descriptors.Descriptor outputType) {
MethodDescriptor.Marshaller<DynamicMessage> marshaller = ProtoUtils
.marshaller(DynamicMessage.newBuilder(outputType).build());
MethodDescriptor<DynamicMessage, DynamicMessage> methodDescriptor = MethodDescriptor
.<DynamicMessage, DynamicMessage>newBuilder().setType(MethodDescriptor.MethodType.UNKNOWN)
.setFullMethodName(MethodDescriptor.generateFullMethodName(config.getService(), config.getMethod()))
.setRequestMarshaller(marshaller).setResponseMarshaller(marshaller).build();
Channel channel = createChannel();
return channel.newCall(methodDescriptor, CallOptions.DEFAULT);
}
private Descriptors.Descriptor getOutputTypeDescriptor(Config config, InputStream descriptorFile)
throws IOException, Descriptors.DescriptorValidationException {
DescriptorProtos.FileDescriptorSet fileDescriptorSet = DescriptorProtos.FileDescriptorSet
.parseFrom(descriptorFile);
DescriptorProtos.FileDescriptorProto fileProto = fileDescriptorSet.getFile(0);
Descriptors.FileDescriptor fileDescriptor = Descriptors.FileDescriptor.buildFrom(fileProto,
new Descriptors.FileDescriptor[0]);
List<Descriptors.MethodDescriptor> methods = fileDescriptor.findServiceByName(config.getService())
.getMethods();
return methods.stream().filter(method -> method.getName().equals(config.getMethod())).findFirst()
.orElseThrow(() -> new NoSuchElementException("No Method found")).getOutputType();
}
private ManagedChannel createChannel() {
URI requestURI = ((Route) exchange.getAttributes().get(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR)).getUri();
return createChannelChannel(requestURI.getHost(), requestURI.getPort());
}
private Function<JsonNode, DynamicMessage> callGRPCServer() {
return jsonRequest -> {
try {
byte[] request = objectWriter.writeValueAsBytes(jsonRequest);
return ClientCalls.blockingUnaryCall(clientCall, DynamicMessage.parseFrom(descriptor, request));
}
catch (IOException e) {
throw new RuntimeException(e);
}
};
}
private Function<DynamicMessage, Object> serialiseGRPCResponse() {
return gRPCResponse -> {
try {
return objectReader.readValue(gRPCResponse.toByteArray());
}
catch (IOException e) {
throw new RuntimeException(e);
}
};
}
private Flux<JsonNode> deserializeJSONRequest() {
return exchange.getRequest().getBody().mapNotNull(dataBufferBody -> {
if (dataBufferBody.capacity() == 0) {
return objectNode;
}
ResolvableType targetType = ResolvableType.forType(JsonNode.class);
return new Jackson2JsonDecoder().decode(dataBufferBody, targetType, null, null);
}).cast(JsonNode.class);
}
private Function<Object, DataBuffer> wrapGRPCResponse() {
return jsonResponse -> {
try {
return new NettyDataBufferFactory(new PooledByteBufAllocator())
.wrap(Objects.requireNonNull(new ObjectMapper().writeValueAsBytes(jsonResponse)));
}
catch (JsonProcessingException e) {
return new NettyDataBufferFactory(new PooledByteBufAllocator()).allocateBuffer();
}
};
}
private ManagedChannel createChannelChannel(String host, int port) {
try {
return NettyChannelBuilder.forAddress(host, port).useTransportSecurity()
.sslContext(GrpcSslContexts.forClient().trustManager(sslContext.getTrustManager()).build())
.negotiationType(TLS).build();
}
catch (SSLException e) {
throw new RuntimeException(e);
}
}
}
}

View File

@@ -43,6 +43,7 @@ import org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayF
import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory.Strategy;
import org.springframework.cloud.gateway.filter.factory.FallbackHeadersGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.JsonToGrpcGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactory;
@@ -210,6 +211,18 @@ public class GatewayFilterSpec extends UriSpec {
return filter(filterFactory.apply(this.routeBuilder.getId(), configConsumer));
}
/**
* A filter that transforms a JSON request into a gRPC one.
* @param protoDescriptor relative path to the proto descriptor file.
* @param protoFile relative path to the proto definition file.
* @param service fully qualified name of the service that will handle the request.
* @param method method name in the service that will handle the request.
*/
public GatewayFilterSpec jsonToGRPC(String protoDescriptor, String protoFile, String service, String method) {
return filter(getBean(JsonToGrpcGatewayFilterFactory.class).apply(c -> c.setMethod(method)
.setProtoDescriptor(protoDescriptor).setProtoFile(protoFile).setService(service)));
}
/**
* Maps headers from one name to another.
* @param fromHeader the header name of the original header.

View File

@@ -24,6 +24,12 @@
"description": "Enables the add-response-header filter.",
"defaultValue": "true"
},
{
"name": "spring.cloud.gateway.filter.json-to-grpc.enabled",
"type": "java.lang.Boolean",
"description": "Enables the JSON to gRPC filter.",
"defaultValue": "true"
},
{
"name": "spring.cloud.gateway.filter.modify-request-body.enabled",
"type": "java.lang.Boolean",

View File

@@ -82,6 +82,7 @@ public class DisableBuiltInFiltersTests {
"spring.cloud.gateway.filter.map-request-header.enabled=false",
"spring.cloud.gateway.filter.add-request-parameter.enabled=false",
"spring.cloud.gateway.filter.add-response-header.enabled=false",
"spring.cloud.gateway.filter.json-to-grpc.enabled=false",
"spring.cloud.gateway.filter.modify-request-body.enabled=false",
"spring.cloud.gateway.filter.dedupe-response-header.enabled=false",
"spring.cloud.gateway.filter.modify-response-body.enabled=false",

View File

@@ -30,6 +30,7 @@ import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayF
import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.FallbackHeadersGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.JsonToGrpcGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayFilterFactory;
import org.springframework.cloud.gateway.handler.predicate.AfterRoutePredicateFactory;
import org.springframework.cloud.gateway.handler.predicate.CloudFoundryRouteServiceRoutePredicateFactory;
@@ -88,13 +89,14 @@ class NameUtilsTests {
void shouldNormalizeFiltersNamesAsProperties() {
List<Class<? extends GatewayFilterFactory<?>>> predicates = Arrays.asList(
AddRequestHeaderGatewayFilterFactory.class, DedupeResponseHeaderGatewayFilterFactory.class,
FallbackHeadersGatewayFilterFactory.class, MapRequestHeaderGatewayFilterFactory.class);
FallbackHeadersGatewayFilterFactory.class, MapRequestHeaderGatewayFilterFactory.class,
JsonToGrpcGatewayFilterFactory.class);
List<String> resultNames = predicates.stream().map(NameUtils::normalizeFilterFactoryNameAsProperty)
.collect(Collectors.toList());
List<String> expectedNames = Arrays.asList("add-request-header", "dedupe-response-header", "fallback-headers",
"map-request-header");
"map-request-header", "json-to-grpc");
assertThat(resultNames).isEqualTo(expectedNames);
}