diff --git a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java index d1d97cc..a851ca3 100644 --- a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java +++ b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java @@ -16,6 +16,9 @@ package org.springframework.grpc.sample; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + import java.time.Duration; import org.awaitility.Awaitility; @@ -30,20 +33,81 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.sample.proto.HelloReply; +import org.springframework.grpc.sample.proto.HelloRequest; +import org.springframework.grpc.sample.proto.SimpleGrpc; import org.springframework.test.annotation.DirtiesContext; +import io.grpc.ManagedChannel; import io.grpc.StatusRuntimeException; import io.grpc.health.v1.HealthCheckRequest; import io.grpc.health.v1.HealthCheckResponse.ServingStatus; import io.grpc.health.v1.HealthGrpc; import io.grpc.health.v1.HealthGrpc.HealthBlockingStub; -import static org.assertj.core.api.Assertions.assertThat; +import io.grpc.protobuf.services.HealthStatusManager; /** * Integration tests for gRPC server health feature. */ class GrpcServerHealthIntegrationTests { + @Nested + @SpringBootTest(properties = { "spring.grpc.inprocess.enabled=false", "spring.grpc.server.port=0", + "spring.grpc.client.channels.health-test.address=static://0.0.0.0:${local.grpc.port}", + "spring.grpc.client.channels.health-test.health.enabled=true", + "spring.grpc.client.channels.health-test.health.service-name=my-service" }) + @DirtiesContext + class WithClientHealthEnabled { + + @Test + void loadBalancerRespectsServerHealth(@Autowired GrpcChannelFactory channels, + @Autowired HealthStatusManager healthStatusManager) { + ManagedChannel channel = channels.createChannel("health-test").build(); + SimpleGrpc.SimpleBlockingStub client = SimpleGrpc.newBlockingStub(channel); + + // put the service up (SERVING) and give load balancer time to update + updateHealthStatusAndWait("my-service", ServingStatus.SERVING, healthStatusManager); + + // initially the status should be SERVING + assertThatResponseIsServedToChannel(client); + + // put the service down (NOT_SERVING) and give load balancer time to update + updateHealthStatusAndWait("my-service", ServingStatus.NOT_SERVING, healthStatusManager); + + // now the request should fail + assertThatResponseIsNotServedToChannel(client); + + // put the service up (SERVING) and give load balancer time to update + updateHealthStatusAndWait("my-service", ServingStatus.SERVING, healthStatusManager); + + // now the request should pass + assertThatResponseIsServedToChannel(client); + } + + private void updateHealthStatusAndWait(String serviceName, ServingStatus healthStatus, + HealthStatusManager healthStatusManager) { + healthStatusManager.setStatus(serviceName, healthStatus); + try { + Thread.sleep(2000L); + } + catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void assertThatResponseIsServedToChannel(SimpleGrpc.SimpleBlockingStub client) { + HelloReply response = client.sayHello(HelloRequest.newBuilder().setName("Alien").build()); + assertThat(response.getMessage()).isEqualTo("Hello ==> Alien"); + } + + private void assertThatResponseIsNotServedToChannel(SimpleGrpc.SimpleBlockingStub client) { + assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> client.sayHello(HelloRequest.newBuilder().setName("Alien").build())) + .withMessageContaining("UNAVAILABLE: Health-check service responded NOT_SERVING for 'my-service'"); + } + + } + @Nested @SpringBootTest(properties = { "spring.grpc.server.health.actuator.health-indicator-paths=custom", "spring.grpc.server.health.actuator.update-initial-delay=3s", @@ -52,7 +116,7 @@ class GrpcServerHealthIntegrationTests { class WithActuatorHealthAdapter { @Test - void healthIndicatorsAdaptedToGprcHealthStatus(@Autowired GrpcChannelFactory channels) { + void healthIndicatorsAdaptedToGrpcHealthStatus(@Autowired GrpcChannelFactory channels) { var channel = channels.createChannel("0.0.0.0:0").build(); var healthStub = HealthGrpc.newBlockingStub(channel); var serviceName = "custom"; @@ -75,6 +139,7 @@ private void assertThatGrpcHealthStatusIs(HealthBlockingStub healthBlockingStub, var healthRequest = HealthCheckRequest.newBuilder().setService(service).build(); var healthResponse = healthBlockingStub.check(healthRequest); assertThat(healthResponse.getStatus()).isEqualTo(expectedStatus); + // verify the overall status as well var overallHealthRequest = HealthCheckRequest.newBuilder().setService("").build(); var overallHealthResponse = healthBlockingStub.check(overallHealthRequest); assertThat(overallHealthResponse.getStatus()).isEqualTo(expectedStatus); diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/health.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/health.adoc index d48b419..139185e 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/health.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/health.adoc @@ -25,3 +25,30 @@ spring: NOTE: The items in the `health-indicator-paths` are the identifiers of the indicator which is typically the name of the indicator bean without the `HealthIndicator` suffix. You can use the xref:appendix.adoc#common-application-properties["spring.grpc.server.health.*"] application properties to further configure the health feature. + +== Client-side +Spring gRPC can also autoconfigure the https://grpc.io/docs/guides/health-checking/[client-side] health check feature to your gRPC clients. +To enable health checks on a named channel, simply set the `spring.grpc.client.channels..health.enabled` application property to `true`. +To enable health checks for all channels, set the `spring.grpc.client.default-channel.enabled` application property to `true`. + +By default, the health check will consult the overall status service (i.e. `""`). +To use a specific service, use the `health.service-name` application property on the desired channel. + +NOTE: The `default-load-balancing-policy` must be set to `round_robin` to participate in the health checking. This is the default used by Spring gRPC but if you change the setting you will not get health checks + +The following example enables health checks for all unknown channels (using the overall server status) and for the channel named `one` (using the service `service-one` health check). + +[source,yaml,indent=0,subs="verbatim"] +---- +spring: + grpc: + client: + default-channel: + health: + enabled: true + channels: + one: + health: + enabled: true + service-name: service-one +---- diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc index cfb36b6..3d83d7d 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc @@ -5,6 +5,8 @@ |spring.grpc.client.default-channel.address | | |spring.grpc.client.default-channel.default-load-balancing-policy | | |spring.grpc.client.default-channel.enable-keep-alive | | +|spring.grpc.client.default-channel.health.enabled | | Whether to enable client-side health check for the channel. +|spring.grpc.client.default-channel.health.service-name | | Name of the service to check health on. |spring.grpc.client.default-channel.idle-timeout | | |spring.grpc.client.default-channel.keep-alive-time | | |spring.grpc.client.default-channel.keep-alive-timeout | | diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java index 0243041..3f983dc 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java @@ -16,6 +16,7 @@ package org.springframework.grpc.autoconfigure.client; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -59,7 +60,7 @@ public NamedChannelCredentialsProvider channelCredentialsProvider(GrpcClientProp } @Bean - public GrpcChannelConfigurer sslGrpcChannelConfigurer(GrpcClientProperties channels) { + public GrpcChannelConfigurer baseGrpcChannelConfigurer(GrpcClientProperties channels) { return (authority, builder) -> { for (String name : channels.getChannels().keySet()) { if (authority.equals(name)) { @@ -70,6 +71,13 @@ public GrpcChannelConfigurer sslGrpcChannelConfigurer(GrpcClientProperties chann if (channel.getDefaultLoadBalancingPolicy() != null) { builder.defaultLoadBalancingPolicy(channel.getDefaultLoadBalancingPolicy()); } + if (channel.getHealth().isEnabled()) { + String serviceNameToCheck = channel.getHealth().getServiceName() != null + ? channel.getHealth().getServiceName() : ""; + Map healthCheckConfig = Map.of("healthCheckConfig", + Map.of("serviceName", serviceNameToCheck)); + builder.defaultServiceConfig(healthCheckConfig); + } if (channel.getMaxInboundMessageSize() != null) { builder.maxInboundMessageSize((int) channel.getMaxInboundMessageSize().toBytes()); } diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientProperties.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientProperties.java index 58f3374..65dafa8 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientProperties.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientProperties.java @@ -152,6 +152,12 @@ public void setDefaultLoadBalancingPolicy(final String defaultLoadBalancingPolic // -------------------------------------------------- + private final Health health = new Health(); + + public Health getHealth() { + return this.health; + } + /** * The negotiation type for the channel. Default is * {@link NegotiationType#PLAINTEXT}. @@ -437,6 +443,9 @@ public void copyDefaultsFrom(final NamedChannel config) { if (this.enableKeepAlive == null) { this.enableKeepAlive = config.enableKeepAlive; } + if (this.idleTimeout == null) { + this.idleTimeout = config.idleTimeout; + } if (this.keepAliveTime == null) { this.keepAliveTime = config.keepAliveTime; } @@ -455,6 +464,7 @@ public void copyDefaultsFrom(final NamedChannel config) { if (this.userAgent == null) { this.userAgent = config.userAgent; } + this.health.copyDefaultsFrom(config.health); this.ssl.copyDefaultsFrom(config.ssl); } @@ -523,6 +533,45 @@ public void setBundle(String bundle) { } + 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 String serviceName; + + public boolean isEnabled() { + return this.enabled != null ? this.enabled : false; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getServiceName() { + return this.serviceName; + } + + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + public void copyDefaultsFrom(Health config) { + if (this.enabled == null) { + this.enabled = config.enabled; + } + if (this.serviceName == null) { + this.serviceName = config.serviceName; + } + } + + } + } public SslBundle sslBundle(SslBundles bundles, String path) { diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfigurationTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfigurationTests.java new file mode 100644 index 0000000..c0d99e6 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfigurationTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2023-2024 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.grpc.autoconfigure.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.autoconfigure.client.GrpcClientAutoConfiguration.NamedChannelVirtualTargets; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.DefaultGrpcChannelFactory; +import org.springframework.grpc.client.GrpcChannelConfigurer; +import org.springframework.grpc.client.GrpcChannelFactory; + +import io.grpc.Codec; +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; + +/** + * Tests for {@link GrpcClientAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcClientAutoConfigurationTests { + + private ApplicationContextRunner contextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientAutoConfiguration.class, SslAutoConfiguration.class)); + } + + @Test + void whenHasUserDefinedChannelFactoryDoesNotAutoConfigureBean() { + GrpcChannelFactory customChannelFactory = mock(GrpcChannelFactory.class); + this.contextRunner() + .withBean("customChannelFactory", GrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class).isSameAs(customChannelFactory)); + } + + @Test + void channelFactoryAutoConfiguredAsExpected() { + this.contextRunner() + .run((context) -> assertThat(context).getBean(DefaultGrpcChannelFactory.class) + .hasFieldOrPropertyWithValue("credentials", context.getBean(NamedChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(NamedChannelVirtualTargets.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(NamedChannelCredentialsProvider.class) + .hasFieldOrPropertyWithValue("channels", context.getBean(GrpcClientProperties.class)) + .extracting("bundles") + .isInstanceOf(SslBundles.class)); + } + + @Test + void baseChannelConfigurerAutoConfiguredWithHealthAsExpected() { + this.contextRunner() + .withPropertyValues("spring.grpc.client.channels.test.health.enabled=true", + "spring.grpc.client.channels.test.health.service-name=my-service") + .run((context) -> { + assertThat(context).getBean("baseGrpcChannelConfigurer", GrpcChannelConfigurer.class).isNotNull(); + var configurer = context.getBean("baseGrpcChannelConfigurer", GrpcChannelConfigurer.class); + ManagedChannelBuilder builder = Mockito.mock(); + configurer.configure("test", builder); + Map healthCheckConfig = Map.of("healthCheckConfig", Map.of("serviceName", "my-service")); + verify(builder).defaultServiceConfig(healthCheckConfig); + }); + } + + @Test + void baseChannelConfigurerAutoConfiguredWithoutHealthAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).getBean("baseGrpcChannelConfigurer", GrpcChannelConfigurer.class).isNotNull(); + var configurer = context.getBean("baseGrpcChannelConfigurer", GrpcChannelConfigurer.class); + ManagedChannelBuilder builder = Mockito.mock(); + configurer.configure("test", builder); + verify(builder, never()).defaultServiceConfig(anyMap()); + }); + } + + @Test + void whenNoCompressorRegistryAutoConfigurationIsSkipped() { + // Codec class guards the imported GrpcCodecConfiguration which provides the + // registry + this.contextRunner() + .withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context).getBean("compressionClientConfigurer", GrpcChannelConfigurer.class) + .isNull()); + } + + @Test + void compressionConfigurerAutoConfiguredAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).getBean("compressionClientConfigurer", GrpcChannelConfigurer.class).isNotNull(); + var configurer = context.getBean("compressionClientConfigurer", GrpcChannelConfigurer.class); + var compressorRegistry = context.getBean(CompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + configurer.configure("testChannel", builder); + verify(builder).compressorRegistry(compressorRegistry); + }); + } + + @Test + void whenNoDecompressorRegistryAutoConfigurationIsSkipped() { + // Codec class guards the imported GrpcCodecConfiguration which provides the + // registry + this.contextRunner() + .withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context).getBean("decompressionClientConfigurer", GrpcChannelConfigurer.class) + .isNull()); + } + + @Test + void decompressionConfigurerAutoConfiguredAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).getBean("decompressionClientConfigurer", GrpcChannelConfigurer.class).isNotNull(); + var configurer = context.getBean("decompressionClientConfigurer", GrpcChannelConfigurer.class); + var decompressorRegistry = context.getBean(DecompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + configurer.configure("testChannel", builder); + verify(builder).decompressorRegistry(decompressorRegistry); + }); + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientPropertiesTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientPropertiesTests.java new file mode 100644 index 0000000..ad1c67a --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientPropertiesTests.java @@ -0,0 +1,233 @@ +/* + * Copyright 2024-2024 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.grpc.autoconfigure.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.grpc.client.NegotiationType; +import org.springframework.util.unit.DataSize; + +/** + * Tests for {@link GrpcClientProperties}. + * + * @author Chris Bono + */ +class GrpcClientPropertiesTests { + + private GrpcClientProperties bindProperties(Map map) { + return new Binder(new MapConfigurationPropertySource(map)) + .bind("spring.grpc.client", GrpcClientProperties.class) + .get(); + } + + @Nested + class BindPropertiesAPI { + + @Test + void withDefaultValues() { + Map map = new HashMap<>(); + // we have to at least bind one property or bind() fails + map.put("spring.grpc.client.default-channel.max-inbound-message-size", "200"); + GrpcClientProperties properties = bindProperties(map); + var defaultChannel = properties.getDefaultChannel(); + assertThat(defaultChannel.getAddress()).isEqualTo("static://localhost:9090"); + assertThat(defaultChannel.getDefaultLoadBalancingPolicy()).isEqualTo("round_robin"); + assertThat(defaultChannel.getHealth().isEnabled()).isFalse(); + assertThat(defaultChannel.getHealth().getServiceName()).isNull(); + assertThat(defaultChannel.getNegotiationType()).isEqualTo(NegotiationType.PLAINTEXT); + assertThat(defaultChannel.isEnableKeepAlive()).isFalse(); + assertThat(defaultChannel.getIdleTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(defaultChannel.getKeepAliveTime()).isEqualTo(Duration.ofMinutes(5)); + assertThat(defaultChannel.getKeepAliveTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(defaultChannel.isEnableKeepAlive()).isFalse(); + assertThat(defaultChannel.isKeepAliveWithoutCalls()).isFalse(); + assertThat(defaultChannel.getMaxInboundMessageSize()).isEqualTo(DataSize.ofBytes(200)); + assertThat(defaultChannel.getMaxInboundMetadataSize()).isNull(); + assertThat(defaultChannel.getUserAgent()).isNull(); + assertThat(defaultChannel.isSecure()).isTrue(); + assertThat(defaultChannel.getSsl().isEnabled()).isFalse(); + assertThat(defaultChannel.getSsl().getBundle()).isNull(); + } + + @Test + void withSpecifiedValues() { + Map map = new HashMap<>(); + map.put("spring.grpc.client.default-channel.address", "static://my-server:8888"); + map.put("spring.grpc.client.default-channel.default-load-balancing-policy", "pick_first"); + map.put("spring.grpc.client.default-channel.health.enabled", "true"); + map.put("spring.grpc.client.default-channel.health.service-name", "my-service"); + map.put("spring.grpc.client.default-channel.negotiation-type", "plaintext_upgrade"); + map.put("spring.grpc.client.default-channel.enable-keep-alive", "true"); + map.put("spring.grpc.client.default-channel.idle-timeout", "1m"); + map.put("spring.grpc.client.default-channel.keep-alive-time", "200s"); + map.put("spring.grpc.client.default-channel.keep-alive-timeout", "60000ms"); + map.put("spring.grpc.client.default-channel.keep-alive-without-calls", "true"); + map.put("spring.grpc.client.default-channel.max-inbound-message-size", "200MB"); + map.put("spring.grpc.client.default-channel.max-inbound-metadata-size", "1GB"); + map.put("spring.grpc.client.default-channel.user-agent", "me"); + map.put("spring.grpc.client.default-channel.secure", "false"); + map.put("spring.grpc.client.default-channel.ssl.enabled", "true"); + map.put("spring.grpc.client.default-channel.ssl.bundle", "my-bundle"); + GrpcClientProperties properties = bindProperties(map); + var defaultChannel = properties.getDefaultChannel(); + assertThat(defaultChannel.getAddress()).isEqualTo("static://my-server:8888"); + assertThat(defaultChannel.getDefaultLoadBalancingPolicy()).isEqualTo("pick_first"); + assertThat(defaultChannel.getHealth().isEnabled()).isTrue(); + assertThat(defaultChannel.getHealth().getServiceName()).isEqualTo("my-service"); + assertThat(defaultChannel.getNegotiationType()).isEqualTo(NegotiationType.PLAINTEXT_UPGRADE); + assertThat(defaultChannel.isEnableKeepAlive()).isTrue(); + assertThat(defaultChannel.getIdleTimeout()).isEqualTo(Duration.ofMinutes(1)); + assertThat(defaultChannel.getKeepAliveTime()).isEqualTo(Duration.ofSeconds(200)); + assertThat(defaultChannel.getKeepAliveTimeout()).isEqualTo(Duration.ofMillis(60000)); + assertThat(defaultChannel.isEnableKeepAlive()).isTrue(); + assertThat(defaultChannel.isKeepAliveWithoutCalls()).isTrue(); + assertThat(defaultChannel.getMaxInboundMessageSize()).isEqualTo(DataSize.ofMegabytes(200)); + assertThat(defaultChannel.getMaxInboundMetadataSize()).isEqualTo(DataSize.ofGigabytes(1)); + assertThat(defaultChannel.getUserAgent()).isEqualTo("me"); + assertThat(defaultChannel.isSecure()).isFalse(); + assertThat(defaultChannel.getSsl().isEnabled()).isTrue(); + assertThat(defaultChannel.getSsl().getBundle()).isEqualTo("my-bundle"); + } + + @Test + void withoutKeepAliveUnitsSpecified() { + Map map = new HashMap<>(); + map.put("spring.grpc.client.default-channel.idle-timeout", "1"); + map.put("spring.grpc.client.default-channel.keep-alive-time", "60"); + map.put("spring.grpc.client.default-channel.keep-alive-timeout", "5"); + GrpcClientProperties properties = bindProperties(map); + var defaultChannel = properties.getDefaultChannel(); + assertThat(defaultChannel.getIdleTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(defaultChannel.getKeepAliveTime()).isEqualTo(Duration.ofSeconds(60)); + assertThat(defaultChannel.getKeepAliveTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void withoutInboundSizeUnitsSpecified() { + Map map = new HashMap<>(); + map.put("spring.grpc.client.default-channel.max-inbound-message-size", "1000"); + map.put("spring.grpc.client.default-channel.max-inbound-metadata-size", "256"); + GrpcClientProperties properties = bindProperties(map); + var defaultChannel = properties.getDefaultChannel(); + assertThat(defaultChannel.getMaxInboundMessageSize()).isEqualTo(DataSize.ofBytes(1000)); + assertThat(defaultChannel.getMaxInboundMetadataSize()).isEqualTo(DataSize.ofBytes(256)); + } + + } + + @Nested + class CopyDefaultsAPI { + + @Test + void withNoUserSpecifiedValues() { + GrpcClientProperties properties = new GrpcClientProperties(); + var defaultChannel = properties.getDefaultChannel(); + var newChannel = new GrpcClientProperties.NamedChannel(); + newChannel.copyDefaultsFrom(defaultChannel); + assertThat(newChannel).usingRecursiveComparison().isEqualTo(defaultChannel); + } + + @Test + void withUserSpecifiedValuesAreRetained() { + GrpcClientProperties properties = new GrpcClientProperties(); + var defaultChannel = properties.getDefaultChannel(); + var newChannel = new GrpcClientProperties.NamedChannel(); + newChannel.setAddress("static://my-server:9999"); + newChannel.setDefaultLoadBalancingPolicy("custom"); + newChannel.getHealth().setEnabled(true); + newChannel.getHealth().setServiceName("custom-service"); + newChannel.setNegotiationType(NegotiationType.TLS); + newChannel.setEnableKeepAlive(true); + newChannel.setIdleTimeout(Duration.ofMinutes(1)); + newChannel.setKeepAliveTime(Duration.ofMinutes(4)); + newChannel.setKeepAliveTimeout(Duration.ofMinutes(6)); + newChannel.setKeepAliveWithoutCalls(true); + newChannel.setMaxInboundMessageSize(DataSize.ofMegabytes(100)); + newChannel.setMaxInboundMetadataSize(DataSize.ofMegabytes(200)); + newChannel.setUserAgent("me"); + newChannel.setSecure(false); + newChannel.getSsl().setEnabled(true); + newChannel.getSsl().setBundle("custom-bundle"); + newChannel.copyDefaultsFrom(defaultChannel); + assertThat(newChannel).usingRecursiveComparison().isNotEqualTo(defaultChannel); + assertThat(newChannel).usingRecursiveComparison().isEqualTo(newChannel); + } + + } + + @Nested + class GetChannelAPI { + + @Test + void withDefaultNameReturnsDefaultChannel() { + GrpcClientProperties properties = new GrpcClientProperties(); + var defaultChannel = properties.getDefaultChannel(); + assertThat(properties.getChannel("default")).isSameAs(defaultChannel); + assertThat(properties.getChannels()).containsExactly(entry("default", defaultChannel)); + } + + @Test + void withUnknownNameReturnsNewChannelWithCopiedDefaults() { + GrpcClientProperties properties = new GrpcClientProperties(); + var defaultChannel = properties.getDefaultChannel(); + defaultChannel.setAddress("static://my-server:9999"); + defaultChannel.setDefaultLoadBalancingPolicy("custom"); + defaultChannel.getHealth().setEnabled(true); + defaultChannel.getHealth().setServiceName("custom-service"); + defaultChannel.setEnableKeepAlive(true); + defaultChannel.setIdleTimeout(Duration.ofMinutes(1)); + defaultChannel.setKeepAliveTime(Duration.ofMinutes(4)); + defaultChannel.setKeepAliveTimeout(Duration.ofMinutes(6)); + defaultChannel.setKeepAliveWithoutCalls(true); + defaultChannel.setMaxInboundMessageSize(DataSize.ofMegabytes(100)); + defaultChannel.setMaxInboundMetadataSize(DataSize.ofMegabytes(200)); + defaultChannel.setUserAgent("me"); + defaultChannel.getSsl().setEnabled(true); + defaultChannel.getSsl().setBundle("custom-bundle"); + var newChannel = properties.getChannel("new"); + assertThat(newChannel).usingRecursiveComparison().ignoringFields("address").isEqualTo(defaultChannel); + assertThat(properties.getChannels()).containsExactly(entry("default", defaultChannel), + entry("new", newChannel)); + } + + } + + @Nested + class GetTargetAPI { + + @Test + void withCustomChannelReturnsCustomChannelAddress() { + Map map = new HashMap<>(); + map.put("spring.grpc.client.channels.custom.address", "static://my-server:8888"); + GrpcClientProperties properties = bindProperties(map); + assertThat(properties.getTarget("custom")).isEqualTo("my-server:8888"); + assertThat(properties.getChannels()).containsOnlyKeys("default", "custom"); + } + + } + +}