From 2f8baac4e02ac0b8099a84d2d7c6d401bc7f96d8 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 10 Jun 2016 15:52:29 -0400 Subject: [PATCH] Validation support for @RequestBody with @Validated --- .../RequestBodyArgumentResolver.java | 63 +++++++++++++++++++ .../RequestBodyArgumentResolverTests.java | 63 ++++++++++++++----- 2 files changed, 112 insertions(+), 14 deletions(-) diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolver.java index 69d6d892c7d..b58123dadd1 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolver.java @@ -16,6 +16,7 @@ package org.springframework.web.reactive.result.method.annotation; +import java.lang.annotation.Annotation; import java.util.List; import java.util.stream.Collectors; @@ -23,16 +24,25 @@ import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.Conventions; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.convert.ConversionService; import org.springframework.http.MediaType; import org.springframework.http.converter.reactive.HttpMessageConverter; import org.springframework.ui.ModelMap; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.Errors; +import org.springframework.validation.SmartValidator; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.UnsupportedMediaTypeStatusException; /** @@ -50,6 +60,8 @@ public class RequestBodyArgumentResolver implements HandlerMethodArgumentResolve private final ConversionService conversionService; + private final Validator validator; + private final List supportedMediaTypes; @@ -61,10 +73,23 @@ public class RequestBodyArgumentResolver implements HandlerMethodArgumentResolve public RequestBodyArgumentResolver(List> converters, ConversionService service) { + this(converters, service, null); + } + + /** + * Constructor with message converters and a ConversionService. + * @param converters converters for reading the request body with + * @param service for converting to other reactive types from Flux and Mono + * @param validator validator to validate decoded objects with + */ + public RequestBodyArgumentResolver(List> converters, + ConversionService service, Validator validator) { + Assert.notEmpty(converters, "At least one message converter is required."); Assert.notNull(service, "'conversionService' is required."); this.messageConverters = converters; this.conversionService = service; + this.validator = validator; this.supportedMediaTypes = converters.stream() .flatMap(converter -> converter.getReadableMediaTypes().stream()) .collect(Collectors.toList()); @@ -107,6 +132,11 @@ public class RequestBodyArgumentResolver implements HandlerMethodArgumentResolve for (HttpMessageConverter converter : getMessageConverters()) { if (converter.canRead(elementType, mediaType)) { Flux elementFlux = converter.read(elementType, exchange.getRequest()); + + if (this.validator != null) { + elementFlux= applyValidationIfApplicable(elementFlux, parameter); + } + if (Mono.class.equals(type.getRawClass())) { return Mono.just(Mono.from(elementFlux)); } @@ -130,4 +160,37 @@ public class RequestBodyArgumentResolver implements HandlerMethodArgumentResolve getConversionService().canConvert(Publisher.class, type.getRawClass())); } + protected Flux applyValidationIfApplicable(Flux elementFlux, MethodParameter methodParam) { + Annotation[] annotations = methodParam.getParameterAnnotations(); + for (Annotation ann : annotations) { + Validated validAnnot = AnnotationUtils.getAnnotation(ann, Validated.class); + if (validAnnot != null || ann.annotationType().getSimpleName().startsWith("Valid")) { + Object hints = (validAnnot != null ? validAnnot.value() : AnnotationUtils.getValue(ann)); + Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints}); + return elementFlux.map(element -> { + validate(element, validationHints, methodParam); + return element; + }); + } + } + return elementFlux; + } + + /** + * TODO: replace with use of DataBinder + */ + private void validate(Object target, Object[] validationHints, MethodParameter methodParam) { + String name = Conventions.getVariableNameForParameter(methodParam); + Errors errors = new BeanPropertyBindingResult(target, name); + if (!ObjectUtils.isEmpty(validationHints) && this.validator instanceof SmartValidator) { + ((SmartValidator) this.validator).validate(target, errors, validationHints); + } + else if (this.validator != null) { + this.validator.validate(target, errors); + } + if (errors.hasErrors()) { + throw new ServerWebInputException("Validation failed", methodParam); + } + } + } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolverTests.java index 56178053d3e..50bb84234d5 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolverTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolverTests.java @@ -27,7 +27,6 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; - import javax.xml.bind.annotation.XmlRootElement; import org.junit.Before; @@ -61,8 +60,12 @@ import org.springframework.http.server.reactive.MockServerHttpResponse; import org.springframework.ui.ExtendedModelMap; import org.springframework.ui.ModelMap; import org.springframework.util.ReflectionUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.UnsupportedMediaTypeStatusException; import org.springframework.web.server.adapter.DefaultServerWebExchange; import org.springframework.web.server.session.DefaultWebSessionManager; @@ -120,28 +123,28 @@ public class RequestBodyArgumentResolverTests { @Test @SuppressWarnings("unchecked") public void monoTestBean() throws Exception { String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; - Mono mono = (Mono) resolve("monoTestBean", Mono.class, body); + Mono mono = (Mono) resolveValue("monoTestBean", Mono.class, body); assertEquals(new TestBean("f1", "b1"), mono.block()); } @Test @SuppressWarnings("unchecked") public void fluxTestBean() throws Exception { String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; - Flux flux = (Flux) resolve("fluxTestBean", Flux.class, body); + Flux flux = (Flux) resolveValue("fluxTestBean", Flux.class, body); assertEquals(Arrays.asList(new TestBean("f1", "b1"), new TestBean("f2", "b2")), flux.collectList().block()); } @Test @SuppressWarnings("unchecked") public void singleTestBean() throws Exception { String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; - Single single = (Single) resolve("singleTestBean", Single.class, body); + Single single = (Single) resolveValue("singleTestBean", Single.class, body); assertEquals(new TestBean("f1", "b1"), single.toBlocking().value()); } @Test @SuppressWarnings("unchecked") public void observableTestBean() throws Exception { String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; - Observable observable = (Observable) resolve("observableTestBean", Observable.class, body); + Observable observable = (Observable) resolveValue("observableTestBean", Observable.class, body); assertEquals(Arrays.asList(new TestBean("f1", "b1"), new TestBean("f2", "b2")), observable.toList().toBlocking().first()); } @@ -149,13 +152,13 @@ public class RequestBodyArgumentResolverTests { @Test @SuppressWarnings("unchecked") public void futureTestBean() throws Exception { String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; - assertEquals(new TestBean("f1", "b1"), resolve("futureTestBean", CompletableFuture.class, body).get()); + assertEquals(new TestBean("f1", "b1"), resolveValue("futureTestBean", CompletableFuture.class, body).get()); } @Test public void testBean() throws Exception { String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; - assertEquals(new TestBean("f1", "b1"), resolve("testBean", TestBean.class, body)); + assertEquals(new TestBean("f1", "b1"), resolveValue("testBean", TestBean.class, body)); } @Test @@ -164,7 +167,7 @@ public class RequestBodyArgumentResolverTests { Map map = new HashMap<>(); map.put("foo", "f1"); map.put("bar", "b1"); - assertEquals(map, resolve("map", Map.class, body)); + assertEquals(map, resolveValue("map", Map.class, body)); } // TODO: @Ignore @@ -174,7 +177,7 @@ public class RequestBodyArgumentResolverTests { public void list() throws Exception { String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; assertEquals(Arrays.asList(new TestBean("f1", "b1"), new TestBean("f2", "b2")), - resolve("list", List.class, body)); + resolveValue("list", List.class, body)); } @Test @@ -182,12 +185,28 @@ public class RequestBodyArgumentResolverTests { public void array() throws Exception { String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; assertArrayEquals(new TestBean[] {new TestBean("f1", "b1"), new TestBean("f2", "b2")}, - resolve("array", TestBean[].class, body)); + resolveValue("array", TestBean[].class, body)); + } + + @Test @SuppressWarnings("unchecked") + public void validateMonoTestBean() throws Exception { + String body = "{\"bar\":\"b1\"}"; + Mono mono = (Mono) resolveValue("monoTestBean", Mono.class, body); + TestSubscriber.subscribe(mono).assertNoValues().assertError(ServerWebInputException.class); + } + + @Test @SuppressWarnings("unchecked") + public void validateFluxTestBean() throws Exception { + String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\"}]"; + Flux flux = (Flux) resolveValue("fluxTestBean", Flux.class, body); + + TestSubscriber.subscribe(flux).assertValues(new TestBean("f1", "b1")) + .assertError(ServerWebInputException.class); } @SuppressWarnings("unchecked") - private T resolve(String paramName, Class valueType, String body) { + private T resolveValue(String paramName, Class valueType, String body) { this.request.getHeaders().setContentType(MediaType.APPLICATION_JSON); this.request.writeWith(Flux.just(dataBuffer(body))); Mono result = this.resolver.resolveArgument(parameter(paramName), this.model, this.exchange); @@ -204,7 +223,7 @@ public class RequestBodyArgumentResolverTests { GenericConversionService service = new GenericConversionService(); service.addConverter(new ReactiveStreamsToCompletableFutureConverter()); service.addConverter(new ReactiveStreamsToRxJava1Converter()); - return new RequestBodyArgumentResolver(converters, service); + return new RequestBodyArgumentResolver(converters, service, new TestBeanValidator()); } @SuppressWarnings("ConfusingArgumentToVarargsMethod") @@ -230,8 +249,8 @@ public class RequestBodyArgumentResolverTests { @SuppressWarnings("unused") void handle( - @RequestBody Mono monoTestBean, - @RequestBody Flux fluxTestBean, + @Validated @RequestBody Mono monoTestBean, + @Validated @RequestBody Flux fluxTestBean, @RequestBody Single singleTestBean, @RequestBody Observable observableTestBean, @RequestBody CompletableFuture futureTestBean, @@ -297,4 +316,20 @@ public class RequestBodyArgumentResolverTests { return "TestBean[foo='" + this.foo + "\'" + ", bar='" + this.bar + "\']"; } } + + static class TestBeanValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return clazz.equals(TestBean.class); + } + + @Override + public void validate(Object target, Errors errors) { + TestBean testBean = (TestBean) target; + if (testBean.getFoo() == null) { + errors.rejectValue("foo", "nullValue"); + } + } + } }