diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/ClientInterceptorsConfigurer.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/ClientInterceptorsConfigurer.java new file mode 100644 index 0000000..10f8cd2 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/ClientInterceptorsConfigurer.java @@ -0,0 +1,76 @@ +/* + * 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.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.grpc.internal.ApplicationContextBeanLookupUtils; + +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; + +/** + * Configure a {@link ManagedChannelBuilder} with client interceptors. + * + * @author Chris Bono + */ +public class ClientInterceptorsConfigurer implements InitializingBean { + + private final ApplicationContext applicationContext; + + private List globalInterceptors; + + public ClientInterceptorsConfigurer(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + /** + * Configure a {@link ManagedChannelBuilder} with client interceptors. + * @param builder the builder to configure + * @param interceptors the non-null list of interceptors to be applied to the channel + * @param mergeWithGlobalInterceptors whether the provided interceptors should be + * blended with the global interceptors. + */ + protected void configureInterceptors(ManagedChannelBuilder builder, List interceptors, + boolean mergeWithGlobalInterceptors) { + // Add global interceptors first + List allInterceptors = new ArrayList<>(this.globalInterceptors); + // Add specific interceptors + allInterceptors.addAll(interceptors); + if (mergeWithGlobalInterceptors) { + ApplicationContextBeanLookupUtils.sortBeansIncludingOrderAnnotation(this.applicationContext, + ClientInterceptor.class, allInterceptors); + } + Collections.reverse(allInterceptors); + builder.intercept(allInterceptors); + } + + @Override + public void afterPropertiesSet() { + this.globalInterceptors = findGlobalInterceptors(); + } + + private List findGlobalInterceptors() { + return ApplicationContextBeanLookupUtils.getBeansWithAnnotation(this.applicationContext, + ClientInterceptor.class, GlobalClientInterceptor.class); + } + +} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java index 93563ec..6ed45b2 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java @@ -25,6 +25,7 @@ import org.springframework.beans.factory.DisposableBean; import org.springframework.util.Assert; import io.grpc.ChannelCredentials; +import io.grpc.ClientInterceptor; import io.grpc.ForwardingChannelBuilder2; import io.grpc.Grpc; import io.grpc.ManagedChannel; @@ -47,17 +48,18 @@ public class DefaultGrpcChannelFactory implements GrpcChannelFactory, Disposable private final List customizers = new ArrayList<>(); + private final ClientInterceptorsConfigurer interceptorsConfigurer; + private ChannelCredentialsProvider credentials = ChannelCredentialsProvider.INSECURE; private VirtualTargets targets = VirtualTargets.DEFAULT; - public DefaultGrpcChannelFactory() { - this(List.of()); - } - - public DefaultGrpcChannelFactory(List customizers) { + public DefaultGrpcChannelFactory(List customizers, + ClientInterceptorsConfigurer interceptorsConfigurer) { Assert.notNull(customizers, () -> "customizers must not be null"); + Assert.notNull(interceptorsConfigurer, () -> "interceptorsConfigurer must not be null"); this.customizers.addAll(customizers); + this.interceptorsConfigurer = interceptorsConfigurer; } public void setVirtualTargets(VirtualTargets targets) { @@ -70,13 +72,20 @@ public class DefaultGrpcChannelFactory implements GrpcChannelFactory, Disposable @Override public ManagedChannelBuilder createChannel(String authority) { - ManagedChannelBuilder target = this.builders.computeIfAbsent(authority, path -> { - ManagedChannelBuilder builder = newChannel(this.targets.getTarget(path), + return this.createChannel(authority, List.of(), false); + } + + @Override + public ManagedChannelBuilder createChannel(String authority, List interceptors, + boolean mergeWithGlobalInterceptors) { + Assert.notNull(interceptors, () -> "interceptors must not be null"); + return this.builders.computeIfAbsent(authority, path -> { + ManagedChannelBuilder builder = newChannelBuilder(this.targets.getTarget(path), this.credentials.getChannelCredentials(path)); + this.interceptorsConfigurer.configureInterceptors(builder, interceptors, mergeWithGlobalInterceptors); this.customizers.forEach((c) -> c.customize(path, builder)); - return builder; + return new DisposableChannelBuilder(authority, builder); }); - return new DisposableChannelBuilder(authority, target); } /** @@ -86,15 +95,17 @@ public class DefaultGrpcChannelFactory implements GrpcChannelFactory, Disposable * @param creds the credentials for the channel * @return a new {@link ManagedChannelBuilder} for the given path and credentials */ - protected ManagedChannelBuilder newChannel(String path, ChannelCredentials creds) { + protected ManagedChannelBuilder newChannelBuilder(String path, ChannelCredentials creds) { return Grpc.newChannelBuilder(path, creds); } + private ManagedChannel buildAndRegisterChannel(String channelName, ManagedChannelBuilder channelBuilder) { + return this.channels.computeIfAbsent(channelName, (__) -> channelBuilder.build()); + } + @Override public void destroy() { - for (ManagedChannel channel : this.channels.values()) { - channel.shutdown(); - } + this.channels.values().forEach(ManagedChannel::shutdown); } /** @@ -103,10 +114,10 @@ public class DefaultGrpcChannelFactory implements GrpcChannelFactory, Disposable */ class DisposableChannelBuilder extends ForwardingChannelBuilder2 { - private final ManagedChannelBuilder delegate; - private final String authority; + private final ManagedChannelBuilder delegate; + DisposableChannelBuilder(String authority, ManagedChannelBuilder delegate) { this.authority = authority; this.delegate = delegate; @@ -119,7 +130,7 @@ public class DefaultGrpcChannelFactory implements GrpcChannelFactory, Disposable @Override public ManagedChannel build() { - return DefaultGrpcChannelFactory.this.channels.computeIfAbsent(this.authority, name -> super.build()); + return DefaultGrpcChannelFactory.this.buildAndRegisterChannel(this.authority, this.delegate); } } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/GlobalClientInterceptor.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GlobalClientInterceptor.java new file mode 100644 index 0000000..d88480e --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GlobalClientInterceptor.java @@ -0,0 +1,45 @@ +/* + * 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. + * + * Partial copy from net.devh:grpc-spring-boot-starter. + */ + +package org.springframework.grpc.client; + +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.core.annotation.Order; + +import io.grpc.ClientInterceptor; + +/** + * Annotation that can be specified on a gRPC {@link ClientInterceptor} bean which will + * result in the interceptor being applied globally to channels. + *

+ * The bean interceptor {@link Order} will be respected. + * + * @author Daniel Theuke (daniel.theuke@heuboe.de) + * @author Chris Bono + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface GlobalClientInterceptor { + +} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java index 0f2b106..6c641c2 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java @@ -16,6 +16,9 @@ package org.springframework.grpc.client; +import java.util.List; + +import io.grpc.ClientInterceptor; import io.grpc.ManagedChannelBuilder; /** @@ -34,4 +37,16 @@ public interface GrpcChannelFactory { */ ManagedChannelBuilder createChannel(String authority); + /** + * Creates a {@link ManagedChannelBuilder} for the given authority and the provided + * client interceptors. + * @param authority the target authority for the channel + * @param interceptors the non-null list of interceptors to be applied to the channel + * @param mergeWithGlobalInterceptors whether the provided interceptors should be + * blended with the global interceptors. + * @return a channel builder conifgured with the provided values + */ + ManagedChannelBuilder createChannel(String authority, List interceptors, + boolean mergeWithGlobalInterceptors); + } diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/client/ClientInterceptorsConfigurerTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/client/ClientInterceptorsConfigurerTests.java new file mode 100644 index 0000000..6a82736 --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/client/ClientInterceptorsConfigurerTests.java @@ -0,0 +1,241 @@ +/* + * 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.client; + +import static org.mockito.Mockito.verify; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; + +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; + +/** + * Tests for {@link ClientInterceptorsConfigurer}. + * + * @author Chris Bono + */ +class ClientInterceptorsConfigurerTests { + + private ApplicationContextRunner contextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ClientInterceptorsConfigurerConfig.class)); + } + + @Test + void globalClientInterceptorsAreFoundInProperOrder() { + this.contextRunner() + .withUserConfiguration(GlobalClientInterceptorsConfig.class) + .run((context) -> Assertions.assertThat(context) + .getBean(ClientInterceptorsConfigurer.class) + .extracting("globalInterceptors", InstanceOfAssertFactories.LIST) + .containsExactly(GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); + } + + private void customizeContextAndRunConfigurer( + Function contextCustomizer, + List clientSpecificInterceptors, List expectedInterceptors) { + ManagedChannelBuilder builder = Mockito.mock(); + this.contextRunner().with(contextCustomizer).run((context) -> { + var configurer = context.getBean(ClientInterceptorsConfigurer.class); + configurer.configureInterceptors(builder, clientSpecificInterceptors, true); + // NOTE: the interceptors are called in reverse order per builder contract + var expectedInterceptorsReversed = new ArrayList<>(expectedInterceptors); + Collections.reverse(expectedInterceptorsReversed); + verify(builder).intercept(expectedInterceptorsReversed); + }); + } + + @Nested + class WithOnlyGlobalInterceptors { + + @Test + void whenNoGlobalInterceptorsRegisteredThenBuilderGetsNoInterceptors() { + customizeContextAndRunConfigurer(Function.identity(), List.of(), List.of()); + } + + @Test + void whenGlobalInterceptorsRegisteredThenBuilderGetsGlobalInterceptors() { + customizeContextAndRunConfigurer( + (contextRunner) -> contextRunner.withUserConfiguration(GlobalClientInterceptorsConfig.class), + List.of(), List.of(GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); + } + + } + + @Nested + class WithOnlyClientSpecificInterceptors { + + @Test + void whenSingleInterceptorSpecifiedThenItIsUsed() { + var clientSpecificInterceptors = List + .of(ClientSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + var expectedInterceptors = List.of(ClientSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunConfigurer( + (contextRunner) -> contextRunner.withUserConfiguration(ClientSpecificInterceptorsConfig.class), + clientSpecificInterceptors, expectedInterceptors); + } + + @Test + void whenMultipleInterceptorsSpecifiedThenTheyAreUsed() { + var clientSpecificInterceptors = List.of(ClientSpecificInterceptorsConfig.SVC_INTERCEPTOR_B, + ClientSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + var expectedInterceptors = List.of(ClientSpecificInterceptorsConfig.SVC_INTERCEPTOR_B, + ClientSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunConfigurer( + (contextRunner) -> contextRunner.withUserConfiguration(ClientSpecificInterceptorsConfig.class), + clientSpecificInterceptors, expectedInterceptors); + } + + } + + @Nested + class WithClientSpecificCombinedWithGlobalInterceptors { + + @Test + void whenBlendInterceptorsFalseThenGlobalInterceptorsAddedFirst() { + ManagedChannelBuilder builder = Mockito.mock(); + ClientInterceptorsConfigurerTests.this.contextRunner() + .withUserConfiguration(GlobalClientInterceptorsConfig.class, ClientSpecificInterceptorsConfig.class) + .run((context) -> { + var interceptorA = context.getBean("interceptorA", ClientInterceptor.class); + var interceptorB = context.getBean("interceptorB", ClientInterceptor.class); + var clientSpecificInterceptors = List.of(interceptorB, interceptorA); + var expectedInterceptors = List.of(GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO, interceptorB, interceptorA); + var configurer = context.getBean(ClientInterceptorsConfigurer.class); + configurer.configureInterceptors(builder, clientSpecificInterceptors, false); + // NOTE: the interceptors are called in reverse order per builder + // contract + var expectedInterceptorsReversed = new ArrayList<>(expectedInterceptors); + Collections.reverse(expectedInterceptorsReversed); + verify(builder).intercept(expectedInterceptorsReversed); + }); + } + + @SuppressWarnings("unchecked") + @Test + void whenBlendInterceptorsTrueThenGlobalInterceptorsBlended() { + ManagedChannelBuilder builder = Mockito.mock(); + ClientInterceptorsConfigurerTests.this.contextRunner() + .withUserConfiguration(GlobalClientInterceptorsConfig.class, ClientSpecificInterceptorsConfig.class) + .run((context) -> { + var interceptorA = context.getBean("interceptorA", ClientInterceptor.class); + var interceptorB = context.getBean("interceptorB", ClientInterceptor.class); + var clientSpecificInterceptors = List.of(interceptorB, interceptorA); + var expectedInterceptors = List.of(GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + interceptorB, GlobalClientInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO, interceptorA); + var configurer = context.getBean(ClientInterceptorsConfigurer.class); + configurer.configureInterceptors(builder, clientSpecificInterceptors, true); + // NOTE: the interceptors are called in reverse order per builder + // contract + var expectedInterceptorsReversed = new ArrayList<>(expectedInterceptors); + Collections.reverse(expectedInterceptorsReversed); + verify(builder).intercept(expectedInterceptorsReversed); + }); + } + + } + + interface TestClientInterceptorA extends ClientInterceptor { + + } + + interface TestClientInterceptorB extends ClientInterceptor { + + } + + @Configuration(proxyBeanMethods = false) + static class ClientInterceptorsConfigurerConfig { + + @Bean + ClientInterceptorsConfigurer clientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + + } + + @Configuration(proxyBeanMethods = false) + static class GlobalClientInterceptorsConfig { + + static ClientInterceptor GLOBAL_INTERCEPTOR_FOO = Mockito.mock(); + + static ClientInterceptor GLOBAL_INTERCEPTOR_IGNORED = Mockito.mock(); + + static ClientInterceptor GLOBAL_INTERCEPTOR_BAR = Mockito.mock(); + + @Bean + @Order(200) + @GlobalClientInterceptor + ClientInterceptor globalInterceptorFoo() { + return GLOBAL_INTERCEPTOR_FOO; + } + + @Bean + @Order(150) + ClientInterceptor globalInterceptorIgnored() { + return GLOBAL_INTERCEPTOR_IGNORED; + } + + @Bean + @Order(100) + @GlobalClientInterceptor + ClientInterceptor globalInterceptorBar() { + return GLOBAL_INTERCEPTOR_BAR; + } + + } + + @Configuration(proxyBeanMethods = false) + static class ClientSpecificInterceptorsConfig { + + static TestClientInterceptorB SVC_INTERCEPTOR_B = Mockito.mock(); + + static TestClientInterceptorA SVC_INTERCEPTOR_A = Mockito.mock(); + + @Bean + @Order(150) + TestClientInterceptorB interceptorB() { + return SVC_INTERCEPTOR_B; + } + + @Bean + @Order(225) + TestClientInterceptorA interceptorA() { + return SVC_INTERCEPTOR_A; + } + + } + +} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/client/DefaultGrpcChannelFactoryTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/client/DefaultGrpcChannelFactoryTests.java new file mode 100644 index 0000000..349cae5 --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/client/DefaultGrpcChannelFactoryTests.java @@ -0,0 +1,64 @@ +/* + * 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.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.grpc.client.DefaultGrpcChannelFactory.DisposableChannelBuilder; + +import io.grpc.ClientInterceptor; + +/** + * Tests for {@link DefaultGrpcChannelFactory}. + */ +class DefaultGrpcChannelFactoryTests { + + @Test + void createChannelWithoutClientSpecificInterceptorsInvokesInterceptorConfigurer() { + ClientInterceptorsConfigurer configurer = mock(); + var channelName = "testChannel"; + var channelFactory = new DefaultGrpcChannelFactory(List.of(), configurer); + channelFactory.createChannel(channelName); + // Get the actual builder that should be passed into the configurer + assertThat(channelFactory) + .extracting("builders", InstanceOfAssertFactories.map(String.class, DisposableChannelBuilder.class)) + .hasEntrySatisfying(channelName, + (builder) -> verify(configurer).configureInterceptors(builder.delegate(), List.of(), false)); + } + + @Test + void createChannelWithClientSpecificInterceptorsInvokesInterceptorConfigurer() { + ClientInterceptorsConfigurer configurer = mock(); + var channelName = "testChannel"; + var channelFactory = new DefaultGrpcChannelFactory(List.of(), configurer); + var interceptor = mock(ClientInterceptor.class); + var interceptors = List.of(interceptor); + channelFactory.createChannel(channelName, interceptors, true); + // Get the actual builder that should be passed into the configurer + assertThat(channelFactory) + .extracting("builders", InstanceOfAssertFactories.map(String.class, DisposableChannelBuilder.class)) + .hasEntrySatisfying(channelName, + (builder) -> verify(configurer).configureInterceptors(builder.delegate(), interceptors, true)); + } + +} diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc index 811e77a..80ae4c5 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc @@ -100,3 +100,34 @@ SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels, @LocalGrpcPort i return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:" + port).build()); } ---- + +[[client-interceptor]] +== Client Interceptors + +=== Global +To add a client interceptor to be applied to all created channels you can simply register a client interceptor bean and then annotate it with `@GlobalClientInterceptor`. +The interceptors are ordered according to their bean natural ordering (i.e. `@Order`). + +[source,java] +---- +@Bean +@Order(100) +@GlobalClientInterceptor +ClientInterceptor myGlobalLoggingInterceptor() { + return new MyLoggingInterceptor(); +} +---- + +=== Per-Channel +To add one or more client interceptors to be applied to a single client channel you can simply pass in the interceptor instance(s) when invoking the channel factory to create the channel. + +The interceptors are ordered according to their position in the specified list. + +=== Blended +When a channel is constructed with both global and per-client interceptors, the global interceptors are first applied in their sorted order followed by the per-service interceptors in their sorted order. + +However, by setting the `mergeWithGlobalInterceptors` parameter on the channel factory to `"true"` you can change this behavior so that the interceptors are all combined and then sorted according to their bean natural ordering (i.e. `@Order` or `Ordered` interface). + +You can use this option if you want to add a per-client interceptor between global interceptors. + +IMPORTANT: The per-channel interceptors you pass in must either be bean instances marked with `@Order` or regular objects that implement the `Ordered` interface to be properly merged/ordered with the global interceptors. diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc index da26d5c..63f60ca 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc @@ -6,6 +6,8 @@ This section covers the changes made from version 0.2.0 to version 0.3.0. +=== Client Interceptors +You can now add xref:client.adoc#client-interceptor[Client Interceptors] to created gRPC channels. === Breaking Changes 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 4cf9826..59e4c30 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 @@ -21,12 +21,14 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 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.autoconfigure.client.GrpcClientProperties.NamedChannel; import org.springframework.grpc.autoconfigure.common.codec.GrpcCodecConfiguration; import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; import org.springframework.grpc.client.DefaultGrpcChannelFactory; import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; import org.springframework.grpc.client.GrpcChannelFactory; @@ -40,11 +42,18 @@ import io.grpc.DecompressorRegistry; @Import(GrpcCodecConfiguration.class) public class GrpcClientAutoConfiguration { + @Bean + @ConditionalOnMissingBean + ClientInterceptorsConfigurer clientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + @Bean @ConditionalOnMissingBean(GrpcChannelFactory.class) public DefaultGrpcChannelFactory defaultGrpcChannelFactory(List customizers, - ChannelCredentialsProvider credentials, GrpcClientProperties channels, SslBundles ignored) { - DefaultGrpcChannelFactory factory = new DefaultGrpcChannelFactory(customizers); + ClientInterceptorsConfigurer interceptorsConfigurer, ChannelCredentialsProvider credentials, + GrpcClientProperties channels, SslBundles ignored) { + DefaultGrpcChannelFactory factory = new DefaultGrpcChannelFactory(customizers, interceptorsConfigurer); factory.setCredentialsProvider(credentials); factory.setVirtualTargets(new NamedChannelVirtualTargets(channels)); return factory; diff --git a/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcChannelFactory.java b/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcChannelFactory.java index ea502aa..01b0eab 100644 --- a/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcChannelFactory.java +++ b/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcChannelFactory.java @@ -15,6 +15,9 @@ */ package org.springframework.grpc.test; +import java.util.List; + +import org.springframework.grpc.client.ClientInterceptorsConfigurer; import org.springframework.grpc.client.DefaultGrpcChannelFactory; import io.grpc.ChannelCredentials; @@ -23,8 +26,12 @@ import io.grpc.inprocess.InProcessChannelBuilder; public class InProcessGrpcChannelFactory extends DefaultGrpcChannelFactory { + public InProcessGrpcChannelFactory(ClientInterceptorsConfigurer interceptorsConfigurer) { + super(List.of(), interceptorsConfigurer); + } + @Override - protected ManagedChannelBuilder newChannel(String path, ChannelCredentials creds) { + protected ManagedChannelBuilder newChannelBuilder(String path, ChannelCredentials creds) { return InProcessChannelBuilder.forName(path); } diff --git a/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcServerFactoryAutoConfiguration.java b/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcServerFactoryAutoConfiguration.java index 295b0e8..3c4f1f4 100644 --- a/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcServerFactoryAutoConfiguration.java +++ b/spring-grpc-test/src/main/java/org/springframework/grpc/test/InProcessGrpcServerFactoryAutoConfiguration.java @@ -20,11 +20,14 @@ import java.util.List; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.grpc.autoconfigure.client.GrpcClientAutoConfiguration; import org.springframework.grpc.autoconfigure.server.GrpcServerFactoryAutoConfiguration; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; import org.springframework.grpc.server.ServerBuilderCustomizer; import org.springframework.grpc.server.service.GrpcServiceDiscoverer; @@ -37,7 +40,7 @@ import io.grpc.inprocess.InProcessServerBuilder; @ConditionalOnNotWebApplication public class InProcessGrpcServerFactoryAutoConfiguration { - private String address = InProcessServerBuilder.generateName(); + private final String address = InProcessServerBuilder.generateName(); @Bean @ConditionalOnBean(BindableService.class) @@ -49,8 +52,14 @@ public class InProcessGrpcServerFactoryAutoConfiguration { } @Bean - InProcessGrpcChannelFactory grpcChannelFactory() { - InProcessGrpcChannelFactory factory = new InProcessGrpcChannelFactory(); + @ConditionalOnMissingBean + ClientInterceptorsConfigurer inProcessClientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + + @Bean + InProcessGrpcChannelFactory grpcChannelFactory(ClientInterceptorsConfigurer interceptorsConfigurer) { + InProcessGrpcChannelFactory factory = new InProcessGrpcChannelFactory(interceptorsConfigurer); factory.setVirtualTargets(path -> address); return factory; }