Add ResponseBodyInterceptor

This change introduces a new ResponseBodyInterceptor interface that can
be used to modify the response after @ResponseBody or ResponseEntity
methods but before the body is actually written to the response with the
selected HttpMessageConverter.

The RequestMappingHandlerAdapter and ExceptionHandlerExceptionResolver
each have a property to configure such interceptors. In addition both
RequestMappingHandlerAdapter and ExceptionHandlerExceptionResolver
detect if any @ControllerAdvice bean implements ResponseBodyInterceptor
and use it accordingly.

Issue: SPR-10859
This commit is contained in:
Rossen Stoyanchev 2014-05-18 17:30:21 -04:00
parent f73a8baec7
commit 96b18c8dc2
8 changed files with 285 additions and 62 deletions

View File

@ -55,6 +55,8 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
private final ContentNegotiationManager contentNegotiationManager; private final ContentNegotiationManager contentNegotiationManager;
private final ResponseBodyInterceptorChain interceptorChain;
protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> messageConverters) { protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> messageConverters) {
this(messageConverters, null); this(messageConverters, null);
@ -62,9 +64,15 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> messageConverters, protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
ContentNegotiationManager manager) { ContentNegotiationManager manager) {
this(messageConverters, manager, null);
}
protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
ContentNegotiationManager manager, List<Object> responseBodyInterceptors) {
super(messageConverters); super(messageConverters);
this.contentNegotiationManager = (manager != null ? manager : new ContentNegotiationManager()); this.contentNegotiationManager = (manager != null ? manager : new ContentNegotiationManager());
this.interceptorChain = new ResponseBodyInterceptorChain(responseBodyInterceptors);
} }
@ -152,6 +160,9 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
} }
} }
if (messageConverter.canWrite(returnValueClass, selectedMediaType)) { if (messageConverter.canWrite(returnValueClass, selectedMediaType)) {
returnValue = this.interceptorChain.invoke(returnValue, selectedMediaType,
(Class<HttpMessageConverter<T>>) messageConverter.getClass(),
returnType, inputMessage, outputMessage);
((HttpMessageConverter<T>) messageConverter).write(returnValue, selectedMediaType, outputMessage); ((HttpMessageConverter<T>) messageConverter).write(returnValue, selectedMediaType, outputMessage);
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug("Written [" + returnValue + "] as \"" + selectedMediaType + "\" using [" + logger.debug("Written [" + returnValue + "] as \"" + selectedMediaType + "\" using [" +

View File

@ -79,6 +79,9 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
private ContentNegotiationManager contentNegotiationManager = new ContentNegotiationManager(); private ContentNegotiationManager contentNegotiationManager = new ContentNegotiationManager();
private final List<Object> responseBodyInterceptors = new ArrayList<Object>();
private final Map<Class<?>, ExceptionHandlerMethodResolver> exceptionHandlerCache = private final Map<Class<?>, ExceptionHandlerMethodResolver> exceptionHandlerCache =
new ConcurrentHashMap<Class<?>, ExceptionHandlerMethodResolver>(64); new ConcurrentHashMap<Class<?>, ExceptionHandlerMethodResolver>(64);
@ -106,6 +109,19 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
this.messageConverters.add(new AllEncompassingFormHttpMessageConverter()); this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
} }
/**
* Add one or more interceptors to be invoked after the execution of a controller
* method annotated with {@code @ResponseBody} or returning {@code ResponseEntity}
* but before the body is written to the response with the selected
* {@code HttpMessageConverter}.
*/
public void setResponseBodyInterceptors(List<ResponseBodyInterceptor> responseBodyInterceptors) {
this.responseBodyInterceptors.clear();
if (responseBodyInterceptors != null) {
this.responseBodyInterceptors.addAll(responseBodyInterceptors);
}
}
/** /**
* Provide resolvers for custom argument types. Custom resolvers are ordered * Provide resolvers for custom argument types. Custom resolvers are ordered
* after built-in ones. To override the built-in support for argument * after built-in ones. To override the built-in support for argument
@ -233,6 +249,10 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
@Override @Override
public void afterPropertiesSet() { public void afterPropertiesSet() {
// Do this first, it may add ResponseBody interceptors
initExceptionHandlerAdviceCache();
if (this.argumentResolvers == null) { if (this.argumentResolvers == null) {
List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers(); List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers();
this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers); this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers);
@ -241,7 +261,30 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers(); List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers();
this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers); this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers);
} }
initExceptionHandlerAdviceCache(); }
private void initExceptionHandlerAdviceCache() {
if (getApplicationContext() == null) {
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Looking for exception mappings: " + getApplicationContext());
}
List<ControllerAdviceBean> beans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
Collections.sort(beans, new OrderComparator());
for (ControllerAdviceBean bean : beans) {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(bean.getBeanType());
if (resolver.hasExceptionMappings()) {
this.exceptionHandlerAdviceCache.put(bean, resolver);
logger.info("Detected @ExceptionHandler methods in " + bean);
}
if (ResponseBodyInterceptor.class.isAssignableFrom(bean.getBeanType())) {
this.responseBodyInterceptors.add(bean);
logger.info("Detected ResponseBodyInterceptor implementation in " + bean);
}
}
} }
/** /**
@ -274,11 +317,13 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
handlers.add(new ModelAndViewMethodReturnValueHandler()); handlers.add(new ModelAndViewMethodReturnValueHandler());
handlers.add(new ModelMethodProcessor()); handlers.add(new ModelMethodProcessor());
handlers.add(new ViewMethodReturnValueHandler()); handlers.add(new ViewMethodReturnValueHandler());
handlers.add(new HttpEntityMethodProcessor(getMessageConverters(), this.contentNegotiationManager)); handlers.add(new HttpEntityMethodProcessor(
getMessageConverters(), this.contentNegotiationManager, this.responseBodyInterceptors));
// Annotation-based return value types // Annotation-based return value types
handlers.add(new ModelAttributeMethodProcessor(false)); handlers.add(new ModelAttributeMethodProcessor(false));
handlers.add(new RequestResponseBodyMethodProcessor(getMessageConverters(), this.contentNegotiationManager)); handlers.add(new RequestResponseBodyMethodProcessor(
getMessageConverters(), this.contentNegotiationManager, this.responseBodyInterceptors));
// Multi-purpose return value types // Multi-purpose return value types
handlers.add(new ViewNameMethodReturnValueHandler()); handlers.add(new ViewNameMethodReturnValueHandler());
@ -295,26 +340,6 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
return handlers; return handlers;
} }
private void initExceptionHandlerAdviceCache() {
if (getApplicationContext() == null) {
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Looking for exception mappings: " + getApplicationContext());
}
List<ControllerAdviceBean> beans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
Collections.sort(beans, new OrderComparator());
for (ControllerAdviceBean bean : beans) {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(bean.getBeanType());
if (resolver.hasExceptionMappings()) {
this.exceptionHandlerAdviceCache.put(bean, resolver);
logger.info("Detected @ExceptionHandler methods in " + bean);
}
}
}
/** /**
* Find an {@code @ExceptionHandler} method and invoke it to handle the raised exception. * Find an {@code @ExceptionHandler} method and invoke it to handle the raised exception.
*/ */

View File

@ -57,12 +57,16 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> messageConverters, public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
ContentNegotiationManager contentNegotiationManager) { ContentNegotiationManager contentNegotiationManager) {
super(messageConverters, contentNegotiationManager); super(messageConverters, contentNegotiationManager);
} }
public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
ContentNegotiationManager contentNegotiationManager, List<Object> responseBodyInterceptors) {
super(messageConverters, contentNegotiationManager, responseBodyInterceptors);
}
@Override
@Override
public boolean supportsParameter(MethodParameter parameter) { public boolean supportsParameter(MethodParameter parameter) {
return HttpEntity.class.equals(parameter.getParameterType()); return HttpEntity.class.equals(parameter.getParameterType());
} }

