Add support for service specific server interceptors

This commit introduces the `@GrpcService` annotation which can be used to
associate one or more `ServerInterceptor` beans with a `BindableService`.

See #5

Signed-off-by: Chris Bono <chris.bono@gmail.com>
This commit is contained in:
Chris Bono
2024-11-13 15:28:33 -06:00
committed by Dave Syer
parent 3904948d3e
commit cd28b69756
11 changed files with 921 additions and 111 deletions

View File

@@ -108,10 +108,28 @@ ServerInterceptor myGlobalLoggingInterceptor() {
----
=== Per-Service
This is a **WIP** and will be available soon.
To add a server interceptor to be applied to a single service you can simply register a server interceptor bean and then annotate your `BindableService` bean with `@GrpcService`, specifying the interceptor using either the `interceptors` or `interceptorNames` attribute.
The interceptors are ordered according to their position in the attribute list.
When using both `interceptors` and `interceptorNames`, the former entries precede the latter.
In the following example, the `myServerInterceptor` will be applied to the `myService` service.
[source,java]
----
@Bean
MyServerInterceptor myServerInterceptor() {
return new MyServerInterceptor();
}
@GrpcService(interceptors = MyServerInterceptor.class)
BindableService myService() {
...
}
----
== Observability
Spring gRPC provides an autoconfigured interceptor that can be used to provide observability to your gRPC services.
All you need to do is add Spring Boot actuators to your project, and optionally a bridge to your observability platform of choice (just like https://docs.spring.io/spring-boot/reference/actuator/observability.html[any other Spring Boot application]).
The `grpc-tomcat` sample in the Spring gRPC repository shows how to do it, and you should see trace logging and metrics when you connect to the server.
The `grpc-tomcat` sample in the Spring gRPC repository shows how to do it, and you should see trace logging and metrics when you connect to the server.

View File

@@ -0,0 +1,96 @@
/*
* 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.lang.annotation.Annotation;
import java.util.LinkedHashMap;
import java.util.List;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.util.Assert;
/**
* Convenience methods performing bean lookups that are not currently provided by the
* standard Spring facilities.
*
* @author Chris Bono
*/
final class ApplicationContextBeanLookupUtils {
private ApplicationContextBeanLookupUtils() {
}
/**
* Find all beans of the given type which may be annotated with the supplied
* {@link Annotation} type and returns a map of matching bean instances with the
* annotation instance (or null if the bean is not annotated with the specified
* annotation type).
* <p>
* Note that the map is ordered according to the matching beans {@link Order}
* annotation.
* <p>
* Note that this method considers objects created by FactoryBeans, which means that
* FactoryBeans will get initialized in order to determine their object type.
* @param applicationContext the application context
* @param beanType the type of bean
* @param annotationType the type of annotation
* @param <B> type of bean
* @param <A> type of annotation
* @return a map of matching bean instances with the annotation instance (or null)
* ordered according to their {@link Order} with annotation
*/
static <B, A extends Annotation> LinkedHashMap<B, A> getOrderedBeansWithAnnotation(
ApplicationContext applicationContext, Class<B> beanType, Class<A> annotationType) {
Assert.notNull(applicationContext, () -> "applicationContext must not be null");
var annotatedBeanNamesToBeans = applicationContext.getBeansWithAnnotation(annotationType);
var annotatedBeansToBeanNames = new LinkedHashMap<Object, String>();
annotatedBeanNamesToBeans.forEach((name, bean) -> annotatedBeansToBeanNames.put(bean, name));
var orderedBeans = new LinkedHashMap<B, A>();
applicationContext.getBeanProvider(beanType).orderedStream().forEachOrdered((b) -> {
var beanName = annotatedBeansToBeanNames.get(b);
A beanAnnotation = null;
if (beanName != null) {
beanAnnotation = applicationContext.findAnnotationOnBean(beanName, annotationType);
}
orderedBeans.put(b, beanAnnotation);
});
return orderedBeans;
}
/**
* Find all beans which are annotated with the supplied {@link Annotation} type.
* <p>
* Note that this method considers objects created by FactoryBeans, which means that
* FactoryBeans will get initialized in order to determine their object type.
* @param applicationContext the application context
* @param beanType the type of bean
* @param annotationType the type of annotation
* @param <B> type of bean
* @param <A> type of annotation
* @return a list of the matching beans ordered according to their {@link Order}
* annotation
*/
static <B, A extends Annotation> List<B> getBeansWithAnnotation(ApplicationContext applicationContext,
Class<B> beanType, Class<A> annotationType) {
Assert.notNull(applicationContext, () -> "applicationContext must not be null");
var nameToBeanMap = applicationContext.getBeansWithAnnotation(annotationType);
var beanToNameMap = new LinkedHashMap<Object, String>();
nameToBeanMap.forEach((name, bean) -> beanToNameMap.put(bean, name));
return applicationContext.getBeanProvider(beanType).orderedStream().filter(beanToNameMap::containsKey).toList();
}
}

View File

@@ -0,0 +1,82 @@
/*
* 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.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.context.ApplicationContext;
import org.springframework.lang.Nullable;
import io.grpc.BindableService;
import io.grpc.ServerInterceptor;
import io.grpc.ServerInterceptors;
import io.grpc.ServerServiceDefinition;
import jakarta.annotation.PostConstruct;
/**
* Default {@link GrpcServiceConfigurer} that binds and configures services with
* interceptors.
*
* @author Chris Bono
*/
public class DefaultGrpcServiceConfigurer implements GrpcServiceConfigurer {
private final ApplicationContext applicationContext;
private List<ServerInterceptor> globalInterceptors;
public DefaultGrpcServiceConfigurer(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@PostConstruct
private void initializeGlobalInterceptors() {
this.globalInterceptors = findGlobalInterceptors();
}
@Override
public ServerServiceDefinition configure(BindableService bindableService, @Nullable GrpcServiceInfo serviceInfo) {
return bindInterceptors(bindableService, serviceInfo);
}
private List<ServerInterceptor> findGlobalInterceptors() {
return ApplicationContextBeanLookupUtils.getBeansWithAnnotation(this.applicationContext,
ServerInterceptor.class, GlobalServerInterceptor.class);
}
private ServerServiceDefinition bindInterceptors(BindableService bindableService,
@Nullable GrpcServiceInfo serviceInfo) {
var serviceDef = bindableService.bindService();
if (serviceInfo == null) {
return ServerInterceptors.interceptForward(serviceDef, this.globalInterceptors);
}
// Add global interceptors first
List<ServerInterceptor> allInterceptors = new ArrayList<>(this.globalInterceptors);
// Add interceptors by type
Arrays.stream(serviceInfo.interceptors())
.forEachOrdered(
(interceptorClass) -> allInterceptors.add(this.applicationContext.getBean(interceptorClass)));
// Add interceptors by name
Arrays.stream(serviceInfo.interceptorNames())
.forEachOrdered((interceptorBeanName) -> allInterceptors
.add(this.applicationContext.getBean(interceptorBeanName, ServerInterceptor.class)));
// TODO handle blend
return ServerInterceptors.interceptForward(serviceDef, allInterceptors);
}
}

View File

@@ -1,11 +1,11 @@
/*
* Copyright (c) 2016-2023 The gRPC-Spring Authors
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@@ -16,17 +16,14 @@
package org.springframework.grpc.autoconfigure.server;
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;
import org.springframework.lang.Nullable;
import io.grpc.BindableService;
import io.grpc.ServerServiceDefinition;
/**
* The default {@link GrpcServiceDiscoverer} that finds all {@link BindableService} beans
@@ -34,40 +31,31 @@ import org.springframework.grpc.server.GrpcServiceDiscoverer;
*
* @author Chris Bono
*/
class DefaultGrpcServiceDiscoverer implements GrpcServiceDiscoverer {
public class DefaultGrpcServiceDiscoverer implements GrpcServiceDiscoverer {
private final ObjectProvider<BindableService> grpcServicesProvider;
private final ObjectProvider<ServerInterceptor> serverInterceptorsProvider;
private final GrpcServiceConfigurer serviceConfigurer;
private final ApplicationContext applicationContext;
public DefaultGrpcServiceDiscoverer(ObjectProvider<BindableService> grpcServicesProvider,
ObjectProvider<ServerInterceptor> serverInterceptorsProvider, ApplicationContext applicationContext) {
this.grpcServicesProvider = grpcServicesProvider;
this.serverInterceptorsProvider = serverInterceptorsProvider;
public DefaultGrpcServiceDiscoverer(GrpcServiceConfigurer serviceConfigurer,
ApplicationContext applicationContext) {
this.serviceConfigurer = serviceConfigurer;
this.applicationContext = applicationContext;
}
@Override
public List<ServerServiceDefinition> findServices() {
List<ServerInterceptor> globalInterceptors = findGlobalInterceptors();
return grpcServicesProvider.orderedStream()
.map(BindableService::bindService)
.map((svc) -> ServerInterceptors.interceptForward(svc, globalInterceptors))
return ApplicationContextBeanLookupUtils
.getOrderedBeansWithAnnotation(this.applicationContext, BindableService.class, GrpcService.class)
.entrySet()
.stream()
.map((e) -> this.serviceConfigurer.configure(e.getKey(), this.serviceInfo(e.getValue())))
.toList();
}
// VisibleForTesting
List<ServerInterceptor> 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<Object, String>();
nameToBeanMap.forEach((name, bean) -> beanToNameMap.put(bean, name));
return this.serverInterceptorsProvider.orderedStream().filter(beanToNameMap::containsKey).toList();
@Nullable
private GrpcServiceInfo serviceInfo(@Nullable GrpcService grpcService) {
return grpcService != null ? GrpcServiceInfo.from(grpcService) : null;
}
}

View File

@@ -37,7 +37,6 @@ 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.
@@ -77,10 +76,15 @@ public class GrpcServerAutoConfiguration {
@ConditionalOnMissingBean
@Bean
GrpcServiceDiscoverer grpcServiceDiscoverer(ObjectProvider<BindableService> bindableServicesProvider,
ObjectProvider<ServerInterceptor> serverInterceptorsProvider, ApplicationContext applicationContext) {
return new DefaultGrpcServiceDiscoverer(bindableServicesProvider, serverInterceptorsProvider,
applicationContext);
GrpcServiceConfigurer grpcServiceConfigurer(ApplicationContext applicationContext) {
return new DefaultGrpcServiceConfigurer(applicationContext);
}
@ConditionalOnMissingBean
@Bean
GrpcServiceDiscoverer grpcServiceDiscoverer(GrpcServiceConfigurer grpcServiceConfigurer,
ApplicationContext applicationContext) {
return new DefaultGrpcServiceDiscoverer(grpcServiceConfigurer, applicationContext);
}
@ConditionalOnBean(CompressorRegistry.class)

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2016-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.
*
* Copy from net.devh:grpc-spring-boot-starter.
*/
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 org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Service;
import io.grpc.BindableService;
import io.grpc.ServerInterceptor;
/**
* Annotation that marks gRPC services that should be registered with a gRPC server.
* <p>
* <b>NOTE:</b> This annotation is optional as all {@link BindableService} beans will be
* registered with a gRPC server. However, this annotation allows specifying additional
* information about the service (e.g. interceptors).
* <p>
* <b>NOTE:</b> This annotation should only be added to {@link BindableService} beans.
*
* @author Michael (yidongnan@gmail.com)
* @author Chris Bono
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service
@Bean
public @interface GrpcService {
/**
* The {@link ServerInterceptor} bean types to be applied to the service.
* @return the interceptor bean types to be applied to the service
*/
Class<? extends ServerInterceptor>[] interceptors() default {};
/**
* The {@link ServerInterceptor} bean names to be applied to the service.
* @return the interceptor bean names to be applied to the service
*/
String[] interceptorNames() default {};
/**
* Whether the service specific interceptors should be blended with the global
* interceptors.
* <p>
* When false, the global interceptors are applied first, followed by the service
* specific interceptors.
* <p>
* When true, the global interceptors are merged and sorted (blended) with the service
* specific interceptors.
* @return whether the service specific interceptors should be blended with the global
* interceptors
*/
boolean blendWithGlobalInterceptors() default false;
}

View File

@@ -0,0 +1,40 @@
/*
* 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 org.springframework.lang.Nullable;
import io.grpc.BindableService;
import io.grpc.ServerServiceDefinition;
/**
* Configures and binds a {@link BindableService gRPC Service}.
*
* @author Chris Bono
*/
@FunctionalInterface
public interface GrpcServiceConfigurer {
/**
* Configure and bind a gRPC service.
* @param bindableService service to bind and configure
* @param serviceInfo optional additional service information
* @return configured service definition
*/
ServerServiceDefinition configure(BindableService bindableService, @Nullable GrpcServiceInfo serviceInfo);
}

View File

@@ -0,0 +1,78 @@
/*
* 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.util.List;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import io.grpc.ServerInterceptor;
/**
* Additional information about a gRPC service that can be used when configuring the
* service.
*
* @param interceptors list of {@link ServerInterceptor} bean types to be applied to the
* service
* @param interceptorNames list of {@link ServerInterceptor} bean names to be applied to
* the service
* @param blendWithGlobalInterceptors whether the service specific interceptors should be
* merged and sorted (blended) with the global interceptors
* @author Chris Bono
*/
public record GrpcServiceInfo(Class<? extends ServerInterceptor>[] interceptors, String[] interceptorNames,
boolean blendWithGlobalInterceptors) {
public GrpcServiceInfo {
Assert.notNull(interceptors, "interceptors must not be null");
Assert.notNull(interceptorNames, "interceptorNames must not be null");
}
/**
* Construct a service info from a {@link GrpcService} annotation.
* @param grpcService the service annotation
* @return the service info or null if the supplied annotation is null
*/
@Nullable
public static GrpcServiceInfo from(@Nullable GrpcService grpcService) {
return grpcService != null ? new GrpcServiceInfo(grpcService.interceptors(), grpcService.interceptorNames(),
grpcService.blendWithGlobalInterceptors()) : null;
}
/**
* Construct a service info with the specified interceptors.
* @param interceptors non-null list of interceptor bean types
* @return the service info with the supplied interceptors
*/
@SuppressWarnings("unchecked")
public static GrpcServiceInfo withInterceptors(List<Class<? extends ServerInterceptor>> interceptors) {
Assert.notNull(interceptors, "interceptors must not be null");
return new GrpcServiceInfo(interceptors.toArray(new Class[0]), new String[0], false);
}
/**
* Construct a service info with the specified interceptors names.
* @param interceptorNames non-null list of interceptor bean names
* @return the service info with the supplied interceptors names
*/
@SuppressWarnings("unchecked")
public static GrpcServiceInfo withInterceptorNames(List<String> interceptorNames) {
Assert.notNull(interceptorNames, "interceptorNames must not be null");
return new GrpcServiceInfo(new Class[0], interceptorNames.toArray(new String[0]), false);
}
}

View File

@@ -0,0 +1,346 @@
/*
* 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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.function.Function;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
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 org.springframework.lang.Nullable;
import io.grpc.BindableService;
import io.grpc.ServerInterceptor;
import io.grpc.ServerInterceptors;
import io.grpc.ServerServiceDefinition;
/**
* Tests for {@link DefaultGrpcServiceConfigurer}.
*
* @author Chris Bono
*/
class DefaultGrpcServiceConfigurerTests {
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(DefaultGrpcServiceConfigurer.class)
.extracting("globalInterceptors", InstanceOfAssertFactories.LIST)
.containsExactly(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR,
GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO));
}
@Nested
class WithNoServiceInfoSpecified {
@Test
void whenNoGlobalInterceptorsRegisteredThenServiceGetsNoInterceptors() {
customizeContextAndRunServiceConfigurerWithServiceInfo(Function.identity(), null, List.of());
}
@Test
void whenGlobalInterceptorsRegisteredThenServiceGetsGlobalInterceptors() {
customizeContextAndRunServiceConfigurerWithServiceInfo(
(contextRunner) -> contextRunner.withUserConfiguration(GlobalServerInterceptorsConfig.class), null,
List.of(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR,
GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO));
}
}
@Nested
class WithServiceInfoWithSingleInterceptor {
@Test
void whenSingleBeanOfInterceptorTypeRegisteredThenItIsUsed() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptors(List.of(TestServerInterceptorA.class));
List<ServerInterceptor> expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A);
customizeContextAndRunServiceConfigurerWithServiceInfo(
(contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class),
serviceInfo, expectedInterceptors);
}
@Test
void whenMultipleBeansOfInterceptorTypeRegisteredThenThrowsException() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptors(List.of(ServerInterceptor.class));
customizeContextAndRunServiceConfigurerWithServiceInfo(
(contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class),
serviceInfo, NoUniqueBeanDefinitionException.class);
}
@Test
void whenNoBeanOfInterceptorTypeRegisteredThenThrowsException() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptors(List.of(ServerInterceptor.class));
customizeContextAndRunServiceConfigurerWithServiceInfo(Function.identity(), serviceInfo,
NoSuchBeanDefinitionException.class);
}
}
@Nested
class WithServiceInfoWithMultipleInterceptors {
@Test
void whenSingleBeanOfEachInterceptorTypeRegisteredThenTheyAreUsed() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo
.withInterceptors(List.of(TestServerInterceptorB.class, TestServerInterceptorA.class));
List<ServerInterceptor> expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B,
ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A);
customizeContextAndRunServiceConfigurerWithServiceInfo(
(contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class),
serviceInfo, expectedInterceptors);
}
}
@Nested
class WithServiceInfoWithSingleInterceptorName {
@Test
void whenSingleBeanWithInterceptorNameRegisteredThenItIsUsed() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptorNames(List.of("interceptorB"));
List<ServerInterceptor> expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B);
customizeContextAndRunServiceConfigurerWithServiceInfo(
(contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class),
serviceInfo, expectedInterceptors);
}
@Test
void whenNoBeanWithInterceptorNameRegisteredThenThrowsException() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptorNames(List.of("interceptor1"));
customizeContextAndRunServiceConfigurerWithServiceInfo(Function.identity(), serviceInfo,
NoSuchBeanDefinitionException.class);
}
}
@Nested
class WithServiceInfoWithMultipleInterceptorNames {
@Test
void whenSingleBeanWithEachInterceptorNameRegisteredThenTheyAreUsed() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptorNames(List.of("interceptorB", "interceptorA"));
List<ServerInterceptor> expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B,
ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A);
customizeContextAndRunServiceConfigurerWithServiceInfo(
(contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class),
serviceInfo, expectedInterceptors);
}
}
@Nested
class WithServiceInfoWithInterceptorAndInterceptorName {
@SuppressWarnings("unchecked")
@Test
void whenSingleBeanOfEachAvailableThenTheyAreBothUsed() {
GrpcServiceInfo serviceInfo = new GrpcServiceInfo(new Class[] { TestServerInterceptorB.class },
new String[] { "interceptorA" }, false);
List<ServerInterceptor> expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B,
ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A);
customizeContextAndRunServiceConfigurerWithServiceInfo(
(contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class),
serviceInfo, expectedInterceptors);
}
}
@Nested
class WithServiceInfoCombinedWithGlobalInterceptors {
@Test
void whenBlendInterceptorsFalseThenGlobalInterceptorsAddedFirst() {
GrpcServiceInfo serviceInfo = GrpcServiceInfo
.withInterceptors(List.of(TestServerInterceptorB.class, TestServerInterceptorA.class));
List<ServerInterceptor> expectedInterceptors = List.of(
GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR,
GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO,
ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B,
ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A);
customizeContextAndRunServiceConfigurerWithServiceInfo((contextRunner) -> contextRunner
.withUserConfiguration(GlobalServerInterceptorsConfig.class, ServiceSpecificInterceptorsConfig.class),
serviceInfo, expectedInterceptors);
}
@SuppressWarnings("unchecked")
@Disabled("Needs 'blend interceptors' to be implemented")
@Test
void whenBlendInterceptorsTrueThenGlobalInterceptorsBlended() {
GrpcServiceInfo serviceInfo = new GrpcServiceInfo(
new Class[] { TestServerInterceptorB.class, TestServerInterceptorA.class }, new String[0], true);
List<ServerInterceptor> expectedInterceptors = List.of(
GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR,
ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B,
GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO,
ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A);
customizeContextAndRunServiceConfigurerWithServiceInfo((contextRunner) -> contextRunner
.withUserConfiguration(GlobalServerInterceptorsConfig.class, ServiceSpecificInterceptorsConfig.class),
serviceInfo, expectedInterceptors);
}
}
private void customizeContextAndRunServiceConfigurerWithServiceInfo(
Function<ApplicationContextRunner, ApplicationContextRunner> contextCustomizer, GrpcServiceInfo serviceInfo,
List<ServerInterceptor> expectedInterceptors) {
this.customizeContextAndRunServiceConfigurerWithServiceInfo(contextCustomizer, serviceInfo,
expectedInterceptors, null);
}
private void customizeContextAndRunServiceConfigurerWithServiceInfo(
Function<ApplicationContextRunner, ApplicationContextRunner> contextCustomizer, GrpcServiceInfo serviceInfo,
Class<? extends Throwable> expectedExceptionType) {
this.customizeContextAndRunServiceConfigurerWithServiceInfo(contextCustomizer, serviceInfo, null,
expectedExceptionType);
}
private void customizeContextAndRunServiceConfigurerWithServiceInfo(
Function<ApplicationContextRunner, ApplicationContextRunner> contextCustomizer, GrpcServiceInfo serviceInfo,
@Nullable List<ServerInterceptor> expectedInterceptors,
@Nullable Class<? extends Throwable> expectedExceptionType) {
// 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 on ServerInterceptors.interceptForward being
// well tested in grpc-java.
try (MockedStatic<ServerInterceptors> serverInterceptorsMocked = Mockito.mockStatic(ServerInterceptors.class)) {
serverInterceptorsMocked
.when(() -> ServerInterceptors.interceptForward(any(ServerServiceDefinition.class), anyList()))
.thenAnswer((Answer<ServerServiceDefinition>) invocation -> invocation.getArgument(0));
BindableService service = mock();
ServerServiceDefinition serviceDef = mock();
when(service.bindService()).thenReturn(serviceDef);
this.contextRunner()
.withBean("service", BindableService.class, () -> service)
.with(contextCustomizer)
.run((context) -> {
DefaultGrpcServiceConfigurer configurer = context.getBean(DefaultGrpcServiceConfigurer.class);
if (expectedExceptionType != null) {
assertThatThrownBy(() -> configurer.configure(service, serviceInfo))
.isInstanceOf(expectedExceptionType);
serverInterceptorsMocked.verifyNoInteractions();
}
else {
configurer.configure(service, serviceInfo);
serverInterceptorsMocked
.verify(() -> ServerInterceptors.interceptForward(serviceDef, expectedInterceptors));
}
});
}
}
interface TestServerInterceptorA extends ServerInterceptor {
}
interface TestServerInterceptorB extends ServerInterceptor {
}
@Configuration(proxyBeanMethods = false)
static class GlobalServerInterceptorsConfig {
static BindableService SERVICE_A = mock();
static ServerServiceDefinition SERVICE_DEF_A = mock();
static ServerInterceptor GLOBAL_INTERCEPTOR_FOO = mock();
static ServerInterceptor GLOBAL_INTERCEPTOR_IGNORED = mock();
static ServerInterceptor GLOBAL_INTERCEPTOR_BAR = mock();
@Bean
BindableService serviceA() {
when(SERVICE_A.bindService()).thenReturn(SERVICE_DEF_A);
return SERVICE_A;
}
@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;
}
}
@Configuration(proxyBeanMethods = false)
static class ServiceSpecificInterceptorsConfig {
static TestServerInterceptorB SVC_INTERCEPTOR_B = mock();
static TestServerInterceptorA SVC_INTERCEPTOR_A = mock();
@Bean
@Order(150)
TestServerInterceptorB interceptorB() {
return SVC_INTERCEPTOR_B;
}
@Bean
@Order(225)
TestServerInterceptorA interceptorA() {
return SVC_INTERCEPTOR_A;
}
}
}

