From c35b21adb40fc7e5a267523fd542f6946f60af94 Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Tue, 10 Feb 2026 18:50:58 -0800 Subject: [PATCH] Add Spring gRPC client support Add support for Spring gRPC client applications. Closes gh-49045 Co-authored-by: Phillip Webb --- documentation/spring-boot-docs/build.gradle | 1 + .../build.gradle | 3 + module/spring-boot-grpc-client/build.gradle | 53 +++ ...positeChannelFactoryAutoConfiguration.java | 73 ++++ ...onalOnGrpcClientChannelFactoryEnabled.java | 40 ++ .../GrpcChannelBuilderCustomizers.java | 113 +++++ .../GrpcChannelFactoryCustomizer.java | 38 ++ .../GrpcClientAutoConfiguration.java | 98 +++++ .../GrpcClientCodecConfiguration.java | 83 ++++ ...cClientDefaultServiceConfigCustomizer.java | 49 +++ .../autoconfigure/GrpcClientProperties.java | 369 ++++++++++++++++ .../InProcessGrpcClientConfiguration.java | 56 +++ .../NettyGrpcClientConfiguration.java | 60 +++ .../PropertiesChannelCredentialsProvider.java | 65 +++ ...ropertiesGrpcChannelBuilderCustomizer.java | 94 +++++ ...cClientDefaultServiceConfigCustomizer.java | 44 ++ .../PropertiesVirtualTargets.java | 59 +++ .../ShadedNettyGrpcClientConfiguration.java | 58 +++ .../client/autoconfigure/package-info.java | 23 + ...itional-spring-configuration-metadata.json | 23 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + ...eChannelFactoryAutoConfigurationTests.java | 140 +++++++ .../GrpcChannelBuilderCustomizersTests.java | 242 +++++++++++ .../GrpcClientAutoConfigurationTests.java | 396 ++++++++++++++++++ .../GrpcClientPropertiesTests.java | 158 +++++++ .../GrpcCodecConfigurationTests.java | 100 +++++ ...ertiesChannelCredentialsProviderTests.java | 179 ++++++++ ...tiesGrpcChannelBuilderCustomizerTests.java | 209 +++++++++ .../PropertiesVirtualTargetsTests.java | 129 ++++++ .../test/scan/DummyBlockingGrpc.java | 46 ++ .../test/scan/DummyBlockingV2Grpc.java | 46 ++ .../autoconfigure/test/scan/package-info.java | 20 + .../spring-boot-dependencies/build.gradle | 2 + settings.gradle | 3 + .../build.gradle | 63 +++ .../SampleGrpcClientApplication.java | 46 ++ .../smoketest/grpcclient/package-info.java | 20 + .../src/main/proto/hello.proto | 17 + .../src/main/resources/application.yaml | 6 + .../SampleGrpcClientApplicationTests.java | 89 ++++ .../build.gradle | 28 ++ 41 files changed, 3343 insertions(+) create mode 100644 module/spring-boot-grpc-client/build.gradle create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java create mode 100644 module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java create mode 100644 starter/spring-boot-starter-grpc-client/build.gradle diff --git a/documentation/spring-boot-docs/build.gradle b/documentation/spring-boot-docs/build.gradle index 48fc323e992..f18dcbcec10 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -110,6 +110,7 @@ dependencies { implementation(project(path: ":module:spring-boot-data-redis-test")) implementation(project(path: ":module:spring-boot-devtools")) implementation(project(path: ":module:spring-boot-graphql-test")) + implementation(project(path: ":module:spring-boot-grpc-client")) implementation(project(path: ":module:spring-boot-grpc-server")) implementation(project(path: ":module:spring-boot-health")) implementation(project(path: ":module:spring-boot-hibernate")) diff --git a/module/spring-boot-autoconfigure-classic-modules/build.gradle b/module/spring-boot-autoconfigure-classic-modules/build.gradle index b1050d6f593..2b45c0d9ec7 100644 --- a/module/spring-boot-autoconfigure-classic-modules/build.gradle +++ b/module/spring-boot-autoconfigure-classic-modules/build.gradle @@ -97,6 +97,9 @@ dependencies { api(project(":module:spring-boot-graphql")) { transitive = false } + api(project(":module:spring-boot-grpc-client")) { + transitive = false + } api(project(":module:spring-boot-grpc-server")) { transitive = false } diff --git a/module/spring-boot-grpc-client/build.gradle b/module/spring-boot-grpc-client/build.gradle new file mode 100644 index 00000000000..4bf990aff43 --- /dev/null +++ b/module/spring-boot-grpc-client/build.gradle @@ -0,0 +1,53 @@ +/* + * Copyright 2012-present 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. + */ + +plugins { + id "java-library" + id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" + id "org.springframework.boot.deployed" + id "org.springframework.boot.optional-dependencies" +} + +description = "Spring Boot gRPC Client" + +dependencies { + api(project(":core:spring-boot")) + api("org.springframework.grpc:spring-grpc-core") + + optional(project(":core:spring-boot-autoconfigure")) + optional(project(":module:spring-boot-micrometer-observation")) + optional("io.grpc:grpc-stub") + optional("io.grpc:grpc-netty") + optional("io.grpc:grpc-netty-shaded") + optional("io.grpc:grpc-inprocess") + optional("io.grpc:grpc-kotlin-stub") { + exclude group: "javax.annotation", module: "javax.annotation-api" + } + optional("io.grpc:grpc-xds") { + exclude group: "javax.annotation", module: "javax.annotation-api" + } + + testImplementation(project(":core:spring-boot-test")) + testImplementation(project(":test-support:spring-boot-test-support")) + testImplementation("org.yaml:snakeyaml") + + testRuntimeOnly("ch.qos.logback:logback-classic") +} + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java new file mode 100644 index 00000000000..35ec955fb80 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import io.grpc.stub.AbstractStub; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Primary; +import org.springframework.grpc.client.CompositeGrpcChannelFactory; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcChannelFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for a + * {@link CompositeGrpcChannelFactory}. + * + * @author Chris Bono + * @since 4.1.0 + */ +@AutoConfiguration +@ConditionalOnClass({ AbstractStub.class, GrpcChannelBuilderCustomizer.class }) +@ConditionalOnProperty(name = "spring.grpc.client.enabled", matchIfMissing = true) +@Conditional(CompositeChannelFactoryAutoConfiguration.MultipleNonPrimaryChannelFactoriesCondition.class) +public final class CompositeChannelFactoryAutoConfiguration { + + @Bean + @Primary + CompositeGrpcChannelFactory compositeChannelFactory(ObjectProvider channelFactoriesProvider) { + return new CompositeGrpcChannelFactory(channelFactoriesProvider.orderedStream().toList()); + } + + static class MultipleNonPrimaryChannelFactoriesCondition extends NoneNestedConditions { + + MultipleNonPrimaryChannelFactoriesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnMissingBean(GrpcChannelFactory.class) + static class NoChannelFactoryCondition { + + } + + @ConditionalOnSingleCandidate(GrpcChannelFactory.class) + static class SingleInjectableChannelFactoryCondition { + + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.java new file mode 100644 index 00000000000..2d4dc03cc2a --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that matches when + * {@code spring.grpc.client.channelfactory.enabled} is {@code true} or missing. + * + * @author Phillip Webb + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Documented +@ConditionalOnBooleanProperty(name = "spring.grpc.client.channelfactory.enabled", matchIfMissing = true) +@interface ConditionalOnGrpcClientChannelFactoryEnabled { + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java new file mode 100644 index 00000000000..f09f34f8abb --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java @@ -0,0 +1,113 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +/** + * Invokes the customizations to a {@link ManagedChannelBuilder} based on the provided + * beans. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Order(Ordered.LOWEST_PRECEDENCE) +class GrpcChannelBuilderCustomizers { + + private final List> customizers; + + GrpcChannelBuilderCustomizers(GrpcClientProperties grpcClientProperties, + ObjectProvider compressorRegistry, + ObjectProvider decompressorRegistry, + ObjectProvider> customizers, + ObjectProvider defaultServiceConfigCustomizers) { + this(grpcClientProperties, compressorRegistry.getIfAvailable(), decompressorRegistry.getIfAvailable(), + customizers.orderedStream().toList(), defaultServiceConfigCustomizers.orderedStream().toList()); + } + + GrpcChannelBuilderCustomizers(List> customizers) { + this(null, null, null, customizers, Collections.emptyList()); + } + + GrpcChannelBuilderCustomizers(@Nullable GrpcClientProperties grpcClientProperties, + @Nullable CompressorRegistry compressorRegistry, @Nullable DecompressorRegistry decompressorRegistry, + List> customizers, + List defaultServiceConfigCustomizers) { + List> all = new ArrayList<>(); + addCustomizer(all, compressorRegistry, ManagedChannelBuilder::compressorRegistry); + addCustomizer(all, decompressorRegistry, ManagedChannelBuilder::decompressorRegistry); + if (grpcClientProperties != null) { + all.add(new PropertiesGrpcChannelBuilderCustomizer<>(grpcClientProperties)); + } + all.addAll(customizers); + all.add(customizeDefaultServiceConfig(grpcClientProperties, defaultServiceConfigCustomizers)); + this.customizers = List.copyOf(all); + } + + private static , T> void addCustomizer( + List> customizers, @Nullable T bean, BiConsumer action) { + if (bean != null) { + GrpcChannelBuilderCustomizer customizer = (target, builder) -> action.accept(builder, bean); + customizers.add(customizer); + } + } + + private > GrpcChannelBuilderCustomizer customizeDefaultServiceConfig( + @Nullable GrpcClientProperties properties, + List customizers) { + PropertiesGrpcClientDefaultServiceConfigCustomizer propertiesCustomizer = (properties != null) + ? new PropertiesGrpcClientDefaultServiceConfigCustomizer(properties) : null; + return (target, builder) -> { + Map defaultServiceConfig = new LinkedHashMap<>(); + if (propertiesCustomizer != null) { + propertiesCustomizer.customize(target, defaultServiceConfig); + } + customizers.forEach((customizer) -> customizer.customize(target, defaultServiceConfig)); + if (!defaultServiceConfig.isEmpty()) { + builder.defaultServiceConfig(defaultServiceConfig); + } + }; + } + + > List> forFactory() { + return List.of(this::apply); + } + + @SuppressWarnings("unchecked") + > void apply(String target, T builder) { + LambdaSafe.callbacks(GrpcChannelBuilderCustomizer.class, this.customizers, builder) + .withLogger(GrpcChannelBuilderCustomizers.class) + .invoke((customizer) -> customizer.customize(target, builder)); + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java new file mode 100644 index 00000000000..321097d3312 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import org.springframework.grpc.client.GrpcChannelFactory; + +/** + * Callback interface that can be implemented by beans wishing to customize the + * {@link GrpcChannelFactory} before it is fully initialized, in particular to tune its + * configuration. + * + * @author Chris Bono + * @since 4.1.0 + */ +@FunctionalInterface +public interface GrpcChannelFactoryCustomizer { + + /** + * Customize the given {@link GrpcChannelFactory}. + * @param factory the factory to customize + */ + void customize(GrpcChannelFactory factory); + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java new file mode 100644 index 00000000000..e16476d8f23 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.AbstractStub; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.CoroutineStubFactory; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC clients. + * + * @author Dave Syer + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(before = CompositeChannelFactoryAutoConfiguration.class) +@ConditionalOnClass({ AbstractStub.class, GrpcChannelBuilderCustomizer.class }) +@ConditionalOnProperty(name = "spring.grpc.client.enabled", matchIfMissing = true) +@EnableConfigurationProperties(GrpcClientProperties.class) +@Import({ GrpcClientCodecConfiguration.class, ShadedNettyGrpcClientConfiguration.class, + NettyGrpcClientConfiguration.class, InProcessGrpcClientConfiguration.class }) +public final class GrpcClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ClientInterceptorsConfigurer grpcClientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + + @Bean + @ConditionalOnMissingBean(ChannelCredentialsProvider.class) + PropertiesChannelCredentialsProvider grpcChannelCredentialsProvider(SslBundles bundles, + GrpcClientProperties properties) { + return new PropertiesChannelCredentialsProvider(properties, bundles); + } + + @Bean + > PropertiesGrpcChannelBuilderCustomizer grpcClientPropertiesChannelCustomizer( + GrpcClientProperties properties) { + return new PropertiesGrpcChannelBuilderCustomizer<>(properties); + } + + @Bean + GrpcChannelBuilderCustomizers grpcDefaultServicesChannelBuilderCustomizer(GrpcClientProperties grpcClientProperties, + ObjectProvider compressorRegistry, + ObjectProvider decompressorRegistry, + ObjectProvider> customizers, + ObjectProvider defaultServiceConfigCustomizers) { + return new GrpcChannelBuilderCustomizers(grpcClientProperties, compressorRegistry, decompressorRegistry, + customizers, defaultServiceConfigCustomizers); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "io.grpc.kotlin.AbstractCoroutineStub") + static class GrpcClientCoroutineStubConfiguration { + + @Bean + @ConditionalOnMissingBean + CoroutineStubFactory coroutineStubFactory() { + return new CoroutineStubFactory(); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java new file mode 100644 index 00000000000..0ed44cb87fb --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.List; + +import io.grpc.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import io.grpc.ServerBuilder; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The configuration that contains all codec related beans for clients. + * + * @author Andrei Lisa + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Codec.class) +class GrpcClientCodecConfiguration { + + /** + * The compressor registry that is set on the + * {@link ServerBuilder#compressorRegistry(CompressorRegistry) server builder} . + * @param compressors the compressors to use on the registry + * @return a new {@link CompressorRegistry#newEmptyInstance() registry} with the + * specified compressors or the {@link CompressorRegistry#getDefaultInstance() default + * registry} if no custom compressors are available in the application context. + */ + @Bean + @ConditionalOnMissingBean + CompressorRegistry grpcCompressorRegistry(List compressors) { + if (compressors.isEmpty()) { + return CompressorRegistry.getDefaultInstance(); + } + CompressorRegistry registry = CompressorRegistry.newEmptyInstance(); + compressors.forEach(registry::register); + return registry; + } + + /** + * The decompressor registry that is set on the + * {@link ServerBuilder#decompressorRegistry(DecompressorRegistry) server builder}. + * @param decompressors the decompressors to use on the registry + * @return a new {@link DecompressorRegistry#emptyInstance() registry} with the + * specified decompressors or the {@link DecompressorRegistry#getDefaultInstance() + * default registry} if no custom decompressors are available in the application + * context. + */ + @Bean + @ConditionalOnMissingBean + DecompressorRegistry grpcDecompressorRegistry(List decompressors) { + if (decompressors.isEmpty()) { + return DecompressorRegistry.getDefaultInstance(); + } + DecompressorRegistry registry = DecompressorRegistry.emptyInstance(); + for (Decompressor decompressor : decompressors) { + registry = registry.with(decompressor, false); + } + return registry; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java new file mode 100644 index 00000000000..2b39c51f24b --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.Map; + +import io.grpc.ManagedChannelBuilder; + +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.VirtualTargets; + +/** + * Callback interface that can be used to customize the default service config of the + * {@link GrpcChannelBuilderCustomizer}. + *

+ * This customizer should be used instead of calling + * {@link ManagedChannelBuilder#defaultServiceConfig(Map)} from a + * {@link GrpcChannelBuilderCustomizer} since it allows multiple customizers to update the + * same default service config, rather than having a "last wins" outcome. + * + * @author Phillip Webb + * @since 4.1.0 + * @see GrpcChannelBuilderCustomizer + */ +@FunctionalInterface +public interface GrpcClientDefaultServiceConfigCustomizer { + + /** + * Customize the given default service config. + * @param target the target (which may be a {@link VirtualTargets virtual target}). + * @param defaultServiceConfig the default service config to customize + */ + void customize(String target, Map defaultServiceConfig); + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java new file mode 100644 index 00000000000..f015938d1be --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java @@ -0,0 +1,369 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Name; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.util.unit.DataSize; + +/** + * Configuration properties for gRPC clients. + * + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@ConfigurationProperties("spring.grpc.client") +public class GrpcClientProperties { + + /** + * Map of channel configured by name. + */ + private final Map channel = new LinkedHashMap<>(); + + public Map getChannel() { + return this.channel; + } + + /** + * Channel Properties. + */ + public static class Channel { + + static final String DEFAULT_TARGET = "static://localhost:9090"; + + /** + * The channel target address. + */ + private String target = DEFAULT_TARGET; + + /** + * The custom User-Agent for the channel. + */ + private @Nullable String userAgent; + + /** + * Bypass certificate validation for easier testing (so the remote certificate + * could be anonymous). Should not be set in production. + */ + private boolean bypassCertificateValidation; + + private final Inbound inbound = new Inbound(); + + @Name("default") + private final Default defaultProperties = new Default(); + + private final Idle idle = new Idle(); + + private final Keepalive keepalive = new Keepalive(); + + private final Ssl ssl = new Ssl(); + + private final Health health = new Health(); + + public String getTarget() { + return this.target; + } + + public void setTarget(String target) { + this.target = target; + } + + public @Nullable String getUserAgent() { + return this.userAgent; + } + + public void setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + } + + public boolean isBypassCertificateValidation() { + return this.bypassCertificateValidation; + } + + public void setBypassCertificateValidation(boolean bypassCertificateValidation) { + this.bypassCertificateValidation = bypassCertificateValidation; + } + + public Inbound getInbound() { + return this.inbound; + } + + public Default getDefault() { + return this.defaultProperties; + } + + public Idle getIdle() { + return this.idle; + } + + public Keepalive getKeepalive() { + return this.keepalive; + } + + public Ssl getSsl() { + return this.ssl; + } + + public Health getHealth() { + return this.health; + } + + /** + * In-bound properties. + */ + public static class Inbound { + + private final Message message = new Message(); + + private final Metadata metadata = new Metadata(); + + public Message getMessage() { + return this.message; + } + + public Metadata getMetadata() { + return this.metadata; + } + + /** + * In-bound message properties. + */ + public static class Message { + + /** + * Maximum message size allowed to be received by the channel. Set to '-1' + * to use the highest possible limit (not recommended). + */ + private DataSize maxSize = DataSize.ofBytes(4194304); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + this.maxSize = maxSize; + } + + } + + /** + * In-bound metadata properties. + */ + public static class Metadata { + + /** + * Maximum metadata size allowed to be received by the channel. Set to + * '-1' to use the highest possible limit (not recommended). + */ + private DataSize maxSize = DataSize.ofBytes(8192); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + this.maxSize = maxSize; + } + + } + + } + + /** + * Properties for client defaults. + */ + public static class Default { + + /** + * The default deadline for RPCs performed on this channel. + */ + private @Nullable Duration deadline; + + /** + * The load balancing policy the channel should use. + */ + private String loadBalancingPolicy = "round_robin"; + + public @Nullable Duration getDeadline() { + return this.deadline; + } + + public void setDeadline(@Nullable Duration deadline) { + this.deadline = deadline; + } + + public String getLoadBalancingPolicy() { + return this.loadBalancingPolicy; + } + + public void setLoadBalancingPolicy(String loadBalancingPolicy) { + this.loadBalancingPolicy = loadBalancingPolicy; + } + + } + + /** + * Idle properties. + */ + public static class Idle { + + /** + * The duration without ongoing RPCs before going to idle mode. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration timeout = Duration.ofSeconds(20); + + public Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + } + + /** + * Keep-alive properties. + */ + public static class Keepalive { + + /** + * The delay before sending a keepAlive. Note that shorter intervals increase + * the network burden for the server and this value can not be lower than + * 'permitKeepAliveTime' on the server. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration time = Duration.ofMinutes(5); + + /** + * The default timeout for a keepAlives ping request. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration timeout = Duration.ofSeconds(20); + + /** + * Whether a keepAlive will be performed when there are no outstanding RPC on + * a connection. + */ + private boolean withoutCalls; + + public Duration getTime() { + return this.time; + } + + public void setTime(Duration time) { + this.time = time; + } + + public Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + public boolean isWithoutCalls() { + return this.withoutCalls; + } + + public void setWithoutCalls(boolean withoutCalls) { + this.withoutCalls = withoutCalls; + } + + } + + /** + * Health properties. + */ + public static class Health { + + /** + * Whether to enable client-side health check for the channel. + */ + private boolean enabled; + + /** + * Name of the service to check health on. + */ + private @Nullable String serviceName; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getServiceName() { + return this.serviceName; + } + + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + } + + /** + * SSL properties. + */ + public static class Ssl { + + /** + * Whether to enable SSL support. Enabled automatically if "bundle" is + * provided unless specified otherwise. + */ + private @Nullable Boolean enabled; + + /** + * SSL bundle name. + */ + private @Nullable String bundle; + + public @Nullable Boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(@Nullable Boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getBundle() { + return this.bundle; + } + + public void setBundle(@Nullable String bundle) { + this.bundle = bundle; + } + + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java new file mode 100644 index 00000000000..6227c48e267 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import io.grpc.inprocess.InProcessChannelBuilder; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.ClientInterceptorFilter; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; + +/** + * {@link Configuration @Configuration} for an in-process gRPC client. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(InProcessChannelBuilder.class) +@ConditionalOnMissingBean(InProcessGrpcChannelFactory.class) +@ConditionalOnGrpcClientChannelFactoryEnabled +@ConditionalOnProperty(name = "spring.grpc.client.inprocess.enabled", havingValue = "true", matchIfMissing = true) +class InProcessGrpcClientConfiguration { + + @Bean + InProcessGrpcChannelFactory inProcessGrpcChannelFactory(GrpcChannelBuilderCustomizers grpcChannelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider interceptorFilter, + ObjectProvider channelFactoryCustomizers) { + InProcessGrpcChannelFactory factory = new InProcessGrpcChannelFactory( + grpcChannelBuilderCustomizers.forFactory(), interceptorsConfigurer); + interceptorFilter.ifAvailable(factory::setInterceptorFilter); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java new file mode 100644 index 00000000000..f97641a398f --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import io.grpc.Channel; +import io.grpc.netty.NettyChannelBuilder; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.client.NettyGrpcChannelFactory; + +/** + * {@link Configuration @Configuration} for a Netty gRPC client. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ Channel.class, NettyChannelBuilder.class }) +@ConditionalOnMissingBean(value = GrpcChannelFactory.class, ignored = InProcessGrpcChannelFactory.class) +@ConditionalOnGrpcClientChannelFactoryEnabled +class NettyGrpcClientConfiguration { + + @Bean + NettyGrpcChannelFactory nettyGrpcChannelFactory(Environment environment, GrpcClientProperties properties, + GrpcChannelBuilderCustomizers grpcChannelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider channelFactoryCustomizers, + ChannelCredentialsProvider credentials) { + NettyGrpcChannelFactory factory = new NettyGrpcChannelFactory(grpcChannelBuilderCustomizers.forFactory(), + interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(new PropertiesVirtualTargets(environment, properties)); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java new file mode 100644 index 00000000000..1ad894b40bf --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.TlsChannelCredentials; +import io.grpc.TlsChannelCredentials.Builder; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel.Ssl; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.internal.InsecureTrustManagerFactory; + +/** + * {@link ChannelCredentialsProvider} backed by {@link GrpcClientProperties}. + * + * @param properties the client properties + * @param bundles the SSL bundles + * @author David Syer + * @author Phillip Webb + */ +record PropertiesChannelCredentialsProvider(GrpcClientProperties properties, + SslBundles bundles) implements ChannelCredentialsProvider { + + @Override + public ChannelCredentials getChannelCredentials(String target) { + Channel channel = this.properties.getChannel().get(target); + channel = (channel != null) ? channel : this.properties.getChannel().get("default"); + if (channel == null || isInsecure(channel.getSsl())) { + return InsecureChannelCredentials.create(); + } + Builder builder = TlsChannelCredentials.newBuilder(); + if (channel.getSsl().getBundle() != null) { + SslBundle bundle = this.bundles.getBundle(channel.getSsl().getBundle()); + builder.trustManager(bundle.getManagers().getTrustManagerFactory().getTrustManagers()); + builder.keyManager(bundle.getManagers().getKeyManagerFactory().getKeyManagers()); + } + if (channel.isBypassCertificateValidation()) { + builder.trustManager(InsecureTrustManagerFactory.INSTANCE.getTrustManagers()); + } + return builder.build(); + } + + private boolean isInsecure(Ssl ssl) { + return Boolean.FALSE.equals(ssl.getEnabled()) || (ssl.getBundle() == null && ssl.getEnabled() == null); + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java new file mode 100644 index 00000000000..3f3ff314eca --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.grpc.ManagedChannelBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.interceptor.DefaultDeadlineSetupClientInterceptor; +import org.springframework.util.Assert; +import org.springframework.util.unit.DataSize; + +/** + * {@link GrpcChannelBuilderCustomizer} that maps {@link GrpcClientProperties} to a + * {@link ManagedChannelBuilder}. + * + * @param the type of the builder + * @param properties the properties to map + * @author David Syer + * @author Chris Bono + * @author Phillip Webb + */ +record PropertiesGrpcChannelBuilderCustomizer>( + GrpcClientProperties properties) implements GrpcChannelBuilderCustomizer { + + private static final Channel STOCK_DEFAULT_CHANNEL = new Channel(); + + @Override + public void customize(String target, T builder) { + Channel channel = getChannel(target); + PropertyMapper map = PropertyMapper.get(); + map.from(channel::getUserAgent).to(builder::userAgent); + map.from(channel.getInbound().getMessage()::getMaxSize).asInt(this::maxSize).to(builder::maxInboundMessageSize); + map.from(channel.getInbound().getMetadata()::getMaxSize) + .asInt(this::maxSize) + .to(builder::maxInboundMetadataSize); + map.from(channel.getDefault()::getDeadline) + .when((deadline) -> deadline.toMillis() > 0L) + .as(DefaultDeadlineSetupClientInterceptor::new) + .to(builder::intercept); + map.from(channel.getDefault()::getLoadBalancingPolicy) + .when((policy) -> supportsLoadBalancing(target, channel)) + .to(builder::defaultLoadBalancingPolicy); + map.from(channel.getIdle()::getTimeout).to(durationProperty(builder::idleTimeout)); + map.from(channel.getKeepalive()::getTime).to(durationProperty(builder::keepAliveTime)); + map.from(channel.getKeepalive()::getTimeout).to(durationProperty(builder::keepAliveTimeout)); + map.from(channel.getKeepalive()::isWithoutCalls).to(builder::keepAliveWithoutCalls); + } + + private Channel getChannel(String target) { + Channel channel = this.properties.getChannel().get(target); + channel = (channel != null) ? channel : this.properties.getChannel().get("default"); + return (channel != null) ? channel : STOCK_DEFAULT_CHANNEL; + } + + private boolean supportsLoadBalancing(String target, Channel channel) { + return !(isUnixOrInProcessTarget(target) || isUnixOrInProcessTarget(channel.getTarget())); + } + + private boolean isUnixOrInProcessTarget(String target) { + return target.startsWith("unix:") || target.startsWith("in-process:"); + } + + Consumer durationProperty(BiConsumer setter) { + return (duration) -> setter.accept(duration.toNanos(), TimeUnit.NANOSECONDS); + } + + private int maxSize(DataSize maxSize) { + long bytes = maxSize.toBytes(); + Assert.state(bytes >= 0 || bytes == -1, () -> "Unsupported max size value " + maxSize); + return (bytes >= 0 && bytes <= Integer.MAX_VALUE) ? (int) bytes : Integer.MAX_VALUE; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java new file mode 100644 index 00000000000..58026ae3e93 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.Map; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; + +/** + * {@link GrpcClientDefaultServiceConfigCustomizer} to apply {@link GrpcClientProperties}. + * + * @author Chris Bono + * @author Phillip Webb + * @param properties the client properties + */ +record PropertiesGrpcClientDefaultServiceConfigCustomizer( + GrpcClientProperties properties) implements GrpcClientDefaultServiceConfigCustomizer { + + @Override + public void customize(String target, Map defaultServiceConfig) { + Channel channel = this.properties.getChannel().get(target); + channel = (channel != null) ? channel : this.properties.getChannel().get("default"); + if (channel != null && channel.getHealth().isEnabled()) { + String serviceName = channel.getHealth().getServiceName(); + Map healthCheckConfig = Map.of("serviceName", (serviceName != null) ? serviceName : ""); + defaultServiceConfig.put("healthCheckConfig", healthCheckConfig); + } + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java new file mode 100644 index 00000000000..458925dab47 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.core.env.PropertyResolver; +import org.springframework.grpc.client.VirtualTargets; + +/** + * {@link VirtualTargets} supporting named channels from {@link GrpcClientProperties} and + * directly specified targets (which may include property placeholders). + * + * @param propertyResolver the property resolver + * @param properties the client properties + * @author Chris Bono + * @author Phillip Webb + */ +record PropertiesVirtualTargets(PropertyResolver propertyResolver, + GrpcClientProperties properties) implements VirtualTargets { + + @Override + public String getTarget(String target) { + Channel channel = this.properties.getChannel().get(target); + if (channel != null) { + return clean(this.propertyResolver.resolvePlaceholders(channel.getTarget())); + } + if ("default".equals(target)) { + return clean(Channel.DEFAULT_TARGET); + } + target = this.propertyResolver.resolvePlaceholders(target); + if (target.contains(":/") || target.startsWith("unix:")) { + return clean(target); + } + return target; + } + + private String clean(String target) { + if (target.startsWith("static:") || target.startsWith("tcp:")) { + String withoutScheme = target.substring(target.indexOf(":") + 1); + return withoutScheme.replaceFirst("/*", ""); + } + return target; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java new file mode 100644 index 00000000000..e63b9659f04 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.client.ShadedNettyGrpcChannelFactory; + +/** + * {@link Configuration @Configuration} for a Shaded Netty gRPC client. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ io.grpc.netty.shaded.io.netty.channel.Channel.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class }) +@ConditionalOnMissingBean(value = GrpcChannelFactory.class, ignored = InProcessGrpcChannelFactory.class) +@ConditionalOnGrpcClientChannelFactoryEnabled +class ShadedNettyGrpcClientConfiguration { + + @Bean + ShadedNettyGrpcChannelFactory shadedNettyGrpcChannelFactory(Environment environment, + GrpcClientProperties properties, GrpcChannelBuilderCustomizers grpcChannelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider channelFactoryCustomizers, + ChannelCredentialsProvider credentials) { + ShadedNettyGrpcChannelFactory factory = new ShadedNettyGrpcChannelFactory( + grpcChannelBuilderCustomizers.forFactory(), interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(new PropertiesVirtualTargets(environment, properties)); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java new file mode 100644 index 00000000000..66f01b0431b --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present 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. + */ + +/** + * Auto-configuration for gRPC clients. + */ +@NullMarked +package org.springframework.boot.grpc.client.autoconfigure; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..0b2cfcc4bd2 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,23 @@ +{ + "groups": [], + "properties": [ + { + "name": "spring.grpc.client.channelfactory.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable gRPC channel factory bean auto-configuration.", + "defaultValue": true + }, + { + "name": "spring.grpc.client.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable gRPC client auto-configuration.", + "defaultValue": true + }, + { + "name": "spring.grpc.client.inprocess.enabled", + "type": "java.lang.Boolean", + "description": "Whether to configure the in-process channel factory.", + "defaultValue": true + } + ] +} diff --git a/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..e2d9c74b8c4 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.springframework.boot.grpc.client.autoconfigure.CompositeChannelFactoryAutoConfiguration +org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java new file mode 100644 index 00000000000..e3be4daf5cd --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java @@ -0,0 +1,140 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.CompositeGrpcChannelFactory; +import org.springframework.grpc.client.GrpcChannelFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link CompositeChannelFactoryAutoConfiguration}. + * + * @author Chris Bono + */ +class CompositeChannelFactoryAutoConfigurationTests { + + private ApplicationContextRunner contextRunnerWithoutChannelFactories() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientAutoConfiguration.class, SslAutoConfiguration.class, + CompositeChannelFactoryAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class, + NettyChannelBuilder.class, InProcessChannelBuilder.class)); + } + + @Test + void whenNoChannelFactoriesDoesNotAutoconfigureComposite() { + this.contextRunnerWithoutChannelFactories() + .run((context) -> assertThat(context).doesNotHaveBean(GrpcChannelFactory.class)); + } + + @Test + void whenSingleChannelFactoryDoesNotAutoconfigureComposite() { + GrpcChannelFactory channelFactory1 = mock(); + this.contextRunnerWithoutChannelFactories() + .withBean("channelFactory1", GrpcChannelFactory.class, () -> channelFactory1) + .run((context) -> assertThat(context).hasSingleBean(GrpcChannelFactory.class) + .getBean(GrpcChannelFactory.class) + .isNotInstanceOf(CompositeGrpcChannelFactory.class) + .isSameAs(channelFactory1)); + } + + @Test + void whenMultipleChannelFactoriesWithPrimaryDoesNotAutoconfigureComposite() { + GrpcChannelFactory channelFactory1 = mock(); + GrpcChannelFactory channelFactory2 = mock(); + this.contextRunnerWithoutChannelFactories() + .withBean("channelFactory1", GrpcChannelFactory.class, () -> channelFactory1) + .withBean("channelFactory2", GrpcChannelFactory.class, () -> channelFactory2, (bd) -> bd.setPrimary(true)) + .run((context) -> { + assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("channelFactory1", "channelFactory2"); + assertThat(context).getBean(GrpcChannelFactory.class) + .isNotInstanceOf(CompositeGrpcChannelFactory.class) + .isSameAs(channelFactory2); + }); + } + + @Test + void whenMultipleChannelFactoriesDoesAutoconfigureComposite() { + GrpcChannelFactory channelFactory1 = mock(); + GrpcChannelFactory channelFactory2 = mock(); + this.contextRunnerWithoutChannelFactories() + .withBean("channelFactory1", GrpcChannelFactory.class, () -> channelFactory1) + .withBean("channelFactory2", GrpcChannelFactory.class, () -> channelFactory2) + .run((context) -> { + assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("channelFactory1", "channelFactory2", "compositeChannelFactory"); + assertThat(context).getBean(GrpcChannelFactory.class).isInstanceOf(CompositeGrpcChannelFactory.class); + }); + } + + @Test + void compositeAutoconfiguredAsExpected() { + this.contextRunnerWithoutChannelFactories() + .withUserConfiguration(MultipleFactoriesTestConfig.class) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(CompositeGrpcChannelFactory.class) + .extracting("channelFactories") + .asInstanceOf(InstanceOfAssertFactories.list(GrpcChannelFactory.class)) + .containsExactly(MultipleFactoriesTestConfig.CHANNEL_FACTORY_BAR, + MultipleFactoriesTestConfig.CHANNEL_FACTORY_BAZ, + MultipleFactoriesTestConfig.CHANNEL_FACTORY_FOO)); + + } + + @Configuration(proxyBeanMethods = false) + static class MultipleFactoriesTestConfig { + + static GrpcChannelFactory CHANNEL_FACTORY_FOO = mock(); + static GrpcChannelFactory CHANNEL_FACTORY_BAR = mock(); + static GrpcChannelFactory CHANNEL_FACTORY_BAZ = mock(); + + @Bean + @Order(3) + GrpcChannelFactory channelFactoryFoo() { + return CHANNEL_FACTORY_FOO; + } + + @Bean + @Order(1) + GrpcChannelFactory channelFactoryBar() { + return CHANNEL_FACTORY_BAR; + } + + @Bean + @Order(2) + GrpcChannelFactory channelFactoryBaz() { + return CHANNEL_FACTORY_BAZ; + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java new file mode 100644 index 00000000000..d49b4bfc585 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java @@ -0,0 +1,242 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link GrpcChannelBuilderCustomizers}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcChannelBuilderCustomizersTests { + + @Test + void applyWhenHasProperties() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.setUserAgent("spring-boot"); + properties.getChannel().put("target", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().userAgent("spring-boot"); + } + + @Test + void applyWhenHasCompressorRegistry() { + CompressorRegistry compressorRegistry = mock(); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, compressorRegistry, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().compressorRegistry(compressorRegistry); + } + + @Test + void applyWhenHasDecompressorRegistry() { + DecompressorRegistry decompressorRegistry = mock(); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, null, decompressorRegistry, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().decompressorRegistry(decompressorRegistry); + } + + @Test + void applyWhenEmptyCustomizersDoesNothing() { + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + new GrpcChannelBuilderCustomizers(Collections.emptyList()).apply("target", builder); + then(builder).shouldHaveNoInteractions(); + } + + @Test + void applyWhenSimpleChannelBuilder() { + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers( + List.of(new SimpleChannelBuilderCustomizer())); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().flowControlWindow(100); + } + + @Test + @SuppressWarnings("unchecked") + void applyWhenGenericCustomizersRespectsGeneric() { + List> list = new ArrayList<>(); + list.add(new TestCustomizer<>()); + list.add(new TestNettyChannelBuilderCustomizer()); + list.add(new TestShadedNettyChannelBuilderCustomizer()); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(list); + customizers.apply("target", mock(ManagedChannelBuilder.class)); + assertThat(list.get(0).getCount()).isOne(); + assertThat(list.get(1).getCount()).isZero(); + assertThat(list.get(2).getCount()).isZero(); + customizers.apply("target", mock(NettyChannelBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(2); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isZero(); + customizers.apply("target", mock(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(3); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isOne(); + } + + @Test + void applyWhenHasGrpcClientDefaultServiceConfigCustomizers() { + GrpcClientDefaultServiceConfigCustomizer defaultConfigCustomizer1 = (target, defaultServiceConfig) -> { + defaultServiceConfig.put("c", "v1"); + defaultServiceConfig.put("c1", "v1"); + }; + GrpcClientDefaultServiceConfigCustomizer defaultConfigCustomizer2 = (target, defaultServiceConfig) -> { + defaultServiceConfig.put("c", "v2"); + defaultServiceConfig.put("c2", "v2"); + }; + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, null, null, + Collections.emptyList(), List.of(defaultConfigCustomizer1, defaultConfigCustomizer2)); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("c", "v2"); + expected.put("c1", "v1"); + expected.put("c2", "v2"); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHasChannelHealthAddsHealthServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + channel.getHealth().setServiceName("testservice"); + properties.getChannel().put("target", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "testservice")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHasDefaultHealthAddsHealthServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + channel.getHealth().setServiceName("testdefaultservice"); + properties.getChannel().put("default", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "testdefaultservice")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHealthEnabledAndNoServiceNameAddsHealthConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + properties.getChannel().put("target", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenNoCustomizersOrHealthDoesSetDefaultServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should(never()).defaultServiceConfig(any()); + } + + static class SimpleChannelBuilderCustomizer implements GrpcChannelBuilderCustomizer { + + @Override + public void customize(String target, NettyChannelBuilder channelBuilder) { + channelBuilder.flowControlWindow(100); + } + + } + + /** + * Test customizer that will match any {@link GrpcChannelBuilderCustomizer}. + * + * @param the builder type + */ + static class TestCustomizer> implements GrpcChannelBuilderCustomizer { + + private int count; + + @Override + public void customize(String targetOrChannelName, T channelBuilder) { + this.count++; + } + + int getCount() { + return this.count; + } + + } + + /** + * Test customizer that will match only {@link NettyChannelBuilder}. + */ + static class TestNettyChannelBuilderCustomizer extends TestCustomizer { + + } + + /** + * Test customizer that will match only + * {@link io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder}. + */ + static class TestShadedNettyChannelBuilderCustomizer + extends TestCustomizer { + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java new file mode 100644 index 00000000000..1aecb618ec7 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java @@ -0,0 +1,396 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import io.grpc.Codec; +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.kotlin.AbstractCoroutineStub; +import io.grpc.netty.NettyChannelBuilder; +import io.grpc.stub.AbstractStub; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration.GrpcClientCoroutineStubConfiguration; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.test.scan.DummyBlockingGrpc; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.client.NettyGrpcChannelFactory; +import org.springframework.grpc.client.ShadedNettyGrpcChannelFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link GrpcClientAutoConfiguration}. + * + * @author Chris Bono + * @author Phillip Webb + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) +class GrpcClientAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientAutoConfiguration.class, SslAutoConfiguration.class)); + + private final ApplicationContextRunner contextRunnerWithoutInProcessChannelFactory = this.contextRunner + .withPropertyValues("spring.grpc.client.inprocess.enabled=false"); + + @Test + void whenGrpcStubNotOnClasspathThenAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(AbstractStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenGrpcKotlinIsNotOnClasspathThenAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(AbstractCoroutineStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientCoroutineStubConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenHasUserDefinedCredentialsProviderDoesNotAutoConfigureBean() { + ChannelCredentialsProvider customCredentialsProvider = mock(ChannelCredentialsProvider.class); + this.contextRunner + .withBean("customCredentialsProvider", ChannelCredentialsProvider.class, () -> customCredentialsProvider) + .run((context) -> assertThat(context).getBean(ChannelCredentialsProvider.class) + .isSameAs(customCredentialsProvider)); + } + + @Test + void credentialsProviderAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(PropertiesChannelCredentialsProvider.class) + .hasFieldOrPropertyWithValue("properties", context.getBean(GrpcClientProperties.class)) + .extracting("bundles") + .isInstanceOf(SslBundles.class)); + } + + @Test + void clientPropertiesAutoConfiguredResolvesPlaceholders() { + this.contextRunner + .withPropertyValues("spring.grpc.client.channel.c1.target=my-server-${channelName}:8888", "channelName=foo") + .run((context) -> assertThat(context).getBean(GrpcClientProperties.class).satisfies((properties) -> { + Channel channel = properties.getChannel().get("c1"); + assertThat(channel).isNotNull(); + assertThat(channel.getTarget()).isEqualTo("my-server-foo:8888"); + })); + } + + @Test + void clientPropertiesChannelCustomizerAutoConfiguredWithHealthAsExpected() { + this.contextRunner + .withPropertyValues("spring.grpc.client.channel.test.health.enabled=true", + "spring.grpc.client.channel.test.health.service-name=my-service") + .run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("test", builder); + Map healthCheckConfig = Map.of("healthCheckConfig", Map.of("serviceName", "my-service")); + then(builder).should().defaultServiceConfig(healthCheckConfig); + }); + } + + @Test + void clientPropertiesChannelCustomizerAutoConfiguredWithoutHealthAsExpected() { + this.contextRunner.run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("test", builder); + then(builder).should(never()).defaultServiceConfig(anyMap()); + }); + } + + @Test + void compressionCustomizerAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + CompressorRegistry compressorRegistry = context.getBean(CompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should().compressorRegistry(compressorRegistry); + }); + } + + @Test + void compressionCustomizerWhenNoRegistrry() { + // Codec class guards the imported GrpcCodecConfiguration to hide registry + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)).run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should(never()).compressorRegistry(any()); + }); + } + + @Test + void decompressionCustomizerAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + DecompressorRegistry decompressorRegistry = context.getBean(DecompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should().decompressorRegistry(decompressorRegistry); + }); + } + + @Test + void whenNoDecompressorRegistryThenDecompressionCustomizerIsNotConfigured() { + // Codec class guards the imported GrpcCodecConfiguration to hide registry + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)).run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should(never()).compressorRegistry(any()); + }); + } + + @Test + void whenInProcessEnabledPropNotSetDoesAutoconfigureInProcess() { + this.contextRunner.run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenInProcessEnabledPropSetToTrueDoesAutoconfigureInProcess() { + this.contextRunner.withPropertyValues("spring.grpc.client.inprocess.enabled=true") + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenInProcessEnabledPropSetToFalseDoesNotAutoconfigureInProcess() { + this.contextRunner.withPropertyValues("spring.grpc.client.inprocess.enabled=false") + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .doesNotContainKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenInProcessIsNotOnClasspathDoesNotAutoconfigureInProcess() { + this.contextRunner.withClassLoader(new FilteredClassLoader(InProcessChannelBuilder.class)) + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .doesNotContainKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenHasUserDefinedInProcessChannelFactoryDoesNotAutoConfigureBean() { + InProcessGrpcChannelFactory customChannelFactory = mock(); + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .withBean("customChannelFactory", InProcessGrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class).isSameAs(customChannelFactory)); + } + + @Test + void whenHasUserDefinedChannelFactoryDoesNotAutoConfigureNettyOrShadedNetty() { + GrpcChannelFactory customChannelFactory = mock(); + this.contextRunnerWithoutInProcessChannelFactory + .withBean("customChannelFactory", GrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class).isSameAs(customChannelFactory)); + } + + @Test + void userDefinedChannelFactoryWithInProcessChannelFactory() { + GrpcChannelFactory customChannelFactory = mock(); + this.contextRunner.withBean("customChannelFactory", GrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("customChannelFactory", "inProcessGrpcChannelFactory")); + } + + @Test + void whenShadedAndNonShadedNettyOnClasspathShadedNettyFactoryIsAutoConfigured() { + this.contextRunnerWithoutInProcessChannelFactory + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(ShadedNettyGrpcChannelFactory.class)); + } + + @Test + void shadedNettyWithInProcessChannelFactory() { + this.contextRunner.run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("shadedNettyGrpcChannelFactory", "inProcessGrpcChannelFactory")); + } + + @Test + void whenOnlyNonShadedNettyOnClasspathNonShadedNettyFactoryIsAutoConfigured() { + this.contextRunnerWithoutInProcessChannelFactory + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(NettyGrpcChannelFactory.class)); + } + + @Test + void nonShadedNettyWithInProcessChannelFactory() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("nettyGrpcChannelFactory", "inProcessGrpcChannelFactory")); + } + + @Test + void whenShadedNettyAndNettyNotOnClasspathNoChannelFactoryIsAutoConfigured() { + this.contextRunnerWithoutInProcessChannelFactory + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcChannelFactory.class)); + } + + @Test + void noChannelFactoryWithInProcessChannelFactory() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(InProcessGrpcChannelFactory.class)); + } + + @Test + void shadedNettyChannelFactoryAutoConfiguredAsExpected() { + this.contextRunnerWithoutInProcessChannelFactory + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(ShadedNettyGrpcChannelFactory.class) + .hasFieldOrPropertyWithValue("credentials", context.getBean(PropertiesChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(PropertiesVirtualTargets.class)); + } + + @Test + void nettyChannelFactoryAutoConfiguredAsExpected() { + this.contextRunnerWithoutInProcessChannelFactory + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(NettyGrpcChannelFactory.class) + .hasFieldOrPropertyWithValue("credentials", context.getBean(PropertiesChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(PropertiesVirtualTargets.class)); + } + + @Test + void inProcessChannelFactoryAutoConfiguredAsExpected() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(InProcessGrpcChannelFactory.class) + .extracting("credentials") + .isSameAs(ChannelCredentialsProvider.INSECURE)); + } + + @Test + void shadedNettyChannelFactoryAutoConfiguredWithCustomizers() { + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers(this.contextRunnerWithoutInProcessChannelFactory, builder, + ShadedNettyGrpcChannelFactory.class); + } + + @Test + void nettyChannelFactoryAutoConfiguredWithCustomizers() { + NettyChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers( + this.contextRunnerWithoutInProcessChannelFactory.withClassLoader( + new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)), + builder, NettyGrpcChannelFactory.class); + } + + @Test + void inProcessChannelFactoryAutoConfiguredWithCustomizers() { + InProcessChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers( + this.contextRunner.withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)), + builder, InProcessGrpcChannelFactory.class); + } + + private > void channelFactoryAutoConfiguredWithCustomizers( + ApplicationContextRunner contextRunner, ManagedChannelBuilder mockChannelBuilder, + Class expectedChannelFactoryType) { + contextRunner.withUserConfiguration(ChannelBuilderCustomizersConfig.class) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(expectedChannelFactoryType) + .extracting("globalCustomizers", InstanceOfAssertFactories.list(GrpcChannelBuilderCustomizer.class)) + .satisfies((allCustomizers) -> { + allCustomizers.forEach((c) -> c.customize("channel1", mockChannelBuilder)); + InOrder ordered = inOrder(mockChannelBuilder); + ordered.verify(mockChannelBuilder).keepAliveTime(40L, TimeUnit.SECONDS); + ordered.verify(mockChannelBuilder).keepAliveTime(50L, TimeUnit.SECONDS); + })); + } + + @Configuration(proxyBeanMethods = false) + @AutoConfigurationPackage(basePackageClasses = DummyBlockingGrpc.class) + static class AutoConfigurePackagesConfiguration { + + } + + @Configuration(proxyBeanMethods = false) + static class ChannelBuilderCustomizersConfig { + + @Bean + @Order(100) + > GrpcChannelBuilderCustomizer customizerOne() { + return (target, builder) -> builder.keepAliveTime(40L, TimeUnit.SECONDS); + } + + @Bean + @Order(200) + > GrpcChannelBuilderCustomizer customizerTwo() { + return (target, builder) -> builder.keepAliveTime(50L, TimeUnit.SECONDS); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java new file mode 100644 index 00000000000..5989f9d34f3 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.testsupport.classpath.resources.WithResource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcClientProperties}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcClientPropertiesTests { + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + a: + target: static://my-server:8888 + b: + user-agent: me""") + void defaultValues() throws Exception { + GrpcClientProperties properties = bind(); + Channel channelA = properties.getChannel().get("a"); + assertThat(channelA).isNotNull(); + assertThat(channelA.getUserAgent()).isNull(); + assertThat(channelA.isBypassCertificateValidation()).isFalse(); + assertThat(channelA.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofBytes(4194304)); + assertThat(channelA.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofBytes(8192)); + assertThat(channelA.getDefault().getDeadline()).isNull(); + assertThat(channelA.getIdle().getTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(channelA.getKeepalive().getTime()).isEqualTo(Duration.ofMinutes(5)); + assertThat(channelA.getKeepalive().getTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(channelA.getKeepalive().isWithoutCalls()).isFalse(); + assertThat(channelA.getSsl().getEnabled()).isNull(); + assertThat(channelA.getSsl().getBundle()).isNull(); + Channel channelB = properties.getChannel().get("b"); + assertThat(channelB).isNotNull(); + assertThat(channelB.getTarget()).isEqualTo("static://localhost:9090"); + } + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + test: + target: static://my-server:8888 + user-agent: me + bypass-certificate-validation: true + inbound: + message: + max-size: 200MB + metadata: + max-size: 1GB + default: + deadline: 1s + load-balancing-policy: pick_first + idle: + timeout: 1m + keepalive: + time: 200s + timeout: 60000ms + without-calls: true + ssl: + enabled: true + bundle: my-bundle + health: + enabled: true + service-name: my-service""") + void specificProperties() throws Exception { + GrpcClientProperties properties = bind(); + Channel channel = properties.getChannel().get("test"); + assertThat(channel).isNotNull(); + assertThat(channel.getTarget()).isEqualTo("static://my-server:8888"); + assertThat(channel.getUserAgent()).isEqualTo("me"); + assertThat(channel.isBypassCertificateValidation()).isTrue(); + assertThat(channel.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofMegabytes(200)); + assertThat(channel.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofGigabytes(1)); + assertThat(channel.getDefault().getDeadline()).isEqualTo(Duration.ofSeconds(1)); + assertThat(channel.getIdle().getTimeout()).isEqualTo(Duration.ofMinutes(1)); + assertThat(channel.getKeepalive().getTime()).isEqualTo(Duration.ofSeconds(200)); + assertThat(channel.getKeepalive().getTimeout()).isEqualTo(Duration.ofMillis(60000)); + assertThat(channel.getKeepalive().isWithoutCalls()).isTrue(); + assertThat(channel.getSsl().getEnabled()).isTrue(); + assertThat(channel.getSsl().getBundle()).isEqualTo("my-bundle"); + } + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + test: + idle: + timeout: 1 + keepalive: + time: 60 + timeout: 5""") + void withoutKeepAliveUnitsSpecified() throws Exception { + GrpcClientProperties properties = bind(); + Channel channel = properties.getChannel().get("test"); + assertThat(channel).isNotNull(); + assertThat(channel.getIdle().getTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(channel.getKeepalive().getTime()).isEqualTo(Duration.ofSeconds(60)); + assertThat(channel.getKeepalive().getTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + test: + inbound: + message: + max-size: 1000 + metadata: + max-size: 256""") + void withoutInboundSizeUnitsSpecified() throws Exception { + GrpcClientProperties properties = bind(); + Channel channel = properties.getChannel().get("test"); + assertThat(channel).isNotNull(); + assertThat(channel.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofBytes(1000)); + assertThat(channel.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofBytes(256)); + } + + private GrpcClientProperties bind() throws Exception { + StandardEnvironment environment = new StandardEnvironment(); + new YamlPropertySourceLoader().load("client.yaml", new ClassPathResource("client.yaml")) + .forEach(environment.getPropertySources()::addLast); + return Binder.get(environment) + .bind("", Bindable.of(GrpcClientProperties.class)) + .orElseGet(GrpcClientProperties::new); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java new file mode 100644 index 00000000000..247168ee92f --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import io.grpc.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcClientCodecConfiguration}. + * + * @author Andrei Lisa + */ +class GrpcCodecConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientCodecConfiguration.class)); + + @Test + void whenCodecNotOnClasspathThenAutoconfigurationSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientCodecConfiguration.class)); + } + + @Test + void whenHasCustomCompressorRegistryDoesNotAutoConfigureBean() { + CompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customCompressorRegistry", CompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(CompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void compressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(CompressorRegistry.class) + .isSameAs(CompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomCompressorsThenCompressorRegistryIsNewInstance() { + Compressor compressor = mock(); + given(compressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Compressor.class, () -> compressor).run((context) -> { + assertThat(context).hasSingleBean(CompressorRegistry.class); + CompressorRegistry registry = context.getBean(CompressorRegistry.class); + assertThat(registry).isNotSameAs(CompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupCompressor("foo")).isSameAs(compressor); + }); + } + + @Test + void whenHasCustomDecompressorRegistryDoesNotAutoConfigureBean() { + DecompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customDecompressorRegistry", DecompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(DecompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void decompressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(DecompressorRegistry.class) + .isSameAs(DecompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomDecompressorsThenDecompressorRegistryIsNewInstance() { + Decompressor decompressor = mock(); + given(decompressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Decompressor.class, () -> decompressor).run((context) -> { + assertThat(context).hasSingleBean(DecompressorRegistry.class); + DecompressorRegistry registry = context.getBean(DecompressorRegistry.class); + assertThat(registry).isNotSameAs(DecompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupDecompressor("foo")).isSameAs(decompressor); + }); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java new file mode 100644 index 00000000000..45e08db1dcd --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java @@ -0,0 +1,179 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; + +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.TlsChannelCredentials; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslManagerBundle; +import org.springframework.grpc.client.ChannelCredentialsProvider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PropertiesChannelCredentialsProvider}. + * + * @author Phillip Webb + */ +class PropertiesChannelCredentialsProviderTests { + + private final TrustManager[] trustManagers = { mock() }; + + private final KeyManager[] keyManagers = { mock() }; + + @Test + void getChannelCredentialsWhenTargetMatchesChannel() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).isNull(); + assertThat(credentials.getKeyManagers()).isNull(); + } + + @Test + void getChannelCredentialsWhenTargetDoesNotMatchChannelAndHasDefault() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + properties.getChannel().put("default", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).isNull(); + assertThat(credentials.getKeyManagers()).isNull(); + } + + @Test + void getChannelCredentialsWhenTargetDoesNotMatchChannelAndHasNoDefaultUsesInsecure() { + GrpcClientProperties properties = new GrpcClientProperties(); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + ChannelCredentials credentials = provider.getChannelCredentials("test"); + assertThat(credentials).isInstanceOf(InsecureChannelCredentials.class); + } + + @Test + void getChannelCredentialsWhenSslExplictlyDisabled() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(false); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + ChannelCredentials credentials = provider.getChannelCredentials("test"); + assertThat(credentials).isInstanceOf(InsecureChannelCredentials.class); + } + + @Test + void getChannelCredentialsWhenSslExplictlyEnabledAndNoBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).isNull(); + assertThat(credentials.getKeyManagers()).isNull(); + } + + @Test + void getChannelCredentialsWhenNoSslEnabledSetButHasBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setBundle("test"); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry("test", mockBundle()); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).containsExactly(this.trustManagers); + assertThat(credentials.getKeyManagers()).containsExactly(this.keyManagers); + } + + @Test + void getChannelCredentialsWhenNoSslEnabledSetAndNoBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + ChannelCredentials credentials = provider.getChannelCredentials("test"); + assertThat(credentials).isInstanceOf(InsecureChannelCredentials.class); + } + + @Test + void getChannelCredentialsWhenSslEnabledAndHasBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + channelProperties.getSsl().setBundle("test"); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry("test", mockBundle()); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).containsExactly(this.trustManagers); + assertThat(credentials.getKeyManagers()).containsExactly(this.keyManagers); + } + + @Test + void getChannelCredentialsWhenBypassCertificateValidation() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.setBypassCertificateValidation(true); + channelProperties.getSsl().setBundle("test"); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry("test", mockBundle()); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + TrustManager trustManager = credentials.getTrustManagers().get(0); + assertThat(trustManager.getClass().getName()).contains("InsecureTrustManager"); + assertThat(((X509ExtendedTrustManager) trustManager).getAcceptedIssuers()).isEmpty(); + assertThat(credentials.getKeyManagers()).containsExactly(this.keyManagers); + } + + private SslBundle mockBundle() { + SslBundle bundle = mock(); + SslManagerBundle managerBundle = mock(); + TrustManagerFactory trustManagerFactory = mock(); + KeyManagerFactory keyManagerFactory = mock(); + given(bundle.getManagers()).willReturn(managerBundle); + given(managerBundle.getTrustManagerFactory()).willReturn(trustManagerFactory); + given(managerBundle.getKeyManagerFactory()).willReturn(keyManagerFactory); + given(trustManagerFactory.getTrustManagers()).willReturn(this.trustManagers); + given(keyManagerFactory.getKeyManagers()).willReturn(this.keyManagers); + return bundle; + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java new file mode 100644 index 00000000000..1c29640301a --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java @@ -0,0 +1,209 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.interceptor.DefaultDeadlineSetupClientInterceptor; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link PropertiesGrpcChannelBuilderCustomizer}. + * + * @author Phillip Webb + */ +class PropertiesGrpcChannelBuilderCustomizerTests { + + @Test + > void customizeWhenHasMatchingChannel() { + GrpcClientProperties properties = new GrpcClientProperties(); + properties.getChannel().put("test", createTestChannelProperties()); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + T builder = mock(); + customizer.customize("test", builder); + assertMapped(builder); + } + + @Test + > void customizeWhenHasDefaultChannel() { + GrpcClientProperties properties = new GrpcClientProperties(); + properties.getChannel().put("default", createTestChannelProperties()); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + T builder = mock(); + customizer.customize("test", builder); + assertMapped(builder); + } + + @Test + > void customizeWhenHasNoMatchAndNoDefault() { + GrpcClientProperties properties = new GrpcClientProperties(); + properties.getChannel().put("other", createTestChannelProperties()); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + T builder = mock(); + customizer.customize("test", builder); + assertMappedStockDefaults(builder); + } + + @Test + > void customizeWhenChannelDoesNotSupportLoadBalancingDoesNotMapDefaultLoadBalancer() { + assertNoLoadBalancerMappedBasedOnChannel("unix:test"); + assertNoLoadBalancerMappedBasedOnChannel("in-process:test"); + } + + private > void assertNoLoadBalancerMappedBasedOnChannel(String target) { + T builder = getBuilder((channelProperties) -> { + channelProperties.setTarget(target); + channelProperties.getDefault().setLoadBalancingPolicy("testlbp"); + }); + then(builder).should(never()).defaultLoadBalancingPolicy(any()); + } + + @Test + > void customizeWhenTargetDoesNotSupportLoadBalancingDoesNotMapDefaultLoadBalancer() { + GrpcChannelBuilderCustomizer customizer = getCustomizer( + (channelProperties) -> channelProperties.setTarget("static://localhost:1234")); + assertNoLoadBalancerMappedBasedOnTarget(customizer, "unix:test"); + assertNoLoadBalancerMappedBasedOnTarget(customizer, "in-process:test"); + } + + private > void assertNoLoadBalancerMappedBasedOnTarget( + GrpcChannelBuilderCustomizer customizer, String target) { + T builder = mock(); + customizer.customize(target, builder); + then(builder).should(never()).defaultLoadBalancingPolicy(any()); + } + + @Test + > void customizeWhenMaxMessageSizeIsMinusOneUsesMaxValue() { + T builder = getBuilder( + (channelProperties) -> channelProperties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(-1))); + then(builder).should().maxInboundMessageSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMessageSizeIsTooBigUsesMaxValue() { + T builder = getBuilder((channelProperties) -> channelProperties.getInbound() + .getMessage() + .setMaxSize(DataSize.ofBytes((long) Integer.MAX_VALUE + 100))); + then(builder).should().maxInboundMessageSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMessageSizeIsNegativeAndNotMinusOneThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> getBuilder( + (channelProperties) -> channelProperties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(-2)))) + .withMessage("Unsupported max size value -2B"); + } + + @Test + > void customizeWhenMaxMetadataSizeIsMinusOneUsesMaxValue() { + T builder = getBuilder( + (channelProperties) -> channelProperties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(-1))); + then(builder).should().maxInboundMetadataSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMetadataSizeIsTooBigUsesMaxValue() { + T builder = getBuilder((channelProperties) -> channelProperties.getInbound() + .getMetadata() + .setMaxSize(DataSize.ofBytes((long) Integer.MAX_VALUE + 100))); + then(builder).should().maxInboundMetadataSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMetadataSizeIsNegativeAndNotMinusOneThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> getBuilder( + (channelProperties) -> channelProperties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(-2)))) + .withMessage("Unsupported max size value -2B"); + } + + private > T getBuilder(Consumer setup) { + GrpcChannelBuilderCustomizer customizer = getCustomizer(setup); + T builder = mock(); + customizer.customize("test", builder); + return builder; + } + + private > GrpcChannelBuilderCustomizer getCustomizer( + Consumer setup) { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + setup.accept(channelProperties); + properties.getChannel().put("test", channelProperties); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + return customizer; + } + + private Channel createTestChannelProperties() { + Channel properties = new Channel(); + properties.setUserAgent("testua"); + properties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(10)); + properties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(20)); + properties.getDefault().setDeadline(Duration.ofMinutes(5)); + properties.getDefault().setLoadBalancingPolicy("testlbp"); + properties.getIdle().setTimeout(Duration.ofMinutes(6)); + properties.getKeepalive().setTime(Duration.ofMinutes(7)); + properties.getKeepalive().setTimeout(Duration.ofMinutes(8)); + properties.getKeepalive().setWithoutCalls(true); + return properties; + } + + private > void assertMapped(T builder) { + then(builder).should().userAgent("testua"); + then(builder).should().maxInboundMessageSize(10); + then(builder).should().maxInboundMetadataSize(20); + ArgumentCaptor interceptors = ArgumentCaptor.captor(); + then(builder).should().intercept(interceptors.capture()); + ClientInterceptor interceptor = interceptors.getValue()[0]; + assertThat(interceptor).isInstanceOf(DefaultDeadlineSetupClientInterceptor.class) + .extracting("defaultDeadline") + .isEqualTo(Duration.ofMinutes(5)); + then(builder).should().defaultLoadBalancingPolicy("testlbp"); + then(builder).should().idleTimeout(360000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTime(420000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTimeout(480000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveWithoutCalls(true); + } + + private > void assertMappedStockDefaults(T builder) { + then(builder).should().maxInboundMessageSize(4194304); + then(builder).should().maxInboundMetadataSize(8192); + then(builder).should().defaultLoadBalancingPolicy("round_robin"); + then(builder).should().idleTimeout(20000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTime(300000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTimeout(20000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveWithoutCalls(false); + then(builder).shouldHaveNoMoreInteractions(); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java new file mode 100644 index 00000000000..ebd497660b2 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.grpc.client.VirtualTargets; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesVirtualTargets}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class PropertiesVirtualTargetsTests { + + @Test + void getTargetWhenHasMatchingChannel() { + GrpcClientProperties properties = createProperties("test", "my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenDefaultAndDefaultChannelDefined() { + GrpcClientProperties properties = createProperties("default", "my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("default")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenDefaultAndNoDefaultChannelDefined() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("default")).isEqualTo("localhost:9090"); + } + + @Test + void getTargetWhenChannelHasStaticTargetReturnsStrippedTarget() { + GrpcClientProperties properties = createProperties("test", "static://my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenChannelHasTcpTargetReturnsStrippedTarget() { + GrpcClientProperties properties = createProperties("test", "tcp://my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenChannelHasOtherUrlTarget() { + GrpcClientProperties properties = createProperties("test", "foo://my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("foo://my-server:8888"); + } + + @Test + void getTargetWhenStaticReturnsStripped() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("static://my-server:8888")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenTcpReturnsStripped() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("tcp://my-server:8888")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenUnixUrlDoesNotPrependStatic() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("foo://bar")).isEqualTo("foo://bar"); + } + + @Test + void getTargetWhenUrlReturnsAsIs() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("unix:foo")).isEqualTo("unix:foo"); + } + + @Test + void getTargetUrlWhenHasColonWithoutSlashReturnsAsIs() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("localhost:123/bar")).isEqualTo("localhost:123/bar"); + } + + @Test + void getTargetWhenNotChannelNameResolvesPlaceholders() { + GrpcClientProperties properties = new GrpcClientProperties(); + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("channelName", "foo"); + VirtualTargets targets = new PropertiesVirtualTargets(environment, properties); + assertThat(targets.getTarget("my-server-${channelName}:8888")).isEqualTo("my-server-foo:8888"); + } + + private GrpcClientProperties createProperties(String name, String target) { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.setTarget(target); + properties.getChannel().put(name, channel); + return properties; + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java new file mode 100644 index 00000000000..a54e133f342 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure.test.scan; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.stub.AbstractBlockingStub; +import io.grpc.stub.AbstractStub.StubFactory; + +public final class DummyBlockingGrpc { + + private DummyBlockingGrpc() { + } + + public static DummyBlockingStub newBlockingStub(io.grpc.Channel channel) { + return AbstractBlockingStub.newStub((StubFactory) DummyBlockingStub::new, channel); + } + + public static class DummyBlockingStub extends AbstractBlockingStub { + + protected DummyBlockingStub(Channel channel, CallOptions callOptions) { + super(channel, callOptions); + } + + @Override + protected DummyBlockingStub build(Channel channel, CallOptions callOptions) { + return new DummyBlockingStub(channel, callOptions); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java new file mode 100644 index 00000000000..a405adc2adf --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-present 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.boot.grpc.client.autoconfigure.test.scan; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.stub.AbstractBlockingStub; +import io.grpc.stub.AbstractStub.StubFactory; + +public final class DummyBlockingV2Grpc { + + private DummyBlockingV2Grpc() { + } + + public static DummyBlockingV2Stub newBlockingV2Stub(io.grpc.Channel channel) { + return AbstractBlockingStub.newStub((StubFactory) DummyBlockingV2Stub::new, channel); + } + + public static class DummyBlockingV2Stub extends AbstractBlockingStub { + + protected DummyBlockingV2Stub(Channel channel, CallOptions callOptions) { + super(channel, callOptions); + } + + @Override + protected DummyBlockingV2Stub build(Channel channel, CallOptions callOptions) { + return new DummyBlockingV2Stub(channel, callOptions); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java new file mode 100644 index 00000000000..caad90364c6 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-present 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. + */ + +@NullMarked +package org.springframework.boot.grpc.client.autoconfigure.test.scan; + +import org.jspecify.annotations.NullMarked; diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index ceba585781f..3569519a5d6 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -2163,6 +2163,7 @@ bom { "spring-boot-freemarker", "spring-boot-graphql", "spring-boot-graphql-test", + "spring-boot-grpc-client", "spring-boot-grpc-server", "spring-boot-groovy-templates", "spring-boot-gson", @@ -2294,6 +2295,7 @@ bom { "spring-boot-starter-freemarker-test", "spring-boot-starter-graphql", "spring-boot-starter-graphql-test", + "spring-boot-starter-grpc-client", "spring-boot-starter-grpc-server", "spring-boot-starter-groovy-templates", "spring-boot-starter-groovy-templates-test", diff --git a/settings.gradle b/settings.gradle index da12a2937fd..8b44e0b5e4e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -120,6 +120,7 @@ include "module:spring-boot-flyway" include "module:spring-boot-freemarker" include "module:spring-boot-graphql" include "module:spring-boot-graphql-test" +include "module:spring-boot-grpc-client" include "module:spring-boot-grpc-server" include "module:spring-boot-groovy-templates" include "module:spring-boot-gson" @@ -269,6 +270,7 @@ include "starter:spring-boot-starter-freemarker" include "starter:spring-boot-starter-freemarker-test" include "starter:spring-boot-starter-graphql" include "starter:spring-boot-starter-graphql-test" +include "starter:spring-boot-starter-grpc-client" include "starter:spring-boot-starter-grpc-server" include "starter:spring-boot-starter-groovy-templates" include "starter:spring-boot-starter-groovy-templates-test" @@ -423,6 +425,7 @@ include ":smoke-test:spring-boot-smoke-test-data-rest" include ":smoke-test:spring-boot-smoke-test-devtools" include ":smoke-test:spring-boot-smoke-test-flyway" include ":smoke-test:spring-boot-smoke-test-graphql" +include ":smoke-test:spring-boot-smoke-test-grpc-client" include ":smoke-test:spring-boot-smoke-test-grpc-server" include ":smoke-test:spring-boot-smoke-test-grpc-server-netty-shaded" include ":smoke-test:spring-boot-smoke-test-grpc-server-servlet" diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-client/build.gradle new file mode 100644 index 00000000000..a0e75c6aff3 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/build.gradle @@ -0,0 +1,63 @@ +/* + * Copyright 2012-present 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC client smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-client")) + + testImplementation(project(":starter:spring-boot-starter-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java new file mode 100644 index 00000000000..98480abf487 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-present 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 smoketest.grpcclient; + +import smoketest.grpcclient.proto.HelloReply; +import smoketest.grpcclient.proto.HelloRequest; +import smoketest.grpcclient.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.ImportGrpcClients; + +@SpringBootApplication +@ImportGrpcClients(types = HelloWorldBlockingStub.class) +public class SampleGrpcClientApplication { + + @Bean + ApplicationRunner applicationRunner(HelloWorldBlockingStub hello) { + return (args) -> { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + HelloReply reply = hello.sayHello(request); + System.out.println(">>> " + reply.getMessage()); + }; + } + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcClientApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java new file mode 100644 index 00000000000..56b3d8318d7 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-present 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. + */ + +@NullMarked +package smoketest.grpcclient; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto new file mode 100644 index 00000000000..8c0bed63c61 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcclient.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml new file mode 100644 index 00000000000..0ec63d46668 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +spring: + grpc: + client: + channel: + default: + target: "static://localhost:9090" diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java new file mode 100644 index 00000000000..a9020ba2c96 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-present 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 smoketest.grpcclient; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; + +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import smoketest.grpcclient.SampleGrpcClientApplicationTests.MockServerInitializer; +import smoketest.grpcclient.proto.HelloReply; +import smoketest.grpcclient.proto.HelloRequest; +import smoketest.grpcclient.proto.HelloWorldGrpc; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.grpc.server.NettyGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.test.context.ContextConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ContextConfiguration(initializers = MockServerInitializer.class) +@ExtendWith(OutputCaptureExtension.class) +class SampleGrpcClientApplicationTests { + + @Test + void applicationRunsAndCallsGrpcServer(CapturedOutput output) { + assertThat(output).contains(">>> Hello 'Spring'"); + } + + static class MockServerInitializer implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + NettyGrpcServerFactory serverFactory = new NettyGrpcServerFactory("*:0", Collections.emptyList(), null, + null, null); + HelloWorldService helloWorldService = new HelloWorldService(); + serverFactory.addService(helloWorldService.bindService()); + GrpcServerLifecycle lifecycle = new GrpcServerLifecycle(serverFactory, Duration.ofSeconds(30), + applicationContext); + lifecycle.start(); + String target = "static://localhost:%s".formatted(lifecycle.getPort()); + applicationContext.getEnvironment() + .getPropertySources() + .addFirst(new MapPropertySource("grpc", Map.of("spring.grpc.client.channel.default.target", target))); + applicationContext.getBeanFactory().registerSingleton("grpcServerLifecyce", lifecycle); + } + + } + + static class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello '%s'".formatted(request.getName())).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void streamHello(HelloRequest request, StreamObserver responseObserver) { + throw new IllegalStateException(); + } + + } + +} diff --git a/starter/spring-boot-starter-grpc-client/build.gradle b/starter/spring-boot-starter-grpc-client/build.gradle new file mode 100644 index 00000000000..cdc21b419ed --- /dev/null +++ b/starter/spring-boot-starter-grpc-client/build.gradle @@ -0,0 +1,28 @@ +/* + * Copyright 2012-present 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. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for using Spring gRPC client" + +dependencies { + api(project(":starter:spring-boot-starter")) + api(project(":module:spring-boot-grpc-client")) + api("io.grpc:grpc-netty") + api("io.grpc:grpc-stub") +}