View File

@ -132,8 +132,11 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
private List<HttpMessageConverter<?>> messageConverters; private List<HttpMessageConverter<?>> messageConverters;
private List<Object> responseBodyInterceptors = new ArrayList<Object>();
private WebBindingInitializer webBindingInitializer; private WebBindingInitializer webBindingInitializer;
private AsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor("MvcAsync"); private AsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor("MvcAsync");
private Long asyncRequestTimeout; private Long asyncRequestTimeout;
@ -306,6 +309,14 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
return modelAndViewResolvers; return modelAndViewResolvers;
} }
/**
* Set the {@link ContentNegotiationManager} to use to determine requested media types.
* If not set, the default constructor is used.
*/
public void setContentNegotiationManager(ContentNegotiationManager contentNegotiationManager) {
this.contentNegotiationManager = contentNegotiationManager;
}
/** /**
* Provide the converters to use in argument resolvers and return value * Provide the converters to use in argument resolvers and return value
* handlers that support reading and/or writing to the body of the * handlers that support reading and/or writing to the body of the
@ -315,14 +326,6 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
this.messageConverters = messageConverters; this.messageConverters = messageConverters;
} }
/**
* Set the {@link ContentNegotiationManager} to use to determine requested media types.
* If not set, the default constructor is used.
*/
public void setContentNegotiationManager(ContentNegotiationManager contentNegotiationManager) {
this.contentNegotiationManager = contentNegotiationManager;
}
/** /**
* Return the configured message body converters. * Return the configured message body converters.
*/ */
@ -330,6 +333,19 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
return messageConverters; return messageConverters;
} }
/**
* Add one or more interceptors to be invoked after the execution of a controller
* method annotated with {@code @ResponseBody} or returning {@code ResponseEntity}
* but before the body is written to the response with the selected
* {@code HttpMessageConverter}.
*/
public void setResponseBodyInterceptors(List<ResponseBodyInterceptor> responseBodyInterceptors) {
this.responseBodyInterceptors.clear();
if (responseBodyInterceptors != null) {
this.responseBodyInterceptors.addAll(responseBodyInterceptors);
}
}
/** /**
* Provide a WebBindingInitializer with "global" initialization to apply * Provide a WebBindingInitializer with "global" initialization to apply
* to every DataBinder instance. * to every DataBinder instance.
@ -481,6 +497,10 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
@Override @Override
public void afterPropertiesSet() { public void afterPropertiesSet() {
// Do this first, it may add ResponseBody interceptors
initControllerAdviceCache();
if (this.argumentResolvers == null) { if (this.argumentResolvers == null) {
List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers(); List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers();
this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers); this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers);
@ -493,7 +513,35 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers(); List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers();
this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers); this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers);
} }
initControllerAdviceCache(); }
private void initControllerAdviceCache() {
if (getApplicationContext() == null) {
return;
}
if (logger.isInfoEnabled()) {
logger.info("Looking for @ControllerAdvice: " + getApplicationContext());
}
List<ControllerAdviceBean> beans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
Collections.sort(beans, new OrderComparator());
for (ControllerAdviceBean bean : beans) {
Set<Method> attrMethods = HandlerMethodSelector.selectMethods(bean.getBeanType(), MODEL_ATTRIBUTE_METHODS);
if (!attrMethods.isEmpty()) {
this.modelAttributeAdviceCache.put(bean, attrMethods);
logger.info("Detected @ModelAttribute methods in " + bean);
}
Set<Method> binderMethods = HandlerMethodSelector.selectMethods(bean.getBeanType(), INIT_BINDER_METHODS);
if (!binderMethods.isEmpty()) {
this.initBinderAdviceCache.put(bean, binderMethods);
logger.info("Detected @InitBinder methods in " + bean);
}
if (ResponseBodyInterceptor.class.isAssignableFrom(bean.getBeanType())) {
this.responseBodyInterceptors.add(bean);
logger.info("Detected ResponseBodyInterceptor implementation in " + bean);
}
}
} }
/** /**
@ -583,7 +631,8 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
handlers.add(new ModelAndViewMethodReturnValueHandler()); handlers.add(new ModelAndViewMethodReturnValueHandler());
handlers.add(new ModelMethodProcessor()); handlers.add(new ModelMethodProcessor());
handlers.add(new ViewMethodReturnValueHandler()); handlers.add(new ViewMethodReturnValueHandler());
handlers.add(new HttpEntityMethodProcessor(getMessageConverters(), this.contentNegotiationManager)); handlers.add(new HttpEntityMethodProcessor(
getMessageConverters(), this.contentNegotiationManager, this.responseBodyInterceptors));
handlers.add(new HttpHeadersReturnValueHandler()); handlers.add(new HttpHeadersReturnValueHandler());
handlers.add(new CallableMethodReturnValueHandler()); handlers.add(new CallableMethodReturnValueHandler());
handlers.add(new DeferredResultMethodReturnValueHandler()); handlers.add(new DeferredResultMethodReturnValueHandler());
@ -592,7 +641,8 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
// Annotation-based return value types // Annotation-based return value types
handlers.add(new ModelAttributeMethodProcessor(false)); handlers.add(new ModelAttributeMethodProcessor(false));
handlers.add(new RequestResponseBodyMethodProcessor(getMessageConverters(), this.contentNegotiationManager)); handlers.add(new RequestResponseBodyMethodProcessor(
getMessageConverters(), this.contentNegotiationManager, this.responseBodyInterceptors));
// Multi-purpose return value types // Multi-purpose return value types
handlers.add(new ViewNameMethodReturnValueHandler()); handlers.add(new ViewNameMethodReturnValueHandler());
@ -614,31 +664,6 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
return handlers; return handlers;
} }
private void initControllerAdviceCache() {
if (getApplicationContext() == null) {
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Looking for controller advice: " + getApplicationContext());
}
List<ControllerAdviceBean> beans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
Collections.sort(beans, new OrderComparator());
for (ControllerAdviceBean bean : beans) {
Set<Method> attrMethods = HandlerMethodSelector.selectMethods(bean.getBeanType(), MODEL_ATTRIBUTE_METHODS);
if (!attrMethods.isEmpty()) {
this.modelAttributeAdviceCache.put(bean, attrMethods);
logger.info("Detected @ModelAttribute methods in " + bean);
}
Set<Method> binderMethods = HandlerMethodSelector.selectMethods(bean.getBeanType(), INIT_BINDER_METHODS);
if (!binderMethods.isEmpty()) {
this.initBinderAdviceCache.put(bean, binderMethods);
logger.info("Detected @InitBinder methods in " + bean);
}
}
}
/** /**
* Always return {@code true} since any method argument and return value * Always return {@code true} since any method argument and return value
* type will be processed in some way. A method argument not recognized * type will be processed in some way. A method argument not recognized

View File

@ -69,10 +69,15 @@ public class RequestResponseBodyMethodProcessor extends AbstractMessageConverter
public RequestResponseBodyMethodProcessor(List<HttpMessageConverter<?>> messageConverters, public RequestResponseBodyMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
ContentNegotiationManager contentNegotiationManager) { ContentNegotiationManager contentNegotiationManager) {
super(messageConverters, contentNegotiationManager); super(messageConverters, contentNegotiationManager);
} }
public RequestResponseBodyMethodProcessor(List<HttpMessageConverter<?>> messageConverters,
ContentNegotiationManager contentNegotiationManager, List<Object> responseBodyInterceptors) {
super(messageConverters, contentNegotiationManager, responseBodyInterceptors);
}
@Override @Override
public boolean supportsParameter(MethodParameter parameter) { public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(RequestBody.class); return parameter.hasParameterAnnotation(RequestBody.class);

View File

@ -0,0 +1,52 @@
/*
* Copyright 2002-2014 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
*
* 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.web.servlet.mvc.method.annotation;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
/**
* Allows customizing the response after the execution of an {@code @ResponseBody}
* or an {@code ResponseEntity} controller method but before the body is written
* with an {@code HttpMessageConverter}.
*
* @author Rossen Stoyanchev
* @since 4.1
*/
public interface ResponseBodyInterceptor {
/**
* Invoked after an {@code HttpMessageConverter} is selected and just before
* its write method is invoked.
*
* @param body the body to be written
* @param contentType the selected content type
* @param converterType the selected converter that will write the body
* @param returnType the return type of the controller method
* @param request the current request
* @param response the current response
* @param <T> the type supported by the message converter
*
* @return the body that was passed in or a modified, possibly new instance
*/
<T> T beforeBodyWrite(T body, MediaType contentType, Class<HttpMessageConverter<T>> converterType,
MethodParameter returnType, ServerHttpRequest request, ServerHttpResponse response);
}

