From 4daf41392a8a449aa06ed8fda0ba7d816c74fedf Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Wed, 6 Nov 2024 11:15:28 -0600 Subject: [PATCH] Add GlobalServerInterceptor to service discovery This commit builds upon the previously added service discoverer by adding support for finding all server interceptor beans that are marked w/ the newly added `@GlobalServerInterceptor` annotation. Resolves #4 Signed-off-by: Chris Bono --- .../antora/modules/ROOT/pages/server.adoc | 22 ++- .../server/DefaultGrpcServiceDiscoverer.java | 35 +++- .../server/GlobalServerInterceptor.java | 43 +++++ .../server/GrpcServerAutoConfiguration.java | 8 +- .../DefaultGrpcServiceDiscovererTests.java | 156 ++++++++++++++++++ 5 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GlobalServerInterceptor.java create mode 100644 spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc index e33bb1d..2e21938 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc @@ -85,4 +85,24 @@ dependencies { The `spring.grpc.server.*` properties will be ignored in facour of the regular `server.*` properties in this case. The servlet that is created is mapped to process HTTP POST requests to the paths defined by the registered services, as `//*`. -Clients can connect to the server using that path, which is what any gRPC client library will do. \ No newline at end of file +Clients can connect to the server using that path, which is what any gRPC client library will do. + +[[server-interceptor]] +== Server Interceptors + +=== Global +To add a server interceptor to be applied to all services you can simply register a server interceptor bean and then annotate it with `@GlobalServerInterceptor`. +The interceptors are ordered according to their bean natural ordering (i.e. `@Order`). + +[source,java] +---- +@Bean +@Order(100) +@GlobalServerInterceptor +ServerInterceptor myGlobalLoggingInterceptor() { + return new MyLoggingInterceptor(); +} +---- + +=== Per-Service +This is a **WIP** and will be available soon. diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java index 35c3f36..daea2b9 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java @@ -16,35 +16,58 @@ package org.springframework.grpc.autoconfigure.server; -import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import io.grpc.BindableService; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; import io.grpc.ServerServiceDefinition; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; import org.springframework.grpc.server.GrpcServiceDiscoverer; /** * The default {@link GrpcServiceDiscoverer} that finds all {@link BindableService} beans * and configures and binds them. * - * @author Michael (yidongnan@gmail.com) * @author Chris Bono */ class DefaultGrpcServiceDiscoverer implements GrpcServiceDiscoverer { private final ObjectProvider grpcServicesProvider; - DefaultGrpcServiceDiscoverer(ObjectProvider grpcServicesProvider) { + private final ObjectProvider serverInterceptorsProvider; + + private final ApplicationContext applicationContext; + + public DefaultGrpcServiceDiscoverer(ObjectProvider grpcServicesProvider, + ObjectProvider serverInterceptorsProvider, ApplicationContext applicationContext) { this.grpcServicesProvider = grpcServicesProvider; + this.serverInterceptorsProvider = serverInterceptorsProvider; + this.applicationContext = applicationContext; } @Override public List findServices() { - List list = new ArrayList<>( - grpcServicesProvider.orderedStream().map(BindableService::bindService).toList()); - return list; + List globalInterceptors = findGlobalInterceptors(); + return grpcServicesProvider.orderedStream() + .map(BindableService::bindService) + .map((svc) -> ServerInterceptors.interceptForward(svc, globalInterceptors)) + .toList(); + } + + // VisibleForTesting + List findGlobalInterceptors() { + // We find unordered map of beans (keyed by name) with the annotation and then + // reverse the map for easy lookup by bean. + // We then get an ordered stream of all server interceptors and filter + // out those that are not present in map of annotated interceptor beans. + var nameToBeanMap = applicationContext.getBeansWithAnnotation(GlobalServerInterceptor.class); + var beanToNameMap = new HashMap(); + nameToBeanMap.forEach((name, bean) -> beanToNameMap.put(bean, name)); + return this.serverInterceptorsProvider.orderedStream().filter(beanToNameMap::containsKey).toList(); } } diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GlobalServerInterceptor.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GlobalServerInterceptor.java new file mode 100644 index 0000000..0fe1fb3 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GlobalServerInterceptor.java @@ -0,0 +1,43 @@ +/* + * 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.server; + +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 io.grpc.ServerInterceptor; + +import org.springframework.core.annotation.Order; + +/** + * Annotation that can be specified on a gRPC {@link ServerInterceptor} bean which will + * result in the interceptor being applied globally to all services. + *

