diff --git a/client/src/main/java/me/danwi/kato/client/ImportKatoClients.java b/client/src/main/java/me/danwi/kato/client/ImportKatoClients.java index 6118db2..55bd2f8 100644 --- a/client/src/main/java/me/danwi/kato/client/ImportKatoClients.java +++ b/client/src/main/java/me/danwi/kato/client/ImportKatoClients.java @@ -17,4 +17,14 @@ @AliasFor(annotation = EnableFeignClients.class) Class[] clients() default {}; + + @AliasFor(annotation = EnableFeignClients.class) + String[] basePackages() default {}; + + @AliasFor(annotation = EnableFeignClients.class) + Class[] basePackageClasses() default {}; + + @AliasFor(annotation = EnableFeignClients.class) + Class[] defaultConfiguration() default {}; + } diff --git a/client/src/main/java/me/danwi/kato/client/KatoClient.java b/client/src/main/java/me/danwi/kato/client/KatoClient.java index 75c27e2..61fe0fa 100644 --- a/client/src/main/java/me/danwi/kato/client/KatoClient.java +++ b/client/src/main/java/me/danwi/kato/client/KatoClient.java @@ -13,8 +13,32 @@ @FeignClient(configuration = KatoClientConfig.class) public @interface KatoClient { @AliasFor(annotation = FeignClient.class) - String value(); + String value() default ""; @AliasFor(annotation = FeignClient.class) - String url(); + String contextId() default ""; + + @AliasFor(annotation = FeignClient.class) + String name() default ""; + + @AliasFor(annotation = FeignClient.class) + String[] qualifiers() default {}; + + @AliasFor(annotation = FeignClient.class) + String url() default ""; + + @AliasFor(annotation = FeignClient.class) + boolean decode404() default false; + + @AliasFor(annotation = FeignClient.class) + Class fallback() default void.class; + + @AliasFor(annotation = FeignClient.class) + Class fallbackFactory() default void.class; + + @AliasFor(annotation = FeignClient.class) + String path() default ""; + + @AliasFor(annotation = FeignClient.class) + boolean primary() default true; } diff --git a/client/src/main/java/me/danwi/kato/client/KatoClientConfig.java b/client/src/main/java/me/danwi/kato/client/KatoClientConfig.java index 2983759..25f2224 100644 --- a/client/src/main/java/me/danwi/kato/client/KatoClientConfig.java +++ b/client/src/main/java/me/danwi/kato/client/KatoClientConfig.java @@ -1,19 +1,80 @@ package me.danwi.kato.client; -import feign.codec.Decoder; +import feign.Contract; +import feign.codec.Encoder; import feign.codec.ErrorDecoder; +import feign.form.MultipartFormContentProcessor; +import feign.form.spring.SpringFormEncoder; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.AnnotatedParameterProcessor; +import org.springframework.cloud.openfeign.FeignClientProperties; +import org.springframework.cloud.openfeign.support.AbstractFormWriter; +import org.springframework.cloud.openfeign.support.FeignEncoderProperties; +import org.springframework.cloud.openfeign.support.HttpMessageConverterCustomizer; +import org.springframework.cloud.openfeign.support.SpringEncoder; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConversionService; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static feign.form.ContentType.MULTIPART; -@Configuration("me.danwi.kato.client.Config") public class KatoClientConfig { - @Bean("me.danwi.kato.client.ResultDecoder") - Decoder resultDecoder() { - return new KatoResultDecoder(); - } + + @Autowired + private ObjectFactory messageConverters; + @Autowired(required = false) + private FeignEncoderProperties encoderProperties; @Bean("me.danwi.kato.client.ErrorDecoder") ErrorDecoder errorDecoder() { return new KatoErrorDecoder(); } + + @Bean + public Encoder katoEncoder( + ObjectProvider formWriterProvider, + ObjectProvider customizer + ) { + return new KatoEncoder(springEncoder(formWriterProvider, encoderProperties, customizer)); + } + + @Bean + public Contract katoContract(ObjectProvider> parameterProcessors, ObjectProvider feignClientProperties, ConversionService feignConversionService) { + AtomicBoolean decodeSlash = new AtomicBoolean(true); + feignClientProperties.ifAvailable(fc -> decodeSlash.set(fc.isDecodeSlash())); + + List processors = new ArrayList<>(); + parameterProcessors.ifAvailable(processors::addAll); + + return new KatoContract(processors, feignConversionService, decodeSlash.get()); + } + + private Encoder springEncoder(ObjectProvider formWriterProvider, + FeignEncoderProperties encoderProperties, ObjectProvider customizers) { + AbstractFormWriter formWriter = formWriterProvider.getIfAvailable(); + + if (formWriter != null) { + return new SpringEncoder(new SpringPojoFormEncoder(formWriter), messageConverters, encoderProperties, + customizers); + } else { + return new SpringEncoder(new SpringFormEncoder(), messageConverters, encoderProperties, customizers); + } + } + + private static class SpringPojoFormEncoder extends SpringFormEncoder { + + SpringPojoFormEncoder(AbstractFormWriter formWriter) { + super(); + + MultipartFormContentProcessor processor = (MultipartFormContentProcessor) getContentProcessor(MULTIPART); + processor.addFirstWriter(formWriter); + } + + } } diff --git a/client/src/main/java/me/danwi/kato/client/KatoContract.java b/client/src/main/java/me/danwi/kato/client/KatoContract.java new file mode 100644 index 0000000..c8a2c75 --- /dev/null +++ b/client/src/main/java/me/danwi/kato/client/KatoContract.java @@ -0,0 +1,374 @@ +package me.danwi.kato.client; + +import feign.AlwaysEncodeBodyContract; +import feign.MethodMetadata; +import feign.Param; +import feign.Request; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.cloud.openfeign.AnnotatedParameterProcessor; +import org.springframework.cloud.openfeign.CollectionFormat; +import org.springframework.cloud.openfeign.annotation.*; +import org.springframework.cloud.openfeign.encoding.HttpEncoding; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.ResourceLoader; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.*; + +import static feign.Util.checkState; +import static feign.Util.emptyToNull; +import static java.util.Optional.ofNullable; +import static org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation; + +/** + * 参考 SpringMvcContract 实现 + */ +public class KatoContract extends AlwaysEncodeBodyContract implements ResourceLoaderAware { + + private static final Log LOG = LogFactory.getLog(KatoContract.class); + + private static final String ACCEPT = "Accept"; + + private static final String CONTENT_TYPE = "Content-Type"; + + private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class); + + private static final TypeDescriptor ITERABLE_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Iterable.class); + + private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); + + private final ConversionService conversionService; + + private final ConvertingExpanderFactory convertingExpanderFactory; + + private ResourceLoader resourceLoader = new DefaultResourceLoader(); + + private final boolean decodeSlash; + + + public KatoContract() { + this(Collections.emptyList()); + } + + public KatoContract(List annotatedParameterProcessors) { + this(annotatedParameterProcessors, new DefaultConversionService()); + } + + public KatoContract(List annotatedParameterProcessors, + ConversionService conversionService) { + this(annotatedParameterProcessors, conversionService, true); + } + + public KatoContract(List annotatedParameterProcessors, + ConversionService conversionService, boolean decodeSlash) { + Assert.notNull(annotatedParameterProcessors, "Parameter processors can not be null."); + Assert.notNull(conversionService, "ConversionService can not be null."); + + this.conversionService = conversionService; + convertingExpanderFactory = new KatoContract.ConvertingExpanderFactory(conversionService); + this.decodeSlash = decodeSlash; + + init(annotatedParameterProcessors); + } + + + public void init(List annotatedParameterProcessors) { + // 注册类注解处理器 + registerClassAnnotationProcessor(); + // 注册方法注解处理器 + registerMethodAnnotationProcessor(); + // 注册参数注解处理 + registerParameterAnnotationProcessor(annotatedParameterProcessors); + } + + private void registerClassAnnotationProcessor() { + registerClassAnnotation( + RequestMapping.class, + (annotation, metadata) -> { + LOG.error("Cannot process class: " + metadata.targetType().getName() + + ". @RequestMapping annotation is not allowed on @FeignClient interfaces."); + throw new IllegalArgumentException("@RequestMapping annotation not allowed on @FeignClient interfaces"); + } + ); + registerClassAnnotation( + CollectionFormat.class, + (annotation, metadata) -> metadata.template().collectionFormat(annotation.value()) + ); + } + + private void registerMethodAnnotationProcessor() { + registerMethodAnnotation( + CollectionFormat.class, + (annotation, metadata) -> metadata.template().collectionFormat(annotation.value()) + ); + registerMethodAnnotation( + (annotation) -> annotation instanceof RequestMapping || annotation.annotationType().isAnnotationPresent(RequestMapping.class), + (annotation, data) -> { + Method method = data.method(); + RequestMapping methodMapping = findMergedAnnotation(method, RequestMapping.class); + // HTTP Method + RequestMethod[] methods = methodMapping.method(); + if (methods.length == 0) { + methods = new RequestMethod[]{RequestMethod.GET}; + } + checkOne(method, methods, "method"); + data.template().method(Request.HttpMethod.valueOf(methods[0].name())); + + // path + checkAtMostOne(method, methodMapping.value(), "value"); + if (methodMapping.value().length > 0) { + String pathValue = emptyToNull(methodMapping.value()[0]); + if (pathValue != null) { + pathValue = resolve(pathValue); + // Append path from @RequestMapping if value is present on method + if (!pathValue.startsWith("/") && !data.template().path().endsWith("/")) { + pathValue = "/" + pathValue; + } + data.template().uri(pathValue, true); + if (data.template().decodeSlash() != this.decodeSlash) { + data.template().decodeSlash(this.decodeSlash); + } + } + } + + // produces + parseProduces(data, method, methodMapping); + + // consumes + parseConsumes(data, method, methodMapping); + + // headers + parseHeaders(data, method, methodMapping); + + data.indexToExpander(new LinkedHashMap<>()); + } + ); + } + + private static TypeDescriptor createTypeDescriptor(Method method, int paramIndex) { + Parameter parameter = method.getParameters()[paramIndex]; + MethodParameter methodParameter = MethodParameter.forParameter(parameter); + TypeDescriptor typeDescriptor = new TypeDescriptor(methodParameter); + + // Feign applies the Param.Expander to each element of an Iterable, so in those + // cases we need to provide a TypeDescriptor of the element. + if (typeDescriptor.isAssignableTo(ITERABLE_TYPE_DESCRIPTOR)) { + TypeDescriptor elementTypeDescriptor = getElementTypeDescriptor(typeDescriptor); + + checkState(elementTypeDescriptor != null, + "Could not resolve element type of Iterable type %s. Not declared?", typeDescriptor); + + typeDescriptor = elementTypeDescriptor; + } + return typeDescriptor; + } + + private static TypeDescriptor getElementTypeDescriptor(TypeDescriptor typeDescriptor) { + TypeDescriptor elementTypeDescriptor = typeDescriptor.getElementTypeDescriptor(); + // that means it's not a collection but it is iterable, gh-135 + if (elementTypeDescriptor == null && Iterable.class.isAssignableFrom(typeDescriptor.getType())) { + ResolvableType type = typeDescriptor.getResolvableType().as(Iterable.class).getGeneric(0); + if (type.resolve() == null) { + return null; + } + return new TypeDescriptor(type, null, typeDescriptor.getAnnotations()); + } + return elementTypeDescriptor; + } + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + @Override + public MethodMetadata parseAndValidateMetadata(Class targetType, Method method) { + return super.parseAndValidateMetadata(targetType, method); + } + + private String resolve(String value) { + if (StringUtils.hasText(value) && resourceLoader instanceof ConfigurableApplicationContext) { + return ((ConfigurableApplicationContext) resourceLoader).getEnvironment().resolvePlaceholders(value); + } + return value; + } + + private void checkAtMostOne(Method method, Object[] values, String fieldName) { + checkState(values != null && (values.length == 0 || values.length == 1), + "Method %s can only contain at most 1 %s field. Found: %s", method.getName(), fieldName, + values == null ? null : Arrays.asList(values)); + } + + private void checkOne(Method method, Object[] values, String fieldName) { + checkState(values != null && values.length == 1, "Method %s can only contain 1 %s field. Found: %s", + method.getName(), fieldName, values == null ? null : Arrays.asList(values)); + } + + private void parseProduces(MethodMetadata md, Method method, RequestMapping annotation) { + String[] serverProduces = annotation.produces(); + String clientAccepts = serverProduces.length == 0 ? null : emptyToNull(serverProduces[0]); + if (clientAccepts != null) { + md.template().header(ACCEPT, clientAccepts); + } + } + + private void parseConsumes(MethodMetadata md, Method method, RequestMapping annotation) { + String[] serverConsumes = annotation.consumes(); + String clientProduces = serverConsumes.length == 0 ? null : emptyToNull(serverConsumes[0]); + if (clientProduces != null) { + md.template().header(CONTENT_TYPE, clientProduces); + } + } + + private void parseHeaders(MethodMetadata md, Method method, RequestMapping annotation) { + // TODO: only supports one header value per key + if (annotation.headers() != null && annotation.headers().length > 0) { + for (String header : annotation.headers()) { + int index = header.indexOf('='); + if (!header.contains("!=") && index >= 0) { + md.template().header(resolve(header.substring(0, index)), + resolve(header.substring(index + 1).trim())); + } + } + } + } + + + private void registerParameterAnnotationProcessor(List annotatedArgumentResolvers) { + + annotatedArgumentResolvers.add(new MatrixVariableParameterProcessor()); + annotatedArgumentResolvers.add(new PathVariableParameterProcessor()); + annotatedArgumentResolvers.add(new RequestParamParameterProcessor()); + annotatedArgumentResolvers.add(new RequestHeaderParameterProcessor()); + annotatedArgumentResolvers.add(new QueryMapParameterProcessor()); + annotatedArgumentResolvers.add(new RequestPartParameterProcessor()); + annotatedArgumentResolvers.add(new CookieValueParameterProcessor()); + + annotatedArgumentResolvers.forEach(annotatedParameterProcessor -> registerParameterAnnotation( + annotatedParameterProcessor.getAnnotationType(), + (ParameterAnnotationProcessor) (annotation, metadata, paramIndex) -> { + // synthesize, handling @AliasFor, while falling back to parameter name on + // missing String #value(): + Annotation processParameterAnnotation = synthesizeWithMethodParameterNameAsFallbackValue(annotation, metadata.method(), paramIndex); + boolean isHttpAnnotation = annotatedParameterProcessor.processArgument(new SimpleAnnotatedParameterContext(metadata, paramIndex), processParameterAnnotation, metadata.method()); + if (!isMultipartFormData(metadata) && isHttpAnnotation && metadata.indexToExpander().get(paramIndex) == null) { + TypeDescriptor typeDescriptor = createTypeDescriptor(metadata.method(), paramIndex); + if (conversionService.canConvert(typeDescriptor, STRING_TYPE_DESCRIPTOR)) { + Param.Expander expander = convertingExpanderFactory.getExpander(typeDescriptor); + metadata.indexToExpander().put(paramIndex, expander); + } + } + } + )); + } + + + private Annotation synthesizeWithMethodParameterNameAsFallbackValue(Annotation parameterAnnotation, Method + method, + int parameterIndex) { + Map annotationAttributes = AnnotationUtils.getAnnotationAttributes(parameterAnnotation); + Object defaultValue = AnnotationUtils.getDefaultValue(parameterAnnotation); + if (defaultValue instanceof String && defaultValue.equals(annotationAttributes.get(AnnotationUtils.VALUE))) { + Type[] parameterTypes = method.getGenericParameterTypes(); + String[] parameterNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method); + if (shouldAddParameterName(parameterIndex, parameterTypes, parameterNames)) { + annotationAttributes.put(AnnotationUtils.VALUE, parameterNames[parameterIndex]); + } + } + return AnnotationUtils.synthesizeAnnotation(annotationAttributes, parameterAnnotation.annotationType(), null); + } + + private boolean shouldAddParameterName(int parameterIndex, Type[] parameterTypes, String[] parameterNames) { + // has a parameter name + return parameterNames != null && parameterNames.length > parameterIndex + // has a type + && parameterTypes != null && parameterTypes.length > parameterIndex; + } + + private boolean isMultipartFormData(MethodMetadata data) { + Collection contentTypes = data.template().headers().get(HttpEncoding.CONTENT_TYPE); + + if (contentTypes != null && !contentTypes.isEmpty()) { + String type = contentTypes.iterator().next(); + try { + return Objects.equals(MediaType.valueOf(type), MediaType.MULTIPART_FORM_DATA); + } catch (InvalidMediaTypeException ignored) { + return false; + } + } + + return false; + } + + private static class ConvertingExpanderFactory { + + private final ConversionService conversionService; + + ConvertingExpanderFactory(ConversionService conversionService) { + this.conversionService = conversionService; + } + + Param.Expander getExpander(TypeDescriptor typeDescriptor) { + return value -> { + Object converted = conversionService.convert(value, typeDescriptor, STRING_TYPE_DESCRIPTOR); + return (String) converted; + }; + } + + } + + private class SimpleAnnotatedParameterContext implements AnnotatedParameterProcessor.AnnotatedParameterContext { + + private final MethodMetadata methodMetadata; + + private final int parameterIndex; + + SimpleAnnotatedParameterContext(MethodMetadata methodMetadata, int parameterIndex) { + this.methodMetadata = methodMetadata; + this.parameterIndex = parameterIndex; + } + + @Override + public MethodMetadata getMethodMetadata() { + return methodMetadata; + } + + @Override + public int getParameterIndex() { + return parameterIndex; + } + + @Override + public void setParameterName(String name) { + nameParam(methodMetadata, name, parameterIndex); + } + + @Override + public Collection setTemplateParameter(String name, Collection rest) { + Collection params = ofNullable(rest).map(ArrayList::new).orElse(new ArrayList<>()); + params.add(String.format("{%s}", name)); + return params; + } + + } +} diff --git a/client/src/main/java/me/danwi/kato/client/KatoEncoder.java b/client/src/main/java/me/danwi/kato/client/KatoEncoder.java new file mode 100644 index 0000000..1893118 --- /dev/null +++ b/client/src/main/java/me/danwi/kato/client/KatoEncoder.java @@ -0,0 +1,55 @@ +package me.danwi.kato.client; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import me.danwi.kato.common.argument.MultiRequestBody; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; + +public class KatoEncoder implements Encoder { + + private final Encoder delegate; + + public KatoEncoder(Encoder delegate) { + this.delegate = delegate; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { + if (object.getClass() != Object[].class) { + this.delegate.encode(object, bodyType, template); + } else { + Object[] objects = (Object[]) object; + if (objects.length == 0) { + this.delegate.encode(new HashMap(), Map.class, template); + } else if (objects.length == 1) { + this.delegate.encode(objects[0], objects[0].getClass(), template); + } else { + Map map = converToMap(objects, template.methodMetadata().method()); + this.delegate.encode(map, Map.class, template); + } + } + } + + private Map converToMap(Object[] objects, Method method) { + HashMap map = new HashMap<>(); + Parameter[] parameters = method.getParameters(); + for (int i = 0; i < parameters.length; i++) { + map.put(getKey(parameters[i]), objects[i]); + } + return map; + } + + private String getKey(Parameter parameters) { + MultiRequestBody annotation = parameters.getAnnotation(MultiRequestBody.class); + if (annotation != null && annotation.value() != null && !annotation.value().equals("")) { + return annotation.value(); + } + return parameters.getName(); + } +} diff --git a/client/src/main/java/me/danwi/kato/client/KatoResultDecoder.java b/client/src/main/java/me/danwi/kato/client/KatoResultDecoder.java deleted file mode 100644 index 053873c..0000000 --- a/client/src/main/java/me/danwi/kato/client/KatoResultDecoder.java +++ /dev/null @@ -1,25 +0,0 @@ -package me.danwi.kato.client; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.type.TypeFactory; -import feign.FeignException; -import feign.Response; -import feign.Util; -import feign.codec.Decoder; - -import java.io.IOException; -import java.lang.reflect.Type; - -public class KatoResultDecoder implements Decoder { - private final ObjectMapper mapper = new ObjectMapper(); - - @Override - public Object decode(Response response, Type type) throws IOException, FeignException { - //空body - if (response.body() == null) - return null; - //反序列化结果 - String bodyStr = Util.toString(response.body().asReader(Util.UTF_8)); - return mapper.readValue(bodyStr, TypeFactory.defaultInstance().constructType(type)); - } -} \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 87a7534..63bb261 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -7,6 +7,10 @@ plugins { dependencies { implementation("com.fasterxml.jackson.core:jackson-databind:2.13.3") + compileOnly("org.springframework.boot:spring-boot-autoconfigure:2.6.7") + compileOnly("org.springframework:spring-context:5.3.19") + compileOnly("org.springframework:spring-webmvc:5.3.19") + compileOnly("org.apache.tomcat.embed:tomcat-embed-core:9.0.62") compileOnly("org.projectlombok:lombok:1.18.24") annotationProcessor("org.projectlombok:lombok:1.18.24") } \ No newline at end of file diff --git a/common/src/main/java/me/danwi/kato/common/ExceptionResult.java b/common/src/main/java/me/danwi/kato/common/ExceptionResult.java index 99b79bc..407a20c 100644 --- a/common/src/main/java/me/danwi/kato/common/ExceptionResult.java +++ b/common/src/main/java/me/danwi/kato/common/ExceptionResult.java @@ -33,4 +33,14 @@ public Map getData() { public void setData(Map data) { this.data = data; } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ExceptionResult{"); + sb.append("id='").append(id).append('\''); + sb.append(", message='").append(message).append('\''); + sb.append(", data=").append(data); + sb.append('}'); + return sb.toString(); + } } diff --git a/common/src/main/java/me/danwi/kato/common/argument/MultiRequestBody.java b/common/src/main/java/me/danwi/kato/common/argument/MultiRequestBody.java new file mode 100644 index 0000000..d15695a --- /dev/null +++ b/common/src/main/java/me/danwi/kato/common/argument/MultiRequestBody.java @@ -0,0 +1,32 @@ +package me.danwi.kato.common.argument; + +import java.lang.annotation.*; + +/** + * 参数解析 源数据 + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MultiRequestBody { + + /** + * 设置解析key + * + * @return 解析key + */ + String value() default ""; + + /** + * @return 是否必填参数 + */ + boolean required() default true; + + /** + * 当body不是JSONObject或者不能匹配到key时 + * + * @return 是否将整个body作为参数解析 + */ + boolean parseBodyIfMissKey() default true; + +} diff --git a/common/src/main/java/me/danwi/kato/common/exception/KatoUndeclaredException.java b/common/src/main/java/me/danwi/kato/common/exception/KatoUndeclaredException.java index ea9e8e4..c1cd5a1 100644 --- a/common/src/main/java/me/danwi/kato/common/exception/KatoUndeclaredException.java +++ b/common/src/main/java/me/danwi/kato/common/exception/KatoUndeclaredException.java @@ -4,6 +4,14 @@ * 未被业务代码处理的异常,通常出现这种异常需要引起开发人员的注意 */ public class KatoUndeclaredException extends KatoException { + public KatoUndeclaredException(String message) { + super(message); + } + + public KatoUndeclaredException(String message, Throwable cause) { + super(message, cause); + } + public KatoUndeclaredException(Throwable cause) { super(cause); } diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 01f87a9..d0d4429 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -12,14 +12,21 @@ configurations { } dependencies { + implementation(project(":client")) implementation(project(":server")) + + implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign:3.1.2") + developmentOnly("org.springframework.boot:spring-boot-devtools") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + testImplementation("org.springframework.boot:spring-boot-starter-test") } diff --git a/example/src/main/java/me/danwi/kato/example/argument/JavaMultiRequestBodyController.java b/example/src/main/java/me/danwi/kato/example/argument/JavaMultiRequestBodyController.java new file mode 100644 index 0000000..91e188f --- /dev/null +++ b/example/src/main/java/me/danwi/kato/example/argument/JavaMultiRequestBodyController.java @@ -0,0 +1,21 @@ +package me.danwi.kato.example.argument; + +import me.danwi.kato.common.argument.MultiRequestBody; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author wjy + */ +@RestController +public class JavaMultiRequestBodyController { + @PostMapping("/multiRequestKotlinNullableJava") + public TestEntity multiRequestKotlinNullableJava(@MultiRequestBody(required = false) Integer id) { + return new TestEntity(id, null); + } + + @PostMapping("/multiRequestKotlinNullableJavaErr") + public TestEntity multiRequestKotlinNullableJavaErr(@MultiRequestBody Integer id) { + return new TestEntity(id, null); + } +} diff --git a/example/src/main/kotlin/me/danwi/kato/example/ExampleApplication.kt b/example/src/main/kotlin/me/danwi/kato/example/ExampleApplication.kt index 6db6c5f..e6482ce 100644 --- a/example/src/main/kotlin/me/danwi/kato/example/ExampleApplication.kt +++ b/example/src/main/kotlin/me/danwi/kato/example/ExampleApplication.kt @@ -1,5 +1,6 @@ package me.danwi.kato.example +import me.danwi.kato.client.ImportKatoClients import me.danwi.kato.common.exception.ExceptionExtraDataHolder import me.danwi.kato.common.exception.KatoCommonException import me.danwi.kato.common.exception.KatoException @@ -7,9 +8,11 @@ import me.danwi.kato.server.EnableKatoServer import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @SpringBootApplication +@ImportKatoClients @EnableKatoServer class ExampleApplication @@ -26,6 +29,11 @@ class TestController { return TestData("kato") } + @RequestMapping("/param") + fun index(@RequestParam name: String): TestData { + return TestData(name) + } + @RequestMapping("/common-exception") fun commonException() { throw KatoCommonException("通用异常") diff --git a/example/src/main/kotlin/me/danwi/kato/example/argument/Entity.kt b/example/src/main/kotlin/me/danwi/kato/example/argument/Entity.kt new file mode 100644 index 0000000..ba15027 --- /dev/null +++ b/example/src/main/kotlin/me/danwi/kato/example/argument/Entity.kt @@ -0,0 +1,12 @@ +package me.danwi.kato.example.argument + +data class TestEntity(val id: Int?, val name: String?) +data class TestEntity2(val id: Int?, val unknown: String?) +data class TestEntity3(val id: Int?, val obj: TestEntity?) + +data class TestEntityAll( + val id: Int? = null, + val obj: TestEntity2? = null, + val unknown: String? = null, + val name: String? = null +) \ No newline at end of file diff --git a/example/src/main/kotlin/me/danwi/kato/example/argument/MultiRequestBodyController.kt b/example/src/main/kotlin/me/danwi/kato/example/argument/MultiRequestBodyController.kt new file mode 100644 index 0000000..d98afc7 --- /dev/null +++ b/example/src/main/kotlin/me/danwi/kato/example/argument/MultiRequestBodyController.kt @@ -0,0 +1,55 @@ +package me.danwi.kato.example.argument + +import me.danwi.kato.common.argument.MultiRequestBody +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RestController + +/** + * @author wjy + */ +@RestController +class MultiRequestBodyController { + + @PostMapping("/multiRequestKotlinNullable") + fun multiRequestKotlinNullable(@MultiRequestBody id: Int?, name: String): TestEntity { + return TestEntity(id, name) + } + + @PostMapping("/multiRequestKotlinNullableErr") + fun multiRequestKotlinNullableErr(@MultiRequestBody id: Int, name: String): TestEntity { + return TestEntity(id, name) + } + + @PostMapping("/multiRequest") + fun multiRequest(@MultiRequestBody id: Int, name: String): TestEntity { + return TestEntity(id, name) + } + + @PostMapping("/multiRequestSingle") + fun multiRequestSingle(@MultiRequestBody id: Int): TestEntity { + return TestEntity(id, null) + } + + @PostMapping("/multiRequestObj") + fun multiRequestObj(@MultiRequestBody obj: TestEntity): TestEntity { + return obj + } + + @PostMapping("/multiRequestObj2") + fun multiRequestObj2( + @MultiRequestBody obj: TestEntity, + @MultiRequestBody id: Int, + @MultiRequestBody obj2: TestEntity2 + ): TestEntity3 { + return TestEntity3(id, obj) + } + + @PostMapping("/multiRequestObj3") + fun multiRequestObj3( + @MultiRequestBody obj: TestEntity2, + @MultiRequestBody id: Int, + @MultiRequestBody obj2: TestEntity3 + ): TestEntity3 { + return obj2 + } +} diff --git a/example/src/main/kotlin/me/danwi/kato/example/argument/MultiRequestBodyWithOutAnnoController.kt b/example/src/main/kotlin/me/danwi/kato/example/argument/MultiRequestBodyWithOutAnnoController.kt new file mode 100644 index 0000000..21cf5b4 --- /dev/null +++ b/example/src/main/kotlin/me/danwi/kato/example/argument/MultiRequestBodyWithOutAnnoController.kt @@ -0,0 +1,63 @@ +package me.danwi.kato.example.argument + +import me.danwi.kato.server.PassByKato +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * @author wjy + */ +@RestController +@RequestMapping("/withOutAnno") +class MultiRequestBodyWithOutAnnoController { + + @PassByKato + @PostMapping("/multiRequestPassByMethod") + fun multiRequestPassByMethod(id: Int?, name: String?): TestEntity { + return TestEntity(id, name) + } + + @PostMapping("/multiRequestPassByParam") + fun multiRequestPassByParam(id: Int?, @PassByKato name: String?): TestEntity { + return TestEntity(id, name) + } + + @PostMapping("/multiRequest") + fun multiRequest(id: Int, name: String): TestEntity { + return TestEntity(id, name) + } + + @PostMapping("/multiRequest2") + fun multiRequest2(id: Int, name: String): TestEntity { + return TestEntity(id, name) + } + + @PostMapping("/multiRequestSingle") + fun multiRequestSingle(id: Int): TestEntity { + return TestEntity(id, null) + } + + @PostMapping("/multiRequestObj") + fun multiRequestObj(obj: TestEntity): TestEntity { + return obj + } + + @PostMapping("/multiRequestObj2") + fun multiRequestObj2( + obj: TestEntity, + id: Int, + obj2: TestEntity2 + ): TestEntity3 { + return TestEntity3(id, obj) + } + + @PostMapping("/multiRequestObj3") + fun multiRequestObj3( + obj: TestEntity2, + id: Int, + obj2: TestEntity3 + ): TestEntity3 { + return obj2 + } +} diff --git a/example/src/main/kotlin/me/danwi/kato/example/rpc/ExceptionRpcClient.kt b/example/src/main/kotlin/me/danwi/kato/example/rpc/ExceptionRpcClient.kt new file mode 100644 index 0000000..cf9eb37 --- /dev/null +++ b/example/src/main/kotlin/me/danwi/kato/example/rpc/ExceptionRpcClient.kt @@ -0,0 +1,16 @@ +package me.danwi.kato.example.rpc + +import me.danwi.kato.client.KatoClient +import me.danwi.kato.example.TestData +import org.springframework.web.bind.annotation.RequestMapping + +@KatoClient("test", url = "http://localhost:8888") +interface ExceptionRpcClient { + + @RequestMapping("/") + fun index(): TestData + + @RequestMapping("/common-exception") + fun commonException() + +} \ No newline at end of file diff --git a/example/src/main/kotlin/me/danwi/kato/example/rpc/NoUseKatoClient.kt b/example/src/main/kotlin/me/danwi/kato/example/rpc/NoUseKatoClient.kt new file mode 100644 index 0000000..db5e073 --- /dev/null +++ b/example/src/main/kotlin/me/danwi/kato/example/rpc/NoUseKatoClient.kt @@ -0,0 +1,16 @@ +package me.danwi.kato.example.rpc + +import me.danwi.kato.example.argument.TestEntity +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping + +@FeignClient(value = "testParamFeign", url = "http://localhost:8888") +interface NoUseKatoClient { + + @PostMapping("/withOutAnno/multiRequest2") + fun multiRequest(id: Int): TestEntity; + + @RequestMapping("/exception") + fun exception() +} \ No newline at end of file diff --git a/example/src/main/kotlin/me/danwi/kato/example/rpc/ParamRpcClient.kt b/example/src/main/kotlin/me/danwi/kato/example/rpc/ParamRpcClient.kt new file mode 100644 index 0000000..2acf7fb --- /dev/null +++ b/example/src/main/kotlin/me/danwi/kato/example/rpc/ParamRpcClient.kt @@ -0,0 +1,18 @@ +package me.danwi.kato.example.rpc + +import me.danwi.kato.client.KatoClient +import me.danwi.kato.example.TestData +import me.danwi.kato.example.argument.TestEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam + +@KatoClient(value = "testParam", url = "http://localhost:8888") +interface ParamRpcClient { + + @PostMapping("/withOutAnno/multiRequest") + fun multiRequest(id: Int, name: String): TestEntity + + @RequestMapping("/param") + fun index(@RequestParam name: String): TestData +} \ No newline at end of file diff --git a/example/src/test/java/me/danwi/kato/example/Config.java b/example/src/test/java/me/danwi/kato/example/Config.java new file mode 100644 index 0000000..d338893 --- /dev/null +++ b/example/src/test/java/me/danwi/kato/example/Config.java @@ -0,0 +1,14 @@ +package me.danwi.kato.example; + +import feign.Logger; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class Config { + + @Bean + public Logger.Level level() { + return Logger.Level.FULL; + } +} diff --git a/example/src/test/java/me/danwi/kato/example/KatoClientExceptionTest.java b/example/src/test/java/me/danwi/kato/example/KatoClientExceptionTest.java new file mode 100644 index 0000000..c54ffc8 --- /dev/null +++ b/example/src/test/java/me/danwi/kato/example/KatoClientExceptionTest.java @@ -0,0 +1,30 @@ +package me.danwi.kato.example; + +import me.danwi.kato.common.exception.KatoCommonException; +import me.danwi.kato.example.rpc.ExceptionRpcClient; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +public class KatoClientExceptionTest { + @Autowired + private ExceptionRpcClient exceptionRpcClient; + + @Test + public void test1() { + final TestData index = exceptionRpcClient.index(); + Assertions.assertEquals(new TestData("kato"), index); + } + + @Test + public void test2() { + try { + exceptionRpcClient.commonException(); + } catch (Exception e) { + Assertions.assertTrue(e instanceof KatoCommonException); + } + } + +} diff --git a/example/src/test/java/me/danwi/kato/example/KatoClientParamTest.java b/example/src/test/java/me/danwi/kato/example/KatoClientParamTest.java new file mode 100644 index 0000000..a3aa3b6 --- /dev/null +++ b/example/src/test/java/me/danwi/kato/example/KatoClientParamTest.java @@ -0,0 +1,63 @@ +package me.danwi.kato.example; + +import feign.FeignException; +import me.danwi.kato.common.exception.KatoCommonException; +import me.danwi.kato.example.argument.TestEntity; +import me.danwi.kato.example.rpc.ExceptionRpcClient; +import me.danwi.kato.example.rpc.NoUseKatoClient; +import me.danwi.kato.example.rpc.ParamRpcClient; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +public class KatoClientParamTest { + @Autowired + private ParamRpcClient paramRpcClient; + + @Autowired + private ExceptionRpcClient exceptionRpcClient; + + @Autowired + private NoUseKatoClient noUseKatoClient; + + @Test + public void test1() { + final TestEntity entity = new TestEntity(1, "name"); + final TestEntity entity2 = paramRpcClient.multiRequest(entity.getId(), entity.getName()); + Assertions.assertEquals(entity, entity2); + } + + @Test + public void test2() { + final TestEntity entity = new TestEntity(1, "name"); + final TestEntity entity2 = noUseKatoClient.multiRequest(entity.getId()); + Assertions.assertEquals(new TestEntity(entity.getId(), entity.getId().toString()), entity2); + } + + @Test + public void useKatoClientTest() { + try { + exceptionRpcClient.commonException(); + } catch (Exception e) { + Assertions.assertTrue(e instanceof KatoCommonException); + } + } + + @Test + public void noUseKatoClientTest() { + try { + noUseKatoClient.exception(); + } catch (Exception e) { + Assertions.assertTrue(e instanceof FeignException); + } + } + + @Test + public void requestParamTest() { + TestData test = new TestData("test"); + TestData index = paramRpcClient.index(test.getName()); + Assertions.assertEquals(test, index); + } +} diff --git a/example/src/test/java/me/danwi/kato/example/MultiRequestBodyControllerTest.java b/example/src/test/java/me/danwi/kato/example/MultiRequestBodyControllerTest.java new file mode 100644 index 0000000..cf5c2e0 --- /dev/null +++ b/example/src/test/java/me/danwi/kato/example/MultiRequestBodyControllerTest.java @@ -0,0 +1,102 @@ +package me.danwi.kato.example; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.danwi.kato.example.argument.TestEntity; +import me.danwi.kato.example.argument.TestEntity3; +import me.danwi.kato.example.argument.TestEntityAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.ResponseEntity; + +import java.util.Map; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author wjy + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class MultiRequestBodyControllerTest { + @Autowired + private TestRestTemplate restTemplate; + @Autowired + private ObjectMapper objectMapper; + + @Test + void multiRequestKotlinNullableJava() throws JsonProcessingException { + final TestEntity test = new TestEntity(null, "test"); + final ResponseEntity result = restTemplate.postForEntity("/multiRequestKotlinNullableJava", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(new TestEntity(null, null)).isEqualTo(result.getBody()); + } + + @Test + void multiRequestKotlinNullableJavaErr() throws JsonProcessingException { + final TestEntity test = new TestEntity(null, "test"); + final ResponseEntity result = restTemplate.postForEntity("/multiRequestKotlinNullableJavaErr", objectMapper.writeValueAsString(test), Map.class); + Assertions.assertTrue(result.getBody().get("message").toString().contains("缺少 id 参数")); + } + + @Test + void multiRequestKotlinNullableErr() throws JsonProcessingException { + final TestEntity test = new TestEntity(null, "test"); + final ResponseEntity result = restTemplate.postForEntity("/multiRequestKotlinNullableErr", objectMapper.writeValueAsString(test), Map.class); + Assertions.assertTrue(result.getBody().get("message").toString().contains("缺少 id 参数")); + } + + @Test + void multiRequestKotlinNullable() throws JsonProcessingException { + final TestEntity test = new TestEntity(null, "test"); + final ResponseEntity result = restTemplate.postForEntity("/multiRequestKotlinNullable", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(test).isEqualTo(result.getBody()); + } + + @Test + void testMultiRequestNoJson() throws JsonProcessingException { + final ResponseEntity result = restTemplate.postForEntity("/multiRequest", 123, TestEntity.class); + assertThat(new TestEntity(123, "123")).isEqualTo(result.getBody()); + } + + @Test + void testMultiRequest() throws JsonProcessingException { + final TestEntity test = new TestEntity(1, "test"); + final ResponseEntity result = restTemplate.postForEntity("/multiRequest", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(result.getBody()).isEqualTo(test); + } + + @Test + void testMultiRequestSingle() { + final int id = 19; + final ResponseEntity result = restTemplate.postForEntity("/multiRequestSingle", id, TestEntity.class); + assertThat(Objects.requireNonNull(result.getBody()).getId()).isEqualTo(id); + } + + @Test + void testMultiRequestObj() throws JsonProcessingException { + final TestEntity test = new TestEntity(1234, "tesdfst"); + final ResponseEntity result = restTemplate.postForEntity("/multiRequestObj", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(result.getBody()).isEqualTo(test); + } + + @Test + void multiRequestObj2() throws JsonProcessingException { + final TestEntityAll test = new TestEntityAll(1234, null, "unknow", "name"); + final ResponseEntity result = restTemplate.postForEntity("/multiRequestObj2", objectMapper.writeValueAsString(test), TestEntity3.class); + assertThat(new TestEntity3(test.getId(), new TestEntity(test.getId(), test.getName()))).isEqualTo(result.getBody()); + } + + @Test + void multiRequestObjLessParam() throws JsonProcessingException { + final TestEntityAll test = new TestEntityAll(null, null, "unknow", null); + final ResponseEntity result = restTemplate.postForEntity("/multiRequestObj2", objectMapper.writeValueAsString(test), Map.class); + Assertions.assertTrue(result.getBody().get("message").toString().contains("缺少 id 参数")); + } + + + // 不使用注解 + +} diff --git a/example/src/test/java/me/danwi/kato/example/MultiRequestBodyWithOutAnnoControllerTest.java b/example/src/test/java/me/danwi/kato/example/MultiRequestBodyWithOutAnnoControllerTest.java new file mode 100644 index 0000000..f289928 --- /dev/null +++ b/example/src/test/java/me/danwi/kato/example/MultiRequestBodyWithOutAnnoControllerTest.java @@ -0,0 +1,89 @@ +package me.danwi.kato.example; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.danwi.kato.example.argument.TestEntity; +import me.danwi.kato.example.argument.TestEntity3; +import me.danwi.kato.example.argument.TestEntityAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.ResponseEntity; + +import java.util.Map; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author wjy + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class MultiRequestBodyWithOutAnnoControllerTest { + @Autowired + private TestRestTemplate restTemplate; + @Autowired + private ObjectMapper objectMapper; + + + @Test + void multiRequestPassByMethod() throws JsonProcessingException { + final TestEntity test = new TestEntity(1, "test"); + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequestPassByMethod", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(result.getBody()).isEqualTo(new TestEntity(null, null)); + } + + @Test + void multiRequestPassByParam() throws JsonProcessingException { + final TestEntity test = new TestEntity(1, "test"); + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequestPassByParam", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(result.getBody()).isEqualTo(new TestEntity(test.getId(), null)); + } + + @Test + void testMultiRequestNoJson() throws JsonProcessingException { + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequest", 123, TestEntity.class); + assertThat(new TestEntity(123, "123")).isEqualTo(result.getBody()); + } + + @Test + void testMultiRequest() throws JsonProcessingException { + final TestEntity test = new TestEntity(1, "test"); + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequest", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(result.getBody()).isEqualTo(test); + } + + @Test + void testMultiRequestSingle() { + final int id = 19; + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequestSingle", id, TestEntity.class); + assertThat(Objects.requireNonNull(result.getBody()).getId()).isEqualTo(id); + } + + @Test + void testMultiRequestObj() throws JsonProcessingException { + final TestEntity test = new TestEntity(1234, "tesdfst"); + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequestObj", objectMapper.writeValueAsString(test), TestEntity.class); + assertThat(result.getBody()).isEqualTo(test); + } + + @Test + void multiRequestObj2() throws JsonProcessingException { + final TestEntityAll test = new TestEntityAll(1234, null, "unknow", "name"); + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequestObj2", objectMapper.writeValueAsString(test), TestEntity3.class); + assertThat(new TestEntity3(test.getId(), new TestEntity(test.getId(), test.getName()))).isEqualTo(result.getBody()); + } + + @Test + void multiRequestObjLessParam() throws JsonProcessingException { + final TestEntityAll test = new TestEntityAll(null, null, "unknow", null); + final ResponseEntity result = restTemplate.postForEntity("/withOutAnno/multiRequestObj2", objectMapper.writeValueAsString(test), Map.class); + Assertions.assertTrue(result.getBody().get("message").toString().contains("缺少 id 参数")); + } + + + // 不使用注解 + +} diff --git a/example/src/test/resources/application.yml b/example/src/test/resources/application.yml new file mode 100644 index 0000000..770596e --- /dev/null +++ b/example/src/test/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 8888 + +logging: + level: + me.danwi: debug \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts index c826dfc..01abee9 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { compileOnly("org.springframework.security:spring-security-core:5.6.3") compileOnly("org.springframework.boot:spring-boot-autoconfigure:2.6.7") compileOnly("com.fasterxml.jackson.core:jackson-databind:2.13.2.2") + compileOnly("javax.servlet:javax.servlet-api:4.0.1") + implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.0") + implementation("org.slf4j:slf4j-api:1.7.36") testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") diff --git a/server/src/main/java/me/danwi/kato/server/EnableKatoServer.java b/server/src/main/java/me/danwi/kato/server/EnableKatoServer.java index 920a63b..08c2f42 100644 --- a/server/src/main/java/me/danwi/kato/server/EnableKatoServer.java +++ b/server/src/main/java/me/danwi/kato/server/EnableKatoServer.java @@ -1,5 +1,6 @@ package me.danwi.kato.server; +import me.danwi.kato.server.argument.MethodArgumentHandlerConfig; import org.springframework.context.annotation.Import; import java.lang.annotation.ElementType; @@ -9,7 +10,7 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) -@Import({KatoConfig.class, KatoConfigWithSecurity.class}) +@Import({KatoConfig.class, KatoConfigWithSecurity.class, MethodArgumentHandlerConfig.class}) public @interface EnableKatoServer { } diff --git a/server/src/main/java/me/danwi/kato/server/KatoResponseBodyAdvice.java b/server/src/main/java/me/danwi/kato/server/KatoResponseBodyAdvice.java index f621af4..7311836 100644 --- a/server/src/main/java/me/danwi/kato/server/KatoResponseBodyAdvice.java +++ b/server/src/main/java/me/danwi/kato/server/KatoResponseBodyAdvice.java @@ -6,6 +6,8 @@ import me.danwi.kato.common.exception.ExceptionExtraDataHolder; import me.danwi.kato.common.exception.KatoException; import me.danwi.kato.common.exception.KatoUndeclaredException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.core.MethodParameter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -21,6 +23,9 @@ @RestControllerAdvice public class KatoResponseBodyAdvice implements ResponseBodyAdvice { + + private final static Logger LOGGER = LoggerFactory.getLogger(KatoResponseBodyAdvice.class); + private final ObjectMapper mapper = new ObjectMapper(); @Override @@ -46,6 +51,8 @@ public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType } //设置异常状态码 response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + LOGGER.debug("捕获异常:", (KatoException) body); + LOGGER.debug("异常:{} 转换为:{}", body.getClass().getName(), exceptionResult); return exceptionResult; } diff --git a/server/src/main/java/me/danwi/kato/server/PassByKato.java b/server/src/main/java/me/danwi/kato/server/PassByKato.java index d5b9fb0..e28f2d0 100644 --- a/server/src/main/java/me/danwi/kato/server/PassByKato.java +++ b/server/src/main/java/me/danwi/kato/server/PassByKato.java @@ -9,6 +9,6 @@ * 注解在Controller/方法上,其结果不再由kato来处理 */ @Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE, ElementType.METHOD}) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER}) public @interface PassByKato { } diff --git a/server/src/main/java/me/danwi/kato/server/argument/MethodArgumentHandlerConfig.java b/server/src/main/java/me/danwi/kato/server/argument/MethodArgumentHandlerConfig.java new file mode 100644 index 0000000..7788df8 --- /dev/null +++ b/server/src/main/java/me/danwi/kato/server/argument/MethodArgumentHandlerConfig.java @@ -0,0 +1,26 @@ +package me.danwi.kato.server.argument; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +/** + * 参数处理器自动配置 + */ +@Configuration("me.danwi.kato.server.argument.MethodArgumentHandlerConfig") +public class MethodArgumentHandlerConfig implements WebMvcConfigurer { + + private final ObjectMapper objectMapper; + + public MethodArgumentHandlerConfig(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new MultiRequestBodyMethodArgumentHandlerResolver(objectMapper)); + } +} diff --git a/server/src/main/java/me/danwi/kato/server/argument/MultiRequestBodyMethodArgumentHandlerResolver.java b/server/src/main/java/me/danwi/kato/server/argument/MultiRequestBodyMethodArgumentHandlerResolver.java new file mode 100644 index 0000000..f60f073 --- /dev/null +++ b/server/src/main/java/me/danwi/kato/server/argument/MultiRequestBodyMethodArgumentHandlerResolver.java @@ -0,0 +1,157 @@ +package me.danwi.kato.server.argument; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kotlin.reflect.KType; +import kotlin.reflect.jvm.ReflectJvmMapping; +import me.danwi.kato.common.argument.MultiRequestBody; +import me.danwi.kato.server.PassByKato; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.MethodParameter; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Objects; + +/** + * 解析请求:body + *

支持特性: + *

    + *
  • 不限制请求类型,支持GET及其他类型请求 + *
+ *

如果JSON是Object: + *

    + *
  • 支持通过注解value,指定JSON的key来解析对象 + *
  • 支持注解无value,直接根据参数名来解析对象 + *
  • 支持注解无value且参数名不匹配JSON串key时,整个body作为参数解析 + *
  • 支持参数“共用”(不指定value时,参数名不为JSON串的key时,解析整个body) + *
  • 支持多余属性不报错(需配置{@link ObjectMapper#configure(DeserializationFeature, boolean)}为FAIL_ON_UNKNOWN_PROPERTIES(false)) + *
+ *

如果JSON不是Object: + *

    + *
  • 默认整个body作为参数解析 + *
+ */ +public class MultiRequestBodyMethodArgumentHandlerResolver implements HandlerMethodArgumentResolver { + + private static final Logger LOGGER = LoggerFactory.getLogger(MultiRequestBodyMethodArgumentHandlerResolver.class); + + private static final String KATO_JSON_NODE_KEY = "_KATO_JSON_NODE_KEY_"; + + private final ObjectMapper mapper; + + public MultiRequestBodyMethodArgumentHandlerResolver(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public boolean supportsParameter(MethodParameter methodParameter) { + final Method method = methodParameter.getMethod(); + if (method == null) { + return false; + } + + // TODO 类(katoService) + return methodParameter.getDeclaringClass().getAnnotation(PassByKato.class) == null + && method.getAnnotation(PassByKato.class) == null + && !methodParameter.hasParameterAnnotation(PassByKato.class); + } + + @Override + public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception { + // 定义结果 + Object result = null; + + // 获取参数配置信息 + final ParamInfo paramInfo = getParamInfo(methodParameter); + + // 获取请求body字符串 + final JsonNode rootNode = getJsonNode(nativeWebRequest); + + if (!ObjectUtils.isEmpty(rootNode)) { + // 尝试获取field + JsonNode node = rootNode.get(paramInfo.key); + // 如果json中不存在与参数名称相对应的field,且开启了全body映射 + if ((node == null || node.isNull()) && paramInfo.parseBodyIfMissKey) { + try { + result = mapper.treeToValue(rootNode, methodParameter.getParameterType()); + } catch (Exception e) { + // 忽略 此时result=null,后续进行是否必填验证 + } + } else { + result = mapper.treeToValue(node, methodParameter.getParameterType()); + } + } + + final KType type = ReflectJvmMapping + .getKotlinFunction(methodParameter.getMethod()) + .getParameters() + .get(methodParameter.getParameterIndex() + 1) + .getType(); + final boolean isJavaCode = type.toString().endsWith("!"); + + // 是否必填验证 + if (Objects.isNull(result) && (isJavaCode ? paramInfo.required : !type.isMarkedNullable())) { + throw new IllegalArgumentException(String.format("缺少 %s 参数", paramInfo.key)); + } + LOGGER.debug("解析参数:key={},value={}", paramInfo.key, result); + return result; + } + + private ParamInfo getParamInfo(MethodParameter methodParameter) { + final ParamInfo paramInfo = new ParamInfo(); + MultiRequestBody parameterAnnotation = methodParameter.getParameterAnnotation(MultiRequestBody.class); + // 获取key + if (parameterAnnotation == null) { + paramInfo.key = methodParameter.getParameterName(); + } else { + paramInfo.key = parameterAnnotation.value(); + if (ObjectUtils.isEmpty(paramInfo.key)) { + paramInfo.key = methodParameter.getParameterName(); + } + paramInfo.required = parameterAnnotation.required(); + paramInfo.parseBodyIfMissKey = parameterAnnotation.parseBodyIfMissKey(); + } + // 校验是否获取到 key + if (ObjectUtils.isEmpty(paramInfo.key)) { + throw new IllegalArgumentException("JVM 版本不支持自动获取参数名,请手动使用 MultiRequestBody 注解指定value作为key"); + } + return paramInfo; + } + + /** + * 多个参数解析时,从attribute中获取数据 + * + * @param nativeWebRequest + * @return JsonNode + * @throws IOException + */ + private JsonNode getJsonNode(NativeWebRequest nativeWebRequest) throws IOException { + Object attribute = nativeWebRequest.getAttribute(KATO_JSON_NODE_KEY, WebRequest.SCOPE_REQUEST); + if (ObjectUtils.isEmpty(attribute)) { + HttpServletRequest servletRequest = nativeWebRequest.getNativeRequest(HttpServletRequest.class); + Assert.state(servletRequest != null, "No HttpServletRequest"); + attribute = mapper.readTree(servletRequest.getInputStream()); + nativeWebRequest.setAttribute(KATO_JSON_NODE_KEY, attribute, WebRequest.SCOPE_REQUEST); + + LOGGER.debug("解析请求数据 [{}] 为 [{}]", nativeWebRequest.getHeader("accept"), attribute); + } + return (JsonNode) attribute; + } + + private static class ParamInfo { + String key = ""; + boolean required = true; + boolean parseBodyIfMissKey = true; + } +}