From af09c72a9a5dbc2240fd5d3de8293a9b7b80750d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20C=2E=20R=C3=ADos?= Date: Wed, 13 Oct 2021 17:30:12 +0200 Subject: [PATCH] Adding initial gRPC support (#2388) Passing TE trailers header through Adding grpc-status as response trailer to ensure the right end of stream Fixes gh-40 --- .../grpc/pom.xml | 104 +++++++++++++++ .../gateway/tests/grpc/GRPCApplication.java | 119 ++++++++++++++++++ .../grpc/src/main/proto/hello.proto | 16 +++ .../grpc/src/main/resources/application.yml | 0 .../grpc/src/main/resources/certificate.pem | 21 ++++ .../grpc/src/main/resources/keystore.p12 | Bin 0 -> 2581 bytes .../grpc/src/main/resources/private.key | 32 +++++ .../tests/grpc/GRPCApplicationTests.java | 83 ++++++++++++ .../grpc/src/test/resources/application.yml | 27 ++++ .../pom.xml | 1 + .../config/GatewayAutoConfiguration.java | 15 +++ .../gateway/config/GatewayProperties.java | 3 +- .../headers/GRPCRequestHeadersFilter.java | 61 +++++++++ .../headers/GRPCResponseHeadersFilter.java | 65 ++++++++++ .../config/GatewayAutoConfigurationTests.java | 26 ++++ .../headers/GRPCRequestHeadersFilterTest.java | 58 +++++++++ 16 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-gateway-integration-tests/grpc/pom.xml create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/main/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplication.java create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.proto create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/main/resources/application.yml create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/main/resources/certificate.pem create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/main/resources/keystore.p12 create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/main/resources/private.key create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplicationTests.java create mode 100644 spring-cloud-gateway-integration-tests/grpc/src/test/resources/application.yml create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCResponseHeadersFilter.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilterTest.java diff --git a/spring-cloud-gateway-integration-tests/grpc/pom.xml b/spring-cloud-gateway-integration-tests/grpc/pom.xml new file mode 100644 index 00000000..9cf1131c --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + grpc + jar + + Spring Cloud Gateway gRPC Integration Test + Spring Cloud Gateway gRPC Integration Test + + + + + + org.springframework.cloud + spring-cloud-gateway-integration-tests + 3.1.0-SNAPSHOT + .. + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + io.grpc + grpc-netty-shaded + 1.41.0 + + + io.grpc + grpc-protobuf + 1.41.0 + + + io.grpc + grpc-stub + 1.41.0 + + + io.netty + netty-tcnative-boringssl-static + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.assertj + assertj-core + test + + + + + + kr.motd.maven + os-maven-plugin + 1.6.2 + + + + + maven-deploy-plugin + + true + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:3.17.3:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:1.41.0:exe:${os.detected.classifier} + + + + + compile + compile-custom + + + + + + + + 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 new file mode 100644 index 00000000..5436d0cc --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/main/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplication.java @@ -0,0 +1,119 @@ +/* + * 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.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import io.grpc.Grpc; +import io.grpc.Server; +import io.grpc.ServerCredentials; +import io.grpc.TlsServerCredentials; +import io.grpc.stub.StreamObserver; + +import org.springframework.boot.ApplicationArguments; +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.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.SocketUtils; + +/** + * @author Alberto C. Ríos + */ +@SpringBootConfiguration +@EnableAutoConfiguration +public class GRPCApplication { + + private static final int GRPC_SERVER_PORT = SocketUtils.findAvailableTcpPort(); + + public static void main(String[] args) { + SpringApplication.run(GRPCApplication.class, args); + } + + @Bean + public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { + return builder.routes().route("grpc", r -> r.predicate(p -> true).uri("https://localhost:" + GRPC_SERVER_PORT)) + .build(); + } + + @Component + static class GRPCServer implements ApplicationRunner { + + private Server server; + + @Override + public void run(ApplicationArguments args) throws Exception { + final GRPCServer server = new GRPCServer(); + server.start(); + } + + private void start() throws Exception { + /* The port on which the server should run */ + ServerCredentials creds = createServerCredentials(); + server = Grpc.newServerBuilderForPort(GRPC_SERVER_PORT, creds).addService(new HelloService()).build() + .start(); + + System.out.println("Starting server in port " + GRPC_SERVER_PORT); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + GRPCServer.this.stop(); + } + catch (InterruptedException e) { + e.printStackTrace(System.err); + } + })); + } + + private ServerCredentials createServerCredentials() throws IOException { + File privateKey = new ClassPathResource("private.key").getFile(); + File certChain = new ClassPathResource("certificate.pem").getFile(); + return TlsServerCredentials.create(certChain, privateKey); + } + + private void stop() throws InterruptedException { + if (server != null) { + server.shutdown().awaitTermination(30, TimeUnit.SECONDS); + } + } + + static class HelloService extends HelloServiceGrpc.HelloServiceImplBase { + + @Override + public void hello(HelloRequest request, StreamObserver responseObserver) { + + String greeting = "Hello, " + request.getFirstName() + " " + request.getLastName(); + System.out.println(greeting); + + HelloResponse response = HelloResponse.newBuilder().setGreeting(greeting).build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } + + } + + } + +} 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 new file mode 100644 index 00000000..0a7769a7 --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/main/proto/hello.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +option java_multiple_files = true; +option java_package = "org.springframework.cloud.gateway.tests.grpc"; + +message HelloRequest { + string firstName = 1; + string lastName = 2; +} + +message HelloResponse { + string greeting = 1; +} + +service HelloService { + rpc hello(HelloRequest) returns (HelloResponse); +} diff --git a/spring-cloud-gateway-integration-tests/grpc/src/main/resources/application.yml b/spring-cloud-gateway-integration-tests/grpc/src/main/resources/application.yml new file mode 100644 index 00000000..e69de29b diff --git a/spring-cloud-gateway-integration-tests/grpc/src/main/resources/certificate.pem b/spring-cloud-gateway-integration-tests/grpc/src/main/resources/certificate.pem new file mode 100644 index 00000000..808fb06c --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/main/resources/certificate.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIEIon96DANBgkqhkiG9w0BAQsFADBsMRAwDgYDVQQGEwdV +bmtub3duMRAwDgYDVQQIEwdVbmtub3duMRAwDgYDVQQHEwdVbmtub3duMRAwDgYD +VQQKEwdVbmtub3duMRAwDgYDVQQLEwdVbmtub3duMRAwDgYDVQQDEwdVbmtub3du +MB4XDTIxMDkyNzE2NDQwNVoXDTMxMDkyNTE2NDQwNVowbDEQMA4GA1UEBhMHVW5r +bm93bjEQMA4GA1UECBMHVW5rbm93bjEQMA4GA1UEBxMHVW5rbm93bjEQMA4GA1UE +ChMHVW5rbm93bjEQMA4GA1UECxMHVW5rbm93bjEQMA4GA1UEAxMHVW5rbm93bjCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIwevGHY30YdoUdCSB/5q7/F +c0KHetdjb71G2u6vFdeNvSwMpCV8Z1JznOJ/1zuY+0Z105QCPM7fi1ACi+tqxDR+ +L7yjUHhUEMTiGgCHcYJIZZCYfWS3BQXVxgORXhDv7RduCUaCLnkaFY++iPMTUy0C +VxIkplIEhAmqcikIgWaa5ZjBkegKgahlPQLKlfD4Rz/kq2P+LLYFsHNNdKfWv6XQ +u4LMw7ZEAJtfdpaMTzmtQipbTt6Dh87vIa0CIVnCPdlQ3o/5WeaxEA1pnfOLas07 +1VdHih2nC5vHhQcTPQDfa+uwzQvzHrchjuvMUUZaCYJzuT0G6nbGBba54EBT7yUC +AwEAAaMhMB8wHQYDVR0OBBYEFKOHmfytP5ab2C4iFHlSklu6tCcuMA0GCSqGSIb3 +DQEBCwUAA4IBAQAdgWwdOtRbI796Z22weTBc0/tM8kLc6G0raNb08WyZMPZVki04 +jPh73pPQCgYeI/pq5JqH46KgvehmygTzpWDAFIllW0kgABVw3Nu6duV+blt1JG8T +lWP7t5A+qDXgPDm3I5diii7O1YlLB3I37XiBdEV/+2WmF1VGQ7uBWAv+uotQeuW2 +JvHOr4ICOiW45TzRYtAbzWukSYKg/A7lwBs7HE9KVomUxNrkD+7+ugRuy/31pyen +pHsEJQpx5juFRE222B6GXmX0w9xLIOapytl4EoPUx3K8Ecc+yI2q00UUC43x0v28 +c05YqwRZ5vp+jUnRxkxaz85YdArfR7QFYWtO +-----END CERTIFICATE----- diff --git a/spring-cloud-gateway-integration-tests/grpc/src/main/resources/keystore.p12 b/spring-cloud-gateway-integration-tests/grpc/src/main/resources/keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..b22b3349cbbc5dd0cab17271ce3403608c94d198 GIT binary patch literal 2581 zcmY+Ec{~%0AIG=Z%-oY(wUAtsZLaXJ-ppR`o3Q8&*%I7^NphL!a+bT6b07D4O2<6NZAwsLV<-8 zSSy$UYy6GtQ4~nPzaoemm;!P7jqQF9o}2G~SNyy{P$32U3q=8MqLjIL|Brt^mjR1H zEOV%}HZLXJ*>7%3=0a=4Wh+mBxB%UDU<&w^Af`hvty#ibpZ*N?m5oPv6 zZlaStuPClD*`f3bHyJ_mv&Nqw${HWzOKej2{aj2#fttn2=Hm;QGs$x_uQl{hP6u=*$>y5o)juY`o9Ww4x#0jh72P%<#MCCy%m51rj&_j1%XZjsP>y&pl71}NiJRcloUl{euFz$Ar>`6;} zx!!{q3g+b!S4i)xi~1k|aHQ^N8;(3_5n!LQmbG^6ljDJl_plDlGTCPFNnelBR(9-# z-kK|-pAtf>{MD?9!I~8RP|FmMNHy6(`X>Com(e%Usj)+bQbbO>{ zMLRjFw9RJ(vy+LF4Q1!jKg{y0tg91lXC;Y{?lY6qqBm%=@t^e%$5eFfe1~lro8<3U z1uq5Nx%Bkv1NxKIm(sr`avW2#K)ZQ*lIa4PMI;9Q`uAezTAP49q8(qK_jD)C4$_uC zQ_7v<3J>c0`VVYT(5#6OBRX?3Si9ngS?Cxci zg`9;;lbh7{pQ&Y+1SRPo5eehnSI%fTrqX-1l&8LD(t^ zjG1^$GieWZJPjsU@uWYT044X$$PLl{0e4 z@X-vZcqf-dk|e7pwOK#MJ9l$k8IS0(sPd>1(l}|Bi}c8Nh)4xY2eI=4z2|2$Zsj_lT$vto= zUGb#wv^qYeV_bqE}i|wKoZnQR)3AdvCr;IT6 zoPHi;VS4$cd&AKOPmzEifot*OYj1{!7O!ia^#4N8WC6U^U*@)X?KS5o^dmKxqxx^Xt+@l9z_7yIsCge>RQ-OIb*WNzJG!HLD8>PmUU0#Z4CL8Ssz48Hb z1FMi7^%vK7&21a3-TPNJC=gH9e4G5<`F_0g{ewccOa}La2)LV^VkJW14~ZCOL&fTv zT7+j$3jzIw=?AfW+>|w!Fky3$RTj)8Q4wkbI3R>(I=JJcRS(7YESt8l8SeDEr+63nXFT!v$&5j+gQAk2g}ay4jEkv;0{il6 z8|A&PKOUOpj+g)>wVV858ZrNB;6X`8NADT&Km=%Z6niu?#ky|Gr^b>xt+#eaDQR%1 zL*tRz;ajwA&Jor^4X{M7)=g``QAZSbsv>$X>8u2)zLjA3;wD*G*ykLPJP-jHGU$`S z4P*I3kerssZ9-v{wiS7d^4E=XDfAviqykn2RKU*6i_!wu;&97GHUAB)#~CagTY z>Z-9>J!Lw(EzclVlyAy`Yek};>w^792a9B~{`5@e6p9Ged!Bb4t+L+g;~DR3eYqQV z85)^J3L)wqAbYD+&kuVvU9>e$3uP4*k!fMRc|5YRYQ(+px4VX0*HyzX;j~1}4vu^L zqP{xY=)Rito|6KR&W~Bh&bL_1G&$H?o@ z?9?D##NF^;NpL87%1VBV#!{Yf6P#y)n~Y-Mj)eN`B?z17H|bZ=sX@34!B zJYN{3hfLEIE5HShL~*&PDoiG`SHFy#)qU)sF|Ed3u*(!lI;_~`Sf$v~eHiX~;O9qO z(Gq`-@7eE-a^yXlj^*(IEclj4#!*Oh4Gvy2ZfUso7&v_R_}lK>WM9S)IV#~@({{nu zi)#mPQ|3uEu{kv`IS|SaC5wV_LsSL1K++HZShS+3*p9ghTDS6c_7@9IFdm)USOJS} cxH0#^_;x?^B%$x{kbuF +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCMHrxh2N9GHaFH +Qkgf+au/xXNCh3rXY2+9RtrurxXXjb0sDKQlfGdSc5zif9c7mPtGddOUAjzO34tQ +AovrasQ0fi+8o1B4VBDE4hoAh3GCSGWQmH1ktwUF1cYDkV4Q7+0XbglGgi55GhWP +vojzE1MtAlcSJKZSBIQJqnIpCIFmmuWYwZHoCoGoZT0CypXw+Ec/5Ktj/iy2BbBz +TXSn1r+l0LuCzMO2RACbX3aWjE85rUIqW07eg4fO7yGtAiFZwj3ZUN6P+VnmsRAN +aZ3zi2rNO9VXR4odpwubx4UHEz0A32vrsM0L8x63IY7rzFFGWgmCc7k9Bup2xgW2 +ueBAU+8lAgMBAAECggEAGu2xQJDAYCZDn4FCgTqnYkSdIRUOa6SFjfe3DZYCeZmY +2IVZaobdCICFjxYIlECTUfhFADXp38wgZvEGWOj86iWyIOu2BFoLmvrlCmL9Uo99 +TWuw9ZEi2vs5gegHDvQ9OXqBN9a+/bEgoa55fVWib4z6lNcMS8joYz8pj28+ByzE +LW0/3T3p6beM2fUcCJWn3d2M3wUgSuXmcdjVXJSkhEwKTVcc4vTTcOeF6xb2VZ/g +Pozv/39G1qZ+QtM58yBiqnJ1Z2gtAk71l/1ztQa3uY22gzw8Kj5dmqcHgdiN5DWI +bNE+k5Q93FzUmZNYPzmY6YVkdEzaNMtmi96sdtEu5QKBgQD36YdGXUyoRXeNDRuc +yMXr6j/9/ewii+byHhFoUvjLuXWIQ06V+nOtYqohg/zgGIiC5LQ8EB0uFZDMhbDf +kSwtoXbpUDLYD2OPgIyLaPqHiYQ9BampbUz5vHlfYLr0vB+Xp5r22Eb6nDQRRtb5 +EXHoYgAokgpYdeTIcdKRcB/2DwKBgQCQsPePeu7PagM9vYEo4zfbFxfY3Qkr4lOQ +BCZ/tgsS0b0jAAxpfUH0/3O5oXizmB/5K7vgKqGnTuBWJJ/hdCWq27FkKxJ+8ejU +8V9TFd5VQ89VeB5OekZwPks8vftwzW4L82ZRW5hvyQB0jPR+lwqjrNqds+xxH7i+ +c+RFx15biwKBgQCgkGGK0zao7YUGl+zAWNDHgQo9KM5deZr0SUEg/kwhNlbHEEC/ +plxxeauS1XdcdMdFb3bER/N+O31y2Uu7IL0qOJ9ZcRXdFep3sNxWFoHcctZw51AB +accnIEjD21R62bTkditJoL4n5i9a2TS2T/QkfAR6QkvtCz5IDGBCzgoFRQKBgC51 +VBfy3gklPgMt/PHW+1FSuep9FnvLwQ8F9iKdnjKdu8AoPNQGTw5Ok6bwDOSFnQaR +n1Kb/anN7sRaICfw9kNFJVFHbznpjNwK4JO5+tif3EvSNND3+/QAXIIVck3G+GXH +8nt/EJQcExRZSgv3jYf+cXeflPTBvb0RUyOAn3B/AoGAM9aUi2dg3XjlDkZahQlY +P5QNIr9BY25Ordga3GLwfR6rE+jiWTeTmreXBSJ7nvcaEQUNyFMX9n3v/QI84etk +lMJkZ4o2TgzRnCsFJoBV36Ihsa6B5uXVWAvMRLKvwKpptKXfO0TnhUg7oKtN4t3a +/FzAOS2Eu1PFP27z74gAMKY= +-----END PRIVATE KEY----- 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 new file mode 100644 index 00000000..f4e9ae34 --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/test/java/org/springframework/cloud/gateway/tests/grpc/GRPCApplicationTests.java @@ -0,0 +1,83 @@ +/* + * 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.cert.X509Certificate; + +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import io.grpc.ManagedChannel; +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; + +import static io.grpc.netty.shaded.io.grpc.netty.NegotiationType.TLS; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +/** + * @author Alberto C. Ríos + */ +@ExtendWith(OutputCaptureExtension.class) +@SpringBootTest(classes = GRPCApplication.class, webEnvironment = WebEnvironment.RANDOM_PORT) +@DirtiesContext +public class GRPCApplicationTests { + + @LocalServerPort + private int port; + + @Test + public void gRPCUnaryCalShouldReturnResponse() throws SSLException { + ManagedChannel channel = createSecuredChannel(port); + + final HelloResponse response = HelloServiceGrpc.newBlockingStub(channel) + .hello(HelloRequest.newBuilder().setFirstName("Sir").setLastName("FromClient").build()); + + Assertions.assertThat(response.getGreeting()).isEqualTo("Hello, Sir FromClient"); + } + + private ManagedChannel createSecuredChannel(int port) throws SSLException { + TrustManager[] trustAllCerts = createTrustAllTrustManager(); + + return NettyChannelBuilder.forAddress("localhost", port).useTransportSecurity() + .sslContext(GrpcSslContexts.forClient().trustManager(trustAllCerts[0]).build()).negotiationType(TLS) + .build(); + } + + private TrustManager[] createTrustAllTrustManager() { + return 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) { + } + } }; + } + +} 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 new file mode 100644 index 00000000..ba0449a3 --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/test/resources/application.yml @@ -0,0 +1,27 @@ +server: + http2: + enabled: true + ssl: + key-store-type: PKCS12 + key-store: classpath:keystore.p12 + key-store-password: password + key-password: password + enabled: true +spring: + cloud: + gateway: + httpserver: + wiretap: true + httpclient: + wiretap: true + ssl: + key-store-type: PKCS12 + key-store: classpath:keystore.p12 + key-store-password: password + key-password: password + use-insecure-trust-manager: true + +logging: + level: + reactor.netty: DEBUG + org.springframework.cloud.gateway.filter: TRACE diff --git a/spring-cloud-gateway-integration-tests/pom.xml b/spring-cloud-gateway-integration-tests/pom.xml index f0197462..76b5c350 100644 --- a/spring-cloud-gateway-integration-tests/pom.xml +++ b/spring-cloud-gateway-integration-tests/pom.xml @@ -21,6 +21,7 @@ + grpc http2 mvc-failure-analyzer 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 60c9634b..2df6f8e5 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 @@ -109,6 +109,8 @@ import org.springframework.cloud.gateway.filter.factory.rewrite.MessageBodyEncod import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter; +import org.springframework.cloud.gateway.filter.headers.GRPCRequestHeadersFilter; +import org.springframework.cloud.gateway.filter.headers.GRPCResponseHeadersFilter; import org.springframework.cloud.gateway.filter.headers.HttpHeadersFilter; import org.springframework.cloud.gateway.filter.headers.RemoveHopByHopHeadersFilter; import org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter; @@ -176,6 +178,7 @@ import static org.springframework.cloud.gateway.config.HttpClientProperties.Pool * @author Spencer Gibb * @author Ziemowit Stolarczyk * @author Mete Alpaslan Katırcıoğlu + * @author Alberto C. Ríos */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "spring.cloud.gateway.enabled", matchIfMissing = true) @@ -290,6 +293,18 @@ public class GatewayAutoConfiguration { return new XForwardedHeadersFilter(); } + @Bean + @ConditionalOnProperty(name = "server.http2.enabled", matchIfMissing = true) + public GRPCRequestHeadersFilter gRPCRequestHeadersFilter() { + return new GRPCRequestHeadersFilter(); + } + + @Bean + @ConditionalOnProperty(name = "server.http2.enabled", matchIfMissing = true) + public GRPCResponseHeadersFilter gRPCResponseHeadersFilter() { + return new GRPCResponseHeadersFilter(); + } + // GlobalFilter beans @Bean diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java index 594f6d6e..46b07d61 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java @@ -60,7 +60,8 @@ public class GatewayProperties { private List defaultFilters = new ArrayList<>(); private List streamingMediaTypes = Arrays.asList(MediaType.TEXT_EVENT_STREAM, - MediaType.APPLICATION_STREAM_JSON); + MediaType.APPLICATION_STREAM_JSON, new MediaType("application", "grpc"), + new MediaType("application", "grpc+protobuf"), new MediaType("application", "grpc+json")); /** * Option to fail on route definition errors, defaults to true. Otherwise, a warning diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java new file mode 100644 index 00000000..3a98a88f --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java @@ -0,0 +1,61 @@ +/* + * 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.headers; + +import java.util.List; +import java.util.Map; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Alberto C. Ríos + */ +public class GRPCRequestHeadersFilter implements HttpHeadersFilter, Ordered { + + @Override + public HttpHeaders filter(HttpHeaders headers, ServerWebExchange exchange) { + HttpHeaders updated = new HttpHeaders(); + + for (Map.Entry> entry : headers.entrySet()) { + updated.addAll(entry.getKey(), entry.getValue()); + } + + // https://datatracker.ietf.org/doc/html/rfc7540#section-8.1.2.2 + if (isGRPC(headers.getFirst(HttpHeaders.CONTENT_TYPE))) { + updated.add("te", "trailers"); + } + return updated; + } + + private boolean isGRPC(String contentTypeValue) { + return StringUtils.startsWithIgnoreCase(contentTypeValue, "application/grpc"); + } + + @Override + public boolean supports(Type type) { + return Type.REQUEST.equals(type); + } + + @Override + public int getOrder() { + return 0; + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCResponseHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCResponseHeadersFilter.java new file mode 100644 index 00000000..f8373677 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCResponseHeadersFilter.java @@ -0,0 +1,65 @@ +/* + * 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.headers; + +import reactor.netty.http.server.HttpServerResponse; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.AbstractServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Alberto C. Ríos + */ +public class GRPCResponseHeadersFilter implements HttpHeadersFilter, Ordered { + + @Override + public HttpHeaders filter(HttpHeaders headers, ServerWebExchange exchange) { + ServerHttpResponse response = exchange.getResponse(); + HttpHeaders responseHeaders = response.getHeaders(); + if (isGRPC(exchange)) { + String trailerHeaderValue = "grpc-status"; + String originalTrailerHeaderValue = responseHeaders.getFirst(HttpHeaders.TRAILER); + if (originalTrailerHeaderValue != null) { + trailerHeaderValue += "," + originalTrailerHeaderValue; + } + responseHeaders.set(HttpHeaders.TRAILER, trailerHeaderValue); + ((HttpServerResponse) ((AbstractServerHttpResponse) response).getNativeResponse()) + .trailerHeaders(h -> h.set("grpc-status", "0")); + } + return headers; + } + + private boolean isGRPC(ServerWebExchange exchange) { + String contentTypeValue = exchange.getRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + return StringUtils.startsWithIgnoreCase(contentTypeValue, "application/grpc"); + } + + @Override + public boolean supports(Type type) { + return Type.RESPONSE.equals(type); + } + + @Override + public int getOrder() { + return 0; + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java index cbff76b2..cbaac27e 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java @@ -40,6 +40,8 @@ import org.springframework.boot.test.context.runner.ReactiveWebApplicationContex import org.springframework.cloud.gateway.actuate.GatewayControllerEndpoint; import org.springframework.cloud.gateway.actuate.GatewayLegacyControllerEndpoint; import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.headers.GRPCRequestHeadersFilter; +import org.springframework.cloud.gateway.filter.headers.GRPCResponseHeadersFilter; import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec; import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; @@ -231,6 +233,30 @@ public class GatewayAutoConfigurationTests { assertThat(spec2.protocols()).isNull(); } + @Test + public void gRPCFiltersConfiguredWhenHTTP2Enabled() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class, + HttpClientCustomizedConfig.class, ServerPropertiesConfig.class)) + .withPropertyValues("server.http2.enabled=true").run(context -> { + assertThat(context).hasSingleBean(GRPCRequestHeadersFilter.class); + assertThat(context).hasSingleBean(GRPCResponseHeadersFilter.class); + }); + } + + @Test + public void gRPCFiltersNotConfiguredWhenHTTP2Disabled() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class, + HttpClientCustomizedConfig.class, ServerPropertiesConfig.class)) + .withPropertyValues("server.http2.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(GRPCRequestHeadersFilter.class); + assertThat(context).doesNotHaveBean(GRPCResponseHeadersFilter.class); + }); + } + @Configuration @EnableConfigurationProperties(ServerProperties.class) protected static class ServerPropertiesConfig { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilterTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilterTest.java new file mode 100644 index 00000000..cf9121fb --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilterTest.java @@ -0,0 +1,58 @@ +/* + * 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.headers; + +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Alberto C. Ríos + */ +public class GRPCRequestHeadersFilterTest { + + @Test + public void shouldIncludeTrailersHeaderIfGRPC() { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .header(HttpHeaders.CONTENT_TYPE, "application/grpc").build(); + + GRPCRequestHeadersFilter filter = new GRPCRequestHeadersFilter(); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys("te"); + + assertThat(headers.getFirst("te")).isEqualTo("trailers"); + } + + @Test + public void shouldNotIncludeTrailersHeaderIfNotGRPC() { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .header(HttpHeaders.CONTENT_TYPE, "application/json").build(); + + GRPCRequestHeadersFilter filter = new GRPCRequestHeadersFilter(); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).doesNotContainKeys("te"); + } + +}