View File

@@ -16,18 +16,18 @@
package org.springframework.grpc.autoconfigure.server;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.grpc.autoconfigure.server.DefaultGrpcServiceDiscovererTests.DefaultGrpcServiceDiscovererTestsConfig.SERVICE_A;
import static org.springframework.grpc.autoconfigure.server.DefaultGrpcServiceDiscovererTests.DefaultGrpcServiceDiscovererTestsConfig.SERVICE_B;
import java.util.LinkedHashMap;
import java.util.Map;
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;
@@ -36,12 +36,8 @@ 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;
import io.grpc.BindableService;
import io.grpc.ServerServiceDefinition;
/**
* Tests for {@link DefaultGrpcServiceDiscoverer}.
@@ -58,50 +54,29 @@ class DefaultGrpcServiceDiscovererTests {
}
@Test
void globalServerInterceptorsAreFoundInProperOrder() {
void servicesAreFoundInProperOrderWithExpectedGrpcServiceAnnotations() {
TestServiceConfigurer configurer = new TestServiceConfigurer();
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<ServerInterceptors> serverInterceptorsMocked = Mockito.mockStatic(ServerInterceptors.class)) {
serverInterceptorsMocked
.when(() -> ServerInterceptors.interceptForward(any(ServerServiceDefinition.class), anyList()))
.thenAnswer((Answer<ServerServiceDefinition>) invocation -> invocation.getArgument(0));
this.contextRunner().withUserConfiguration(GlobalServerInterceptorsConfig.class).run((context) -> {
.withUserConfiguration(DefaultGrpcServiceDiscovererTestsConfig.class)
.withBean("customServiceConfigurer", GrpcServiceConfigurer.class, () -> configurer)
.run((context) -> {
assertThat(context).getBean(DefaultGrpcServiceDiscoverer.class)
.extracting(DefaultGrpcServiceDiscoverer::findServices, InstanceOfAssertFactories.LIST)
.containsExactly(GlobalServerInterceptorsConfig.SERVICE_DEF_B,
GlobalServerInterceptorsConfig.SERVICE_DEF_A);
ArgumentCaptor<ServerServiceDefinition> serviceDefArg = ArgumentCaptor.captor();
ArgumentCaptor<List<ServerInterceptor>> 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));
.containsExactly(DefaultGrpcServiceDiscovererTestsConfig.SERVICE_DEF_B,
DefaultGrpcServiceDiscovererTestsConfig.SERVICE_DEF_A);
assertThat(configurer.invocations).hasSize(2);
assertThat(configurer.invocations.keySet()).containsExactly(SERVICE_B, SERVICE_A);
assertThat(configurer.invocations).containsEntry(SERVICE_B, null);
assertThat(configurer.invocations).hasEntrySatisfying(SERVICE_A, (serviceInfo) -> {
assertThat(serviceInfo.interceptors()).isEmpty();
assertThat(serviceInfo.interceptorNames()).isEmpty();
assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse();
});
});
}
}
@Configuration(proxyBeanMethods = false)
static class GlobalServerInterceptorsConfig {
static class DefaultGrpcServiceDiscovererTestsConfig {
static BindableService SERVICE_A = mock();
@@ -111,12 +86,7 @@ class DefaultGrpcServiceDiscovererTests {
static ServerServiceDefinition SERVICE_DEF_B = mock();
static ServerInterceptor GLOBAL_INTERCEPTOR_FOO = mock();
static ServerInterceptor GLOBAL_INTERCEPTOR_IGNORED = mock();
static ServerInterceptor GLOBAL_INTERCEPTOR_BAR = mock();
@GrpcService
@Bean
@Order(200)
BindableService serviceA() {
@@ -131,24 +101,16 @@ class DefaultGrpcServiceDiscovererTests {
return SERVICE_B;
}
@Bean
@Order(200)
@GlobalServerInterceptor
ServerInterceptor globalInterceptorFoo() {
return GLOBAL_INTERCEPTOR_FOO;
}
}
@Bean
@Order(150)
ServerInterceptor globalInterceptorIgnored() {
return GLOBAL_INTERCEPTOR_IGNORED;
}
static class TestServiceConfigurer implements GrpcServiceConfigurer {
@Bean
@Order(100)
@GlobalServerInterceptor
ServerInterceptor globalInterceptorBar() {
return GLOBAL_INTERCEPTOR_BAR;
Map<BindableService, GrpcServiceInfo> invocations = new LinkedHashMap<>();
@Override
public ServerServiceDefinition configure(BindableService bindableService, GrpcServiceInfo serviceInfo) {
invocations.put(bindableService, serviceInfo);
return bindableService.bindService();
}
}

View File

@@ -0,0 +1,118 @@
/*
* 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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotationUtils;
import io.grpc.ServerInterceptor;
import java.util.List;
/**
* Tests for {@link GrpcServiceInfo}.
*/
class GrpcServiceInfoTests {
@Nested
class WithInterceptorsApiInvokedWith {
@Test
void nullInterceptors() {
assertThatIllegalArgumentException().isThrownBy(() -> GrpcServiceInfo.withInterceptors(null))
.withMessage("interceptors must not be null");
}
@Test
void interceptorTypes() {
assertThat(GrpcServiceInfo.withInterceptors(List.of(ServerInterceptor.class))).satisfies((serviceInfo) -> {
assertThat(serviceInfo.interceptors()).containsExactly(ServerInterceptor.class);
assertThat(serviceInfo.interceptorNames()).isEmpty();
assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse();
});
}
}
@Nested
class WithInterceptorNamesApiInvokedWith {
@Test
void nullInterceptorNames() {
assertThatIllegalArgumentException().isThrownBy(() -> GrpcServiceInfo.withInterceptorNames(null))
.withMessage("interceptorNames must not be null");
}
@Test
void interceptorNames() {
assertThat(GrpcServiceInfo.withInterceptorNames(List.of("myInterceptor"))).satisfies((serviceInfo) -> {
assertThat(serviceInfo.interceptors()).isEmpty();
assertThat(serviceInfo.interceptorNames()).containsExactly("myInterceptor");
assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse();
});
}
}
@Nested
class FromApiInvokedWith {
@Test
void nullGrpcService() {
assertThat(GrpcServiceInfo.from(null)).isNull();
}
@Test
void grpcServiceAnnotationWithDefaults() {
var grpcServiceAnnotation = AnnotationUtils.findAnnotation(TestServiceMarkedWithDefaults.class,
GrpcService.class);
assertThat(GrpcServiceInfo.from(grpcServiceAnnotation)).satisfies((serviceInfo) -> {
assertThat(serviceInfo.interceptors()).isEmpty();
assertThat(serviceInfo.interceptorNames()).isEmpty();
assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse();
});
}
@Test
void grpcServiceAnnotationWithAttributes() {
var grpcServiceAnnotation = AnnotationUtils.findAnnotation(TestServiceMarkedWithAttributes.class,
GrpcService.class);
assertThat(GrpcServiceInfo.from(grpcServiceAnnotation)).satisfies((serviceInfo) -> {
assertThat(serviceInfo.interceptors()).containsExactly(ServerInterceptor.class);
assertThat(serviceInfo.interceptorNames()).containsExactly("myInterceptor");
assertThat(serviceInfo.blendWithGlobalInterceptors()).isTrue();
});
}
}
@GrpcService
static class TestServiceMarkedWithDefaults {
}
@GrpcService(interceptors = ServerInterceptor.class, interceptorNames = "myInterceptor",
blendWithGlobalInterceptors = true)
static class TestServiceMarkedWithAttributes {
}
}