diff --git a/flyfish-common/src/main/java/com/flyfish/framework/validation/spi/ConditionalGroup.java b/flyfish-common/src/main/java/com/flyfish/framework/validation/spi/ConditionalGroup.java new file mode 100644 index 0000000..9f339a4 --- /dev/null +++ b/flyfish-common/src/main/java/com/flyfish/framework/validation/spi/ConditionalGroup.java @@ -0,0 +1,20 @@ +package com.flyfish.framework.validation.spi; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 指定动态分组 + * @author wangyu + */ +@Target({TYPE}) +@Retention(RUNTIME) +@Documented +public @interface ConditionalGroup { + + Class>[] value() default {}; +} diff --git a/flyfish-common/src/main/java/com/flyfish/framework/validation/spi/ConditionalValidGroupProvider.java b/flyfish-common/src/main/java/com/flyfish/framework/validation/spi/ConditionalValidGroupProvider.java new file mode 100644 index 0000000..68d39d8 --- /dev/null +++ b/flyfish-common/src/main/java/com/flyfish/framework/validation/spi/ConditionalValidGroupProvider.java @@ -0,0 +1,19 @@ +package com.flyfish.framework.validation.spi; + +import javax.validation.groups.Default; + +/** + * 条件化的验证分组 + * + * @author wangyu + */ +public interface ConditionalValidGroupProvider { + + /** + * 根据数据返回不同的验证分组 + * + * @param data 数据 + * @return 结果 + */ + Class[] provide(T data); +} diff --git a/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/MoneyValidator.java b/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/MoneyValidator.java index 8f855f8..df0b44e 100644 --- a/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/MoneyValidator.java +++ b/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/MoneyValidator.java @@ -24,6 +24,6 @@ public class MoneyValidator implements ConstraintValidator { */ @Override public boolean isValid(Long value, ConstraintValidatorContext context) { - return value >= 9; + return null == value || value >= 9; } } diff --git a/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/PhoneValidator.java b/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/PhoneValidator.java index 34054d4..f3d28ea 100644 --- a/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/PhoneValidator.java +++ b/flyfish-common/src/main/java/com/flyfish/framework/validation/validators/PhoneValidator.java @@ -33,6 +33,9 @@ public class PhoneValidator implements ConstraintValidator { */ @Override public boolean isValid(String value, ConstraintValidatorContext context) { + if (null == value) { + return true; + } if (type == PhoneType.ALL) { return isMobile(value) || isTel(value); } diff --git a/flyfish-web/src/main/java/com/flyfish/framework/configuration/WebfluxConfig.java b/flyfish-web/src/main/java/com/flyfish/framework/configuration/WebfluxConfig.java index 10b8a17..4452e8d 100644 --- a/flyfish-web/src/main/java/com/flyfish/framework/configuration/WebfluxConfig.java +++ b/flyfish-web/src/main/java/com/flyfish/framework/configuration/WebfluxConfig.java @@ -3,7 +3,10 @@ package com.flyfish.framework.configuration; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import com.flyfish.framework.configuration.resolver.PageQueryArgumentResolver; +import com.flyfish.framework.configuration.resolver.RequestContextBodyArgumentResolver; import com.flyfish.framework.configuration.resolver.UserArgumentResolver; +import com.flyfish.framework.configuration.resolver.ValidRequestBodyMethodArgumentResolver; +import com.flyfish.framework.context.SpringContext; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; @@ -17,7 +20,9 @@ import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; +import javax.annotation.Resource; import java.util.TimeZone; /** @@ -33,9 +38,6 @@ public class WebfluxConfig implements WebFluxConfigurer { @Value("${spring.jackson.date-format}") private String dateFormat = "yyyy-MM-dd HH:mm:ss"; -// @Resource -// private RequestMappingHandlerAdapter requestMappingHandlerAdapter; - /** * Configure resolvers for custom {@code @RequestMapping} method arguments. * @@ -44,10 +46,11 @@ public class WebfluxConfig implements WebFluxConfigurer { @Override public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { ReactiveAdapterRegistry registry = ReactiveAdapterRegistry.getSharedInstance(); + ServerCodecConfigurer serverCodecConfigurer = SpringContext.getBean(ServerCodecConfigurer.class); configurer.addCustomResolver(new PageQueryArgumentResolver(registry)); configurer.addCustomResolver(new UserArgumentResolver(registry)); -// configurer.addCustomResolver(new RequestContextBodyArgumentResolver( -// requestMappingHandlerAdapter.getMessageReaders(), registry)); + configurer.addCustomResolver(new ValidRequestBodyMethodArgumentResolver(serverCodecConfigurer.getReaders(), registry)); + configurer.addCustomResolver(new RequestContextBodyArgumentResolver(serverCodecConfigurer.getReaders(), registry)); } /** diff --git a/flyfish-web/src/main/java/com/flyfish/framework/configuration/annotations/ValidRequestBody.java b/flyfish-web/src/main/java/com/flyfish/framework/configuration/annotations/ValidRequestBody.java new file mode 100644 index 0000000..bd66b1d --- /dev/null +++ b/flyfish-web/src/main/java/com/flyfish/framework/configuration/annotations/ValidRequestBody.java @@ -0,0 +1,16 @@ +package com.flyfish.framework.configuration.annotations; + +import java.lang.annotation.*; + +/** + * 附带校验的请求体 + * 天然支持条件校验 + * @author wangyu + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ValidRequestBody { + + boolean required() default true; +} diff --git a/flyfish-web/src/main/java/com/flyfish/framework/configuration/resolver/RequestContextBodyArgumentResolver.java b/flyfish-web/src/main/java/com/flyfish/framework/configuration/resolver/RequestContextBodyArgumentResolver.java index 817b141..8a2389f 100644 --- a/flyfish-web/src/main/java/com/flyfish/framework/configuration/resolver/RequestContextBodyArgumentResolver.java +++ b/flyfish-web/src/main/java/com/flyfish/framework/configuration/resolver/RequestContextBodyArgumentResolver.java @@ -17,7 +17,7 @@ import reactor.core.publisher.Mono; import java.util.List; /** - * 附带上下文你的请求参数解析器 + * 附带上下文里的请求参数解析器 * * @author wangyu */ @@ -25,7 +25,6 @@ public class RequestContextBodyArgumentResolver extends AbstractMessageReaderArg public RequestContextBodyArgumentResolver(List> readers, ReactiveAdapterRegistry registry) { - super(readers, registry); } @@ -38,7 +37,6 @@ public class RequestContextBodyArgumentResolver extends AbstractMessageReaderArg @Override public Mono resolveArgument( MethodParameter param, BindingContext bindingContext, ServerWebExchange exchange) { - RequestBody ann = param.getParameterAnnotation(RequestBody.class); Assert.state(ann != null, "No RequestBody annotation"); return readBody(param, ann.required(), bindingContext, exchange) diff --git a/flyfish-web/src/main/java/com/flyfish/framework/configuration/resolver/ValidRequestBodyMethodArgumentResolver.java b/flyfish-web/src/main/java/com/flyfish/framework/configuration/resolver/ValidRequestBodyMethodArgumentResolver.java new file mode 100644 index 0000000..3f0092c --- /dev/null +++ b/flyfish-web/src/main/java/com/flyfish/framework/configuration/resolver/ValidRequestBodyMethodArgumentResolver.java @@ -0,0 +1,191 @@ +package com.flyfish.framework.configuration.resolver; + +import com.flyfish.framework.configuration.annotations.ValidRequestBody; +import com.flyfish.framework.validation.spi.ConditionalGroup; +import com.flyfish.framework.validation.spi.ConditionalValidGroupProvider; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ArrayUtils; +import org.springframework.core.*; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.codec.Hints; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.data.util.CastUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.bind.support.WebExchangeDataBinder; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.result.method.annotation.AbstractMessageReaderArgumentResolver; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import javax.validation.groups.Default; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 私有校验逻辑支持的body + * + * @author wangyu + * 支持条件校验,更高级的骚操作,大量重写 + */ +public class ValidRequestBodyMethodArgumentResolver extends AbstractMessageReaderArgumentResolver { + + private static final Set SUPPORTED_METHODS = + EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); + + private final List supportedMediaTypes; + + public ValidRequestBodyMethodArgumentResolver(List> readers, ReactiveAdapterRegistry registry) { + super(readers, registry); + supportedMediaTypes = readers.stream() + .flatMap(converter -> converter.getReadableMediaTypes().stream()) + .collect(Collectors.toList()); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ValidRequestBody.class); + } + + @Override + public Mono resolveArgument( + MethodParameter param, BindingContext bindingContext, ServerWebExchange exchange) { + + ValidRequestBody ann = param.getParameterAnnotation(ValidRequestBody.class); + Assert.state(ann != null, "No RequestBody annotation"); + return readBody(param, ann.required(), bindingContext, exchange); + } + + @Override + protected Mono readBody(MethodParameter bodyParam, @Nullable MethodParameter actualParam, + boolean isBodyRequired, BindingContext bindingContext, ServerWebExchange exchange) { + + ResolvableType bodyType = ResolvableType.forMethodParameter(bodyParam); + ResolvableType actualType = (actualParam != null ? ResolvableType.forMethodParameter(actualParam) : bodyType); + Class resolvedType = bodyType.resolve(); + ReactiveAdapter adapter = (resolvedType != null ? getAdapterRegistry().getAdapter(resolvedType) : null); + ResolvableType elementType = (adapter != null ? bodyType.getGeneric() : bodyType); + isBodyRequired = isBodyRequired || (adapter != null && !adapter.supportsEmpty()); + + ServerHttpRequest request = exchange.getRequest(); + ServerHttpResponse response = exchange.getResponse(); + + MediaType contentType = request.getHeaders().getContentType(); + MediaType mediaType = (contentType != null ? contentType : MediaType.APPLICATION_OCTET_STREAM); + + if (mediaType.isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED)) { + return Mono.error(new IllegalStateException( + "In a WebFlux application, form data is accessed via ServerWebExchange.getFormData().")); + } + + if (logger.isDebugEnabled()) { + logger.debug(exchange.getLogPrefix() + (contentType != null ? + "Content-Type:" + contentType : + "No Content-Type, using " + MediaType.APPLICATION_OCTET_STREAM)); + } + + for (HttpMessageReader reader : getMessageReaders()) { + if (reader.canRead(elementType, mediaType)) { + Map readHints = Hints.from(Hints.LOG_PREFIX_HINT, exchange.getLogPrefix()); + if (adapter != null && adapter.isMultiValue()) { + if (logger.isDebugEnabled()) { + logger.debug(exchange.getLogPrefix() + "0..N [" + elementType + "]"); + } + Flux flux = reader.read(actualType, elementType, request, response, readHints); + flux = flux.onErrorResume(ex -> Flux.error(handleReadError(bodyParam, ex))); + if (isBodyRequired) { + flux = flux.switchIfEmpty(Flux.error(() -> handleMissingBody(bodyParam))); + } + flux = flux.doOnNext(target -> validate(target, bodyParam, bindingContext, exchange)); + return Mono.just(adapter.fromPublisher(flux)); + } else { + // Single-value (with or without reactive type wrapper) + if (logger.isDebugEnabled()) { + logger.debug(exchange.getLogPrefix() + "0..1 [" + elementType + "]"); + } + Mono mono = reader.readMono(actualType, elementType, request, response, readHints); + mono = mono.onErrorResume(ex -> Mono.error(handleReadError(bodyParam, ex))); + if (isBodyRequired) { + mono = mono.switchIfEmpty(Mono.error(() -> handleMissingBody(bodyParam))); + } + mono = mono.doOnNext(target -> validate(target, bodyParam, bindingContext, exchange)); + return (adapter != null ? Mono.just(adapter.fromPublisher(mono)) : Mono.from(mono)); + } + } + } + + // No compatible reader but body may be empty.. + + HttpMethod method = request.getMethod(); + if (contentType == null && method != null && SUPPORTED_METHODS.contains(method)) { + Flux body = request.getBody().doOnNext(buffer -> { + DataBufferUtils.release(buffer); + // Body not empty, back to 415.. + throw new UnsupportedMediaTypeStatusException(mediaType, this.supportedMediaTypes, elementType); + }); + if (isBodyRequired) { + body = body.switchIfEmpty(Mono.error(() -> handleMissingBody(bodyParam))); + } + return (adapter != null ? Mono.just(adapter.fromPublisher(body)) : Mono.from(body)); + } + + return Mono.error(new UnsupportedMediaTypeStatusException(mediaType, this.supportedMediaTypes, elementType)); + } + + /** + * 核心,从对象抽取出验证的group + * + * @param target 目标对象 + * @return 结果 + */ + @NonNull + private Object[] extractValidationHints(Object target) { + ConditionalGroup group = AnnotationUtils.findAnnotation(target.getClass(), ConditionalGroup.class); + if (null != group && ArrayUtils.isNotEmpty(group.value())) { + List> groups = new ArrayList<>(); + for (Class> clazz : group.value()) { + try { + groups.addAll(Arrays.asList(clazz.newInstance().provide(CastUtils.cast(target)))); + } catch (InstantiationException | IllegalAccessException ignored) { + } + } + if (CollectionUtils.isNotEmpty(groups)) { + return groups.toArray(); + } + } + return new Object[]{}; + } + + private void validate(Object target, MethodParameter param, + BindingContext binding, ServerWebExchange exchange) { + Object[] validationHints = extractValidationHints(target); + String name = Conventions.getVariableNameForParameter(param); + WebExchangeDataBinder binder = binding.createDataBinder(exchange, target, name); + binder.validate(validationHints); + if (binder.getBindingResult().hasErrors()) { + throw new WebExchangeBindException(param, binder.getBindingResult()); + } + } + + private Throwable handleReadError(MethodParameter parameter, Throwable ex) { + return (ex instanceof DecodingException ? + new ServerWebInputException("Failed to read HTTP message", parameter, ex) : ex); + } + + private ServerWebInputException handleMissingBody(MethodParameter parameter) { + String paramInfo = parameter.getExecutable().toGenericString(); + return new ServerWebInputException("Request body is missing: " + paramInfo, parameter); + } +} diff --git a/flyfish-web/src/main/java/com/flyfish/framework/controller/BaseController.java b/flyfish-web/src/main/java/com/flyfish/framework/controller/BaseController.java index 3315301..c4e27f1 100644 --- a/flyfish-web/src/main/java/com/flyfish/framework/controller/BaseController.java +++ b/flyfish-web/src/main/java/com/flyfish/framework/controller/BaseController.java @@ -5,6 +5,7 @@ import com.flyfish.framework.bean.Result; import com.flyfish.framework.bean.SyncVo; import com.flyfish.framework.configuration.annotations.CurrentUser; import com.flyfish.framework.configuration.annotations.PagedQuery; +import com.flyfish.framework.configuration.annotations.ValidRequestBody; import com.flyfish.framework.constant.ReactiveConstants; import com.flyfish.framework.context.UserContext; import com.flyfish.framework.domain.base.Domain; @@ -16,7 +17,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Mono; -import javax.validation.Valid; import java.util.List; /** @@ -51,7 +51,7 @@ public abstract class BaseController> implemen @PostMapping("") @Operation.Create - public Result create(@RequestBody @Valid T entity, @CurrentUser User user) { + public Result create(@ValidRequestBody T entity, @CurrentUser User user) { userContext.setUser(user); return Result.accept(service.create(entity)); } @@ -63,7 +63,7 @@ public abstract class BaseController> implemen @PutMapping("{id}") @Operation.Update - public Result update(@RequestBody @Valid T entity, @CurrentUser User user) { + public Result update(@ValidRequestBody T entity, @CurrentUser User user) { userContext.setUser(user); return Result.accept(service.updateSelectiveById(entity)); } @@ -110,7 +110,7 @@ public abstract class BaseController> implemen * reactive接口 */ @PostMapping(value = "", headers = ReactiveConstants.USE_REACTIVE) - public Mono> reactiveCreate(@RequestBody @Valid T entity) { + public Mono> reactiveCreate(@ValidRequestBody T entity) { return reactiveService.create(entity).map(Result::accept); } @@ -122,7 +122,7 @@ public abstract class BaseController> implemen } @PutMapping(value = "{id}", headers = ReactiveConstants.USE_REACTIVE) - public Mono> reactiveUpdate(@RequestBody @Valid T entity) { + public Mono> reactiveUpdate(@ValidRequestBody T entity) { return reactiveService.updateById(entity) .map(Result::accept) .defaultIfEmpty(Result.notFound());