+ * 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 GlobalServerInterceptor { + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java index 53cff8d..08d759b 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java @@ -23,6 +23,7 @@ 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.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; @@ -36,6 +37,7 @@ import io.grpc.BindableService; import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; import io.grpc.ServerBuilder; +import io.grpc.ServerInterceptor; /** * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side components. @@ -75,8 +77,10 @@ public class GrpcServerAutoConfiguration { @ConditionalOnMissingBean @Bean - GrpcServiceDiscoverer grpcServiceDiscoverer(ObjectProvider bindableServicesProvider) { - return new DefaultGrpcServiceDiscoverer(bindableServicesProvider); + GrpcServiceDiscoverer grpcServiceDiscoverer(ObjectProvider bindableServicesProvider, + ObjectProvider serverInterceptorsProvider, ApplicationContext applicationContext) { + return new DefaultGrpcServiceDiscoverer(bindableServicesProvider, serverInterceptorsProvider, + applicationContext); } @ConditionalOnBean(CompressorRegistry.class) diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java new file mode 100644 index 0000000..0dc5f88 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java @@ -0,0 +1,156 @@ +/* + * 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.server; + +import java.util.List; + +import io.grpc.BindableService; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.ServerServiceDefinition; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +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.server.lifecycle.GrpcServerLifecycle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link DefaultGrpcServiceDiscoverer}. + * + * @author Chris Bono + */ +class DefaultGrpcServiceDiscovererTests { + + private ApplicationContextRunner contextRunner() { + // NOTE: we use noop server lifecycle to avoid startup + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + } + + @Test + void globalServerInterceptorsAreFoundInProperOrder() { + this.contextRunner() + .withUserConfiguration(GlobalServerInterceptorsConfig.class) + .run((context) -> assertThat(context).getBean(DefaultGrpcServiceDiscoverer.class) + .extracting(DefaultGrpcServiceDiscoverer::findGlobalInterceptors, InstanceOfAssertFactories.LIST) + .containsExactly(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); + } + + @Test + void servicesAreFoundInProperOrderWithGlobalInterceptorsApplied() { + // It gets difficult to verify interceptors are added properly to mocked services. + // To make it easier, we just static mock ServerInterceptors.interceptForward to + // echo back the service def. This way we can verify the interceptors were passed + // in the proper order as we rely/trust that ServerInterceptors.interceptForward + // is + // tested well in grpc-java. + try (MockedStatic serverInterceptorsMocked = Mockito.mockStatic(ServerInterceptors.class)) { + serverInterceptorsMocked + .when(() -> ServerInterceptors.interceptForward(any(ServerServiceDefinition.class), anyList())) + .thenAnswer((Answer) invocation -> invocation.getArgument(0)); + this.contextRunner().withUserConfiguration(GlobalServerInterceptorsConfig.class).run((context) -> { + assertThat(context).getBean(DefaultGrpcServiceDiscoverer.class) + .extracting(DefaultGrpcServiceDiscoverer::findServices, InstanceOfAssertFactories.LIST) + .containsExactly(GlobalServerInterceptorsConfig.SERVICE_DEF_B, + GlobalServerInterceptorsConfig.SERVICE_DEF_A); + ArgumentCaptor serviceDefArg = ArgumentCaptor.captor(); + ArgumentCaptor> interceptorsArg = ArgumentCaptor.captor(); + serverInterceptorsMocked.verify( + () -> ServerInterceptors.interceptForward(serviceDefArg.capture(), interceptorsArg.capture()), + times(2)); + assertThat(serviceDefArg.getAllValues()).containsExactly(GlobalServerInterceptorsConfig.SERVICE_DEF_B, + GlobalServerInterceptorsConfig.SERVICE_DEF_A); + assertThat(interceptorsArg.getAllValues()).containsExactly( + List.of(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO), + List.of(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); + }); + } + } + + @Configuration(proxyBeanMethods = false) + static class GlobalServerInterceptorsConfig { + + static BindableService SERVICE_A = mock(); + + static ServerServiceDefinition SERVICE_DEF_A = mock(); + + static BindableService SERVICE_B = mock(); + + static ServerServiceDefinition SERVICE_DEF_B = mock(); + + static ServerInterceptor GLOBAL_INTERCEPTOR_FOO = mock(); + + static ServerInterceptor GLOBAL_INTERCEPTOR_IGNORED = mock(); + + static ServerInterceptor GLOBAL_INTERCEPTOR_BAR = mock(); + + @Bean + @Order(200) + BindableService serviceA() { + when(SERVICE_A.bindService()).thenReturn(SERVICE_DEF_A); + return SERVICE_A; + } + + @Bean + @Order(100) + BindableService serviceB() { + when(SERVICE_B.bindService()).thenReturn(SERVICE_DEF_B); + return SERVICE_B; + } + + @Bean + @Order(200) + @GlobalServerInterceptor + ServerInterceptor globalInterceptorFoo() { + return GLOBAL_INTERCEPTOR_FOO; + } + + @Bean + @Order(150) + ServerInterceptor globalInterceptorIgnored() { + return GLOBAL_INTERCEPTOR_IGNORED; + } + + @Bean + @Order(100) + @GlobalServerInterceptor + ServerInterceptor globalInterceptorBar() { + return GLOBAL_INTERCEPTOR_BAR; + } + + } + +}