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 00000000..b22b3349 Binary files /dev/null and b/spring-cloud-gateway-integration-tests/grpc/src/main/resources/keystore.p12 differ diff --git a/spring-cloud-gateway-integration-tests/grpc/src/main/resources/private.key b/spring-cloud-gateway-integration-tests/grpc/src/main/resources/private.key new file mode 100644 index 00000000..1db2b3cb --- /dev/null +++ b/spring-cloud-gateway-integration-tests/grpc/src/main/resources/private.key @@ -0,0 +1,32 @@ +Bag Attributes + friendlyName: mykey + localKeyID: 54 69 6D 65 20 31 36 33 32 38 32 34 38 37 31 33 33 35 +Key Attributes: +-----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"); + } + +}