From c8bc96ffc38974e2c2167d819861dc41a9195608 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Fri, 18 Nov 2022 14:32:22 +0800 Subject: [PATCH] refactor: support chaining calls for flux and mono in javascript inline tag (#2715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind improvement /area core /milestone 2.0 #### What this PR does / why we need it: 支持在 JavaScript 中对 Flux 或 Mono 的泛型类型属性链式调用 测试参考: https://github.com/halo-dev/halo/blob/7e9bdec4923348b98df17b7e1071154ecec8ead1/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java#L131-L147 期望 https://github.com/halo-dev/halo/blob/7e9bdec4923348b98df17b7e1071154ecec8ead1/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java#L64-L79 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 支持在 JavaScript 中对 Flux 或 Mono 的泛型类型属性链式调用 ``` --- .../app/theme/ReactivePropertyAccessor.java | 94 ++++++----- ...activeSpelVariableExpressionEvaluator.java | 2 +- .../ReactiveFinderExpressionParserTests.java | 151 ++++++++++++++++++ 3 files changed, 197 insertions(+), 50 deletions(-) create mode 100644 src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java diff --git a/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java b/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java index 572396aef..02cad83f0 100644 --- a/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java +++ b/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java @@ -1,18 +1,16 @@ package run.halo.app.theme; -import com.fasterxml.jackson.databind.JsonNode; -import java.util.ArrayList; import java.util.List; import org.springframework.expression.AccessException; import org.springframework.expression.EvaluationContext; import org.springframework.expression.PropertyAccessor; import org.springframework.expression.TypedValue; +import org.springframework.expression.spel.ast.AstUtils; import org.springframework.integration.json.JsonPropertyAccessor; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import run.halo.app.infra.utils.JsonUtils; /** * A SpEL PropertyAccessor that knows how to read properties from {@link Mono} or {@link Flux} @@ -24,25 +22,25 @@ import run.halo.app.infra.utils.JsonUtils; * @since 2.0.0 */ public class ReactivePropertyAccessor implements PropertyAccessor { - private static final Class[] SUPPORTED_CLASSES = { - Mono.class, - Flux.class - }; - private final JsonPropertyAccessor jsonPropertyAccessor = new JsonPropertyAccessor(); @Override public Class[] getSpecificTargetClasses() { - return SUPPORTED_CLASSES; + return null; } @Override public boolean canRead(@NonNull EvaluationContext context, Object target, @NonNull String name) throws AccessException { - if (target == null) { - return false; + if (isReactiveType(target)) { + return true; } - return Mono.class.isAssignableFrom(target.getClass()) - || Flux.class.isAssignableFrom(target.getClass()); + List propertyAccessors = context.getPropertyAccessors(); + for (PropertyAccessor propertyAccessor : propertyAccessors) { + if (propertyAccessor.canRead(context, target, name)) { + return true; + } + } + return false; } @Override @@ -52,29 +50,44 @@ public class ReactivePropertyAccessor implements PropertyAccessor { if (target == null) { return TypedValue.NULL; } - Class clazz = target.getClass(); - Object value = null; - if (Mono.class.isAssignableFrom(clazz)) { - value = ((Mono) target).block(); - } else if (Flux.class.isAssignableFrom(clazz)) { - value = ((Flux) target).toIterable(); - } - - if (value == null) { - return TypedValue.NULL; - } + Object value = blockingGetForReactive(target); List propertyAccessorsToTry = getPropertyAccessorsToTry(value, context.getPropertyAccessors()); for (PropertyAccessor propertyAccessor : propertyAccessorsToTry) { try { - return propertyAccessor.read(context, target, name); + TypedValue result = propertyAccessor.read(context, value, name); + return new TypedValue(blockingGetForReactive(result.getValue())); } catch (AccessException e) { - // ignore + // ignore this } } - JsonNode jsonNode = JsonUtils.DEFAULT_JSON_MAPPER.convertValue(value, JsonNode.class); - return jsonPropertyAccessor.read(context, jsonNode, name); + + throw new AccessException("Cannot read property '" + name + "' from [" + value + "]"); + } + + @Nullable + private static Object blockingGetForReactive(@Nullable Object target) { + if (target == null) { + return null; + } + Class clazz = target.getClass(); + Object value = target; + if (Mono.class.isAssignableFrom(clazz)) { + value = ((Mono) target).block(); + } else if (Flux.class.isAssignableFrom(clazz)) { + value = ((Flux) target).collectList().block(); + } + return value; + } + + private boolean isReactiveType(Object target) { + if (target == null) { + return false; + } + Class clazz = target.getClass(); + return Mono.class.isAssignableFrom(clazz) + || Flux.class.isAssignableFrom(clazz); } private List getPropertyAccessorsToTry( @@ -82,27 +95,10 @@ public class ReactivePropertyAccessor implements PropertyAccessor { Class targetType = (contextObject != null ? contextObject.getClass() : null); - List specificAccessors = new ArrayList<>(); - List generalAccessors = new ArrayList<>(); - for (PropertyAccessor resolver : propertyAccessors) { - Class[] targets = resolver.getSpecificTargetClasses(); - if (targets == null) { - // generic resolver that says it can be used for any type - generalAccessors.add(resolver); - } else if (targetType != null) { - for (Class clazz : targets) { - if (clazz == targetType) { - specificAccessors.add(resolver); - break; - } else if (clazz.isAssignableFrom(targetType)) { - generalAccessors.add(resolver); - } - } - } - } - List resolvers = new ArrayList<>(specificAccessors); - generalAccessors.removeAll(specificAccessors); - resolvers.addAll(generalAccessors); + List resolvers = + AstUtils.getPropertyAccessorsToTry(targetType, propertyAccessors); + // remove this resolver to avoid infinite loop + resolvers.remove(this); return resolvers; } diff --git a/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java b/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java index b09b25c60..179449778 100644 --- a/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java +++ b/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java @@ -37,7 +37,7 @@ public class ReactiveSpelVariableExpressionEvaluator return ((Mono) returnValue).block(); } if (Flux.class.isAssignableFrom(clazz)) { - return ((Flux) returnValue).toIterable(); + return ((Flux) returnValue).collectList().block(); } return returnValue; } diff --git a/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java b/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java new file mode 100644 index 000000000..a44ff8c0c --- /dev/null +++ b/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java @@ -0,0 +1,151 @@ +package run.halo.app.theme; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.dialect.SpringStandardDialect; +import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; +import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator; +import org.thymeleaf.templateresolver.StringTemplateResolver; +import org.thymeleaf.templateresource.ITemplateResource; +import org.thymeleaf.templateresource.StringTemplateResource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.theme.dialect.HaloProcessorDialect; + +/** + * Tests expression parser for reactive return value. + * + * @author guqing + * @see ReactivePropertyAccessor + * @see ReactiveSpelVariableExpressionEvaluator + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +public class ReactiveFinderExpressionParserTests { + @Mock + private ApplicationContext applicationContext; + + private TemplateEngine templateEngine; + + @BeforeEach + void setUp() { + HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); + templateEngine = new TemplateEngine(); + templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect() { + @Override + public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() { + return new ReactiveSpelVariableExpressionEvaluator(); + } + })); + templateEngine.addTemplateResolver(new TestTemplateResolver()); + } + + @Test + void javascriptInlineParser() { + Context context = getContext(); + context.setVariable("target", new TestReactiveFinder()); + context.setVariable("genericMap", Map.of("key", "value")); + String result = templateEngine.process("javascriptInline", context); + assertThat(result).isEqualTo(""" +

value

+

ruibaby

+

guqing

+

bar

+ + """); + } + + static class TestReactiveFinder { + public Mono getName() { + return Mono.just("guqing"); + } + + public Flux names() { + return Flux.just("guqing", "johnniang", "ruibaby"); + } + + public Flux users() { + return Flux.just( + new TestUser("guqing"), new TestUser("ruibaby"), new TestUser("johnniang") + ); + } + + public Flux objectJsonNodeFlux() { + ObjectNode objectNode = JsonUtils.DEFAULT_JSON_MAPPER.createObjectNode(); + objectNode.put("name", "guqing"); + return Flux.just(objectNode); + } + + public Mono> mapMono() { + return Mono.just(Map.of("foo", "bar")); + } + + public Mono arrayNodeMono() { + ArrayNode arrayNode = JsonUtils.DEFAULT_JSON_MAPPER.createArrayNode(); + arrayNode.add(arrayNode.objectNode().put("foo", "bar")); + return Mono.just(arrayNode); + } + } + + record TestUser(String name) { + } + + private Context getContext() { + Context context = new Context(); + context.setVariable( + ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, + new ThymeleafEvaluationContext(applicationContext, null)); + return context; + } + + static class TestTemplateResolver extends StringTemplateResolver { + @Override + protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, + String ownerTemplate, String template, + Map templateResolutionAttributes) { + return new StringTemplateResource(""" +

+

+

+

+ + """); + } + + } +}