InvocableHandlerMethod and arg resolution updates

General improvements e.g. make use of Java 8 Stream. The main reason
for the refactoring however to tighten error handling. To that extent:

InvocableHandlerMethod turns all exceptions into Reactive Streams
error signals, in effect never allowing any Exceptions to bubble up.

HandlerMethodArgumentResolver may throw an Exception for sync resolution
or produce an error signal via the returned Publisher. Either way the
exception is consistently wrapped with helpful method argument details.
For the latter case using a custom mapError operator.

HandlerMethodArgumentResolver no longer needs to return Optional for
nullable argument values. Instead (for now) the defaultIfEmpty operator
of reactor.rx.Stream operator is used to ensure a default constant value
(called "NO_VALUE") is produced. That way an argument resolver may
produce 0..1 values where 0 means it did not resolve to any value and
that results in null passed as the argument value.

If a HandlerMethodArgumentResolver produces more than one value, all
additional values beyond the first one will be ignored with the help
of a custom "first" operator.

As HandlerMethod is invoked within the map operator, checked exceptions
are not allowed but instead of wrapping it in a runtime exception what
we really need is to unwrap the target exception for exception
resolution purposes. To this end concatMap is used to produce a nested
Publisher or an error Publisher with the unwrapped target exception.

Related to that InvocableHandlerMethod now returns
Publisher<HandlerResult> instead of Publisher<Object> so that no longer
needs to be externally mapped from Object to HandlerResult.

InvocableHandlerMethodTests provides tests for the above scenarios and
verifies the details of resulting error signals.
This commit is contained in:
Rossen Stoyanchev
2015-12-05 16:53:00 -05:00
parent ce0bd2efc2
commit 45706422dd
5 changed files with 365 additions and 163 deletions

View File

@@ -0,0 +1,236 @@
/*
* Copyright 2002-2015 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.reactive.method;
import java.lang.reflect.Method;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.Before;
import org.junit.Test;
import org.reactivestreams.Publisher;
import reactor.Publishers;
import reactor.rx.Streams;
import reactor.rx.action.Signal;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.method.annotation.RequestParamArgumentResolver;
import static org.junit.Assert.assertEquals;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* @author Rossen Stoyanchev
*/
@SuppressWarnings("ThrowableResultOfMethodCallIgnored")
public class InvocableHandlerMethodTests {
private static Log logger = LogFactory.getLog(InvocableHandlerMethodTests.class);
private ServerHttpRequest request;
@Before
public void setUp() throws Exception {
this.request = mock(ServerHttpRequest.class);
}
@Test
public void noArgsMethod() throws Exception {
InvocableHandlerMethod hm = createHandlerMethod("noArgs");
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Object value = awaitValue(publisher);
assertEquals("success", value);
}
@Test
public void resolveArgToZeroValues() throws Exception {
when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/path"));
InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class);
hm.setHandlerMethodArgumentResolvers(Collections.singletonList(new RequestParamArgumentResolver()));
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Object value = awaitValue(publisher);
assertEquals("success:null", value);
}
@Test
public void resolveArgToOneValue() throws Exception {
InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class);
addResolver(hm, Publishers.just("value1"));
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Object value = awaitValue(publisher);
assertEquals("success:value1", value);
}
@Test
public void resolveArgToMultipleValues() throws Exception {
InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class);
addResolver(hm, Publishers.from(Arrays.asList("value1", "value2", "value3")));
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
List<Signal<HandlerResult>> signals = awaitSignals(publisher);
assertEquals("Expected only one value: " + signals.toString(), 2, signals.size());
assertEquals(Signal.Type.NEXT, signals.get(0).getType());
assertEquals(Signal.Type.COMPLETE, signals.get(1).getType());
assertEquals("success:value1", signals.get(0).get().getValue());
}
@Test
public void noResolverForArg() throws Exception {
InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class);
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Throwable ex = awaitErrorSignal(publisher);
assertEquals(IllegalStateException.class, ex.getClass());
assertEquals("No resolver for argument [0] of type [java.lang.String] on method " +
"[" + hm.getMethod().toGenericString() + "]", ex.getMessage());
}
@Test
public void resolveArgumentWithThrownException() throws Exception {
HandlerMethodArgumentResolver resolver = mock(HandlerMethodArgumentResolver.class);
when(resolver.supportsParameter(any())).thenReturn(true);
when(resolver.resolveArgument(any(), any())).thenThrow(new IllegalStateException("boo"));
InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class);
hm.setHandlerMethodArgumentResolvers(Collections.singletonList(resolver));
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Throwable ex = awaitErrorSignal(publisher);
assertEquals(IllegalStateException.class, ex.getClass());
assertEquals("Exception not wrapped with helpful argument details",
"Error resolving argument [0] of type [java.lang.String] on method " +
"[" + hm.getMethod().toGenericString() + "]", ex.getMessage());
}
@Test
public void resolveArgumentWithErrorSignal() throws Exception {
InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class);
addResolver(hm, Publishers.error(new IllegalStateException("boo")));
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Throwable ex = awaitErrorSignal(publisher);
assertEquals(IllegalStateException.class, ex.getClass());
assertEquals("Exception not wrapped with helpful argument details",
"Error resolving argument [0] of type [java.lang.String] on method " +
"[" + hm.getMethod().toGenericString() + "]", ex.getMessage());
}
@Test
public void illegalArgumentExceptionIsWrappedWithHelpfulDetails() throws Exception {
InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class);
addResolver(hm, Publishers.just(1));
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Throwable ex = awaitErrorSignal(publisher);
assertEquals(IllegalStateException.class, ex.getClass());
assertEquals("Failed to invoke controller with resolved arguments: " +
"[0][type=java.lang.Integer][value=1] " +
"on method [" + hm.getMethod().toGenericString() + "]", ex.getMessage());
}
@Test
public void invocationTargetExceptionIsUnwrapped() throws Exception {
InvocableHandlerMethod hm = createHandlerMethod("exceptionMethod");
Publisher<HandlerResult> publisher = hm.invokeForRequest(this.request);
Throwable ex = awaitErrorSignal(publisher);
assertEquals(IllegalStateException.class, ex.getClass());
assertEquals("boo", ex.getMessage());
}
private InvocableHandlerMethod createHandlerMethod(String methodName, Class<?>... argTypes) throws Exception {
Object controller = new TestController();
Method method = controller.getClass().getMethod(methodName, argTypes);
return new InvocableHandlerMethod(new HandlerMethod(controller, method));
}
private void addResolver(InvocableHandlerMethod handlerMethod, Publisher<Object> resolvedValue) {
HandlerMethodArgumentResolver resolver = mock(HandlerMethodArgumentResolver.class);
when(resolver.supportsParameter(any())).thenReturn(true);
when(resolver.resolveArgument(any(), any())).thenReturn(resolvedValue);
handlerMethod.setHandlerMethodArgumentResolvers(Collections.singletonList(resolver));
}
private Object awaitValue(Publisher<HandlerResult> publisher) throws Exception {
Object object = awaitSignal(publisher, Signal.Type.NEXT).get();
assertEquals(HandlerResult.class, object.getClass());
return ((HandlerResult) object).getValue();
}
private Throwable awaitErrorSignal(Publisher<HandlerResult> publisher) throws Exception {
return awaitSignal(publisher, Signal.Type.ERROR).getThrowable();
}
@SuppressWarnings("unchecked")
private Signal<HandlerResult> awaitSignal(Publisher<HandlerResult> publisher, Signal.Type type) throws Exception {
Signal<HandlerResult> signal = awaitSignals(publisher).get(0);
if (!type.equals(signal.getType()) && signal.isOnError()) {
logger.error("Unexpected error: ", signal.getThrowable());
}
assertEquals("Unexpected signal: " + signal, type, signal.getType());
return signal;
}
private List<Signal<HandlerResult>> awaitSignals(Publisher<HandlerResult> publisher) throws InterruptedException {
return Streams.wrap(publisher).materialize().toList().await(5, TimeUnit.SECONDS);
}
@SuppressWarnings("unused")
private static class TestController {
public String noArgs() {
return "success";
}
public String singleArg(@RequestParam(required=false) String q) {
return "success:" + q;
}
public void exceptionMethod() {
throw new IllegalStateException("boo");
}
}
}