View File

@ -0,0 +1,75 @@
/*
* Copyright 2002-2014 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
*
* 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.web.servlet.mvc.method.annotation;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.web.method.ControllerAdviceBean;
import java.util.List;
/**
* Invokes a a list of ResponseBodyInterceptor's.
*
* @author Rossen Stoyanchev
* @since 4.1
*/
class ResponseBodyInterceptorChain {
private static Log logger = LogFactory.getLog(ResponseBodyInterceptorChain.class);
private final List<Object> interceptors;
public ResponseBodyInterceptorChain(List<Object> interceptors) {
this.interceptors = interceptors;
}
public <T> T invoke(T body, MediaType contentType, Class<HttpMessageConverter<T>> converterType,
MethodParameter returnType, ServerHttpRequest request, ServerHttpResponse response) {
if (this.interceptors != null) {
if (logger.isDebugEnabled()) {
logger.debug("Invoking ResponseBody interceptor chain for body=" + body);
}
for (Object interceptor : this.interceptors) {
if (interceptor instanceof ControllerAdviceBean) {
ControllerAdviceBean adviceBean = (ControllerAdviceBean) interceptor;
if (!adviceBean.isApplicableToBeanType(returnType.getContainingClass())) {
continue;
}
interceptor = adviceBean.resolveBean();
}
Assert.state(interceptor instanceof ResponseBodyInterceptor);
body = ((ResponseBodyInterceptor) interceptor).beforeBodyWrite(
body, contentType, converterType, returnType, request, response);
}
if (logger.isDebugEnabled()) {
logger.debug("After interceptor chain body=" + body);
}
}
return body;
}
}

View File

@ -0,0 +1,26 @@
/*
* Copyright 2002-2014 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
*
* 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.web.servlet.mvc.method.annotation;
/**
* @author Rossen Stoyanchev
* @since 4.1
*/
public class ResponseBodyInterceptorChainTests {
}