From 1843547e206561a49c31aa77255f873673b5c399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20C=2E=20R=C3=ADos?= Date: Mon, 18 Jul 2022 18:54:10 +0200 Subject: [PATCH] Adding native JSON to gRPC filter (#2657) --- docs/src/main/asciidoc/_configprops.adoc | 21 +- .../main/asciidoc/spring-cloud-gateway.adoc | 80 +++++ .../grpc/pom.xml | 21 +- .../gateway/tests/grpc/GRPCApplication.java | 37 ++- .../grpc/src/main/proto/hello.pb | 11 + .../grpc/src/main/proto/hello.proto | 6 +- .../tests/grpc/GRPCApplicationTests.java | 3 - .../grpc/JsonToGrpcApplicationTests.java | 97 ++++++ .../grpc/src/test/resources/application.yml | 2 +- spring-cloud-gateway-server/pom.xml | 29 ++ .../cloud/gateway/config/GRPCSSLContext.java | 36 ++ .../config/GatewayAutoConfiguration.java | 29 ++ .../JsonToGrpcGatewayFilterFactory.java | 314 ++++++++++++++++++ .../route/builder/GatewayFilterSpec.java | 13 + ...itional-spring-configuration-metadata.json | 6 + .../DisableBuiltInFiltersTests.java | 1 + .../cloud/gateway/support/NameUtilsTests.java | 6 +- 17 files changed, 685 insertions(+), 27 deletions(-) create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.pb create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/JsonToGrpcApplicationTests.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GRPCSSLContext.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/JsonToGrpcGatewayFilterFactory.java diff --git a/docs/src/main/asciidoc/_configprops.adoc b/docs/src/main/asciidoc/_configprops.adoc index 2b9d236f..2a7771a2 100644 --- a/docs/src/main/asciidoc/_configprops.adoc +++ b/docs/src/main/asciidoc/_configprops.adoc @@ -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. diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 785de171..0285d51a 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -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`. diff --git a/spring-cloud-gateway-integration-tests/grpc/pom.xml b/spring-cloud-gateway-integration-tests/grpc/pom.xml index af788166..29bc5339 100644 --- a/spring-cloud-gateway-integration-tests/grpc/pom.xml +++ b/spring-cloud-gateway-integration-tests/grpc/pom.xml @@ -11,6 +11,7 @@ Spring Cloud Gateway gRPC Integration Test + 1.47.0 @@ -29,26 +30,38 @@ org.springframework.cloud spring-cloud-starter-gateway - io.grpc grpc-netty-shaded - 1.44.0 + ${grpc.version} io.grpc grpc-protobuf - 1.44.0 + ${grpc.version} io.grpc grpc-stub - 1.44.0 + ${grpc.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-protobuf + + + com.fasterxml.jackson.core + jackson-databind io.netty netty-tcnative-boringssl-static + + org.apache.httpcomponents + httpclient + test + org.springframework.boot spring-boot-starter-test diff --git a/spring-cloud-gateway-integration-tests/grpc/src/main/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplication.java b/spring-cloud-gateway-integration-tests/grpc/src/main/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplication.java index 5436d0cc..b2dc170c 100644 --- a/spring-cloud-gateway-integration-tests/grpc/src/main/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplication.java +++ b/spring-cloud-gateway-integration-tests/grpc/src/main/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplication.java @@ -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.context.annotation.Bean; @@ -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 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(); diff --git a/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.pb b/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.pb new file mode 100644 index 00000000..584ded1a --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.pb @@ -0,0 +1,11 @@ + +ö + hello.proto"H + HelloRequest + firstName ( R firstName +lastName ( RlastName"+ + HelloResponse +greeting ( Rgreeting26 + HelloService& +hello .HelloRequest.HelloResponseB0 +,org.springframework.cloud.gateway.tests.grpcPbproto3 \ No newline at end of file diff --git a/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.proto b/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.proto index 0a7769a7..24a34749 100644 --- a/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.proto +++ b/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.proto @@ -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 { diff --git a/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplicationTests.java b/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplicationTests.java index f4e9ae34..0c6289ed 100644 --- a/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplicationTests.java +++ b/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplicationTests.java @@ -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.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 { diff --git a/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/JsonToGrpcApplicationTests.java b/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/JsonToGrpcApplicationTests.java new file mode 100644 index 00000000..f7ee4620 --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/JsonToGrpcApplicationTests.java @@ -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 socketFactoryRegistry = RegistryBuilder.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); + } + +} diff --git a/spring-cloud-gateway-integration-tests/grpc/src/test/resources/application.yml b/spring-cloud-gateway-integration-tests/grpc/src/test/resources/application.yml index ba0449a3..7cc4ef2e 100644 --- a/spring-cloud-gateway-integration-tests/grpc/src/test/resources/application.yml +++ b/spring-cloud-gateway-integration-tests/grpc/src/test/resources/application.yml @@ -24,4 +24,4 @@ spring: logging: level: reactor.netty: DEBUG - org.springframework.cloud.gateway.filter: TRACE + org.springframework.cloud.gateway.filter: DEBUG diff --git a/spring-cloud-gateway-server/pom.xml b/spring-cloud-gateway-server/pom.xml index caf19e72..d3756764 100644 --- a/spring-cloud-gateway-server/pom.xml +++ b/spring-cloud-gateway-server/pom.xml @@ -16,6 +16,7 @@ Spring Cloud Gateway Server ${basedir}/.. + 1.47.0 @@ -81,6 +82,34 @@ io.projectreactor.addons reactor-extra + + com.fasterxml.jackson.dataformat + jackson-dataformat-protobuf + true + + + com.fasterxml.jackson.core + jackson-databind + true + + + io.grpc + grpc-netty-shaded + true + ${grpc.version} + + + io.grpc + grpc-protobuf + true + ${grpc.version} + + + io.grpc + grpc-stub + true + ${grpc.version} + org.springframework.boot spring-boot-autoconfigure-processor diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GRPCSSLContext.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GRPCSSLContext.java new file mode 100644 index 00000000..bbdb34a6 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GRPCSSLContext.java @@ -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; + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index aebf9efb..1dea2a08 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -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(); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/JsonToGrpcGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/JsonToGrpcGatewayFilterFactory.java new file mode 100644 index 00000000..adec2f18 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/JsonToGrpcGatewayFilterFactory.java @@ -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 { + + 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 shortcutFieldOrder() { + return Arrays.asList("protoDescriptor", "protoFile", "service", "method"); + } + + @Override + public GatewayFilter apply(Config config) { + GatewayFilter filter = new GatewayFilter() { + @Override + public Mono 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 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 writeWith(Publisher 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 createClientCallForType(Config config, + Descriptors.Descriptor outputType) { + MethodDescriptor.Marshaller marshaller = ProtoUtils + .marshaller(DynamicMessage.newBuilder(outputType).build()); + MethodDescriptor methodDescriptor = MethodDescriptor + .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 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 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 serialiseGRPCResponse() { + return gRPCResponse -> { + try { + return objectReader.readValue(gRPCResponse.toByteArray()); + } + catch (IOException e) { + throw new RuntimeException(e); + } + }; + } + + private Flux 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 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); + } + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index 25b9b689..c49893b6 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -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. diff --git a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 807ed20c..5dbdd521 100644 --- a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -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", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java index eddd89c0..9eea24b1 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java @@ -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", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java index c99b6d70..8b3b8865 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java @@ -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>> predicates = Arrays.asList( AddRequestHeaderGatewayFilterFactory.class, DedupeResponseHeaderGatewayFilterFactory.class, - FallbackHeadersGatewayFilterFactory.class, MapRequestHeaderGatewayFilterFactory.class); + FallbackHeadersGatewayFilterFactory.class, MapRequestHeaderGatewayFilterFactory.class, + JsonToGrpcGatewayFilterFactory.class); List resultNames = predicates.stream().map(NameUtils::normalizeFilterFactoryNameAsProperty) .collect(Collectors.toList()); List expectedNames = Arrays.asList("add-request-header", "dedupe-response-header", "fallback-headers", - "map-request-header"); + "map-request-header", "json-to-grpc"); assertThat(resultNames).isEqualTo(expectedNames); }