This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-25928-body-type-resolver in repository https://gitbox.apache.org/repos/asf/camel.git
commit 32f0a0abc7223c8e1d67236c64a427a366c3f4cc Author: Claus Ibsen <[email protected]> AuthorDate: Sun Oct 25 08:54:51 2026 +0100 CAMEL-25928: camel-tooling-model - BodyTypeResolver, the rule the tools share to read a body type for the options a step sets Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/dsl/yaml/validator/CatalogBodyTypes.java | 85 +--------- .../camel/tooling/model/BodyTypeResolver.java | 172 +++++++++++++++++++++ .../camel/tooling/model/BodyTypeResolverTest.java | 149 ++++++++++++++++++ .../camel/maven/packaging/BodyTypesHelper.java | 18 +-- 4 files changed, 328 insertions(+), 96 deletions(-) diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java index a2ff7f8b230c..6cd0a7753bf4 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java @@ -20,7 +20,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -31,11 +30,10 @@ import com.fasterxml.jackson.databind.JsonNode; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.tooling.model.BaseOptionModel; import org.apache.camel.tooling.model.BodyTypeModel; -import org.apache.camel.tooling.model.BodyTypeModel.OptionBodyTypeModel; +import org.apache.camel.tooling.model.BodyTypeResolver; import org.apache.camel.tooling.model.ComponentModel; import org.apache.camel.tooling.model.DataFormatModel; import org.apache.camel.tooling.model.EipModel; -import org.apache.camel.util.StringHelper; import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme; @@ -234,85 +232,12 @@ final class CatalogBodyTypes { } /** - * What a side says for the options a step sets: the body for an option value it names, or its body when the options - * it depends on keep their defaults; null when it is not known for the step. An option the side depends on decides - * first; then the option that names the class of the body (CAMEL-25928), and then the bodies for the values of the - * other options. + * What a side says for the options a step sets, by the rule the tools share (BodyTypeResolver of the tooling + * model); null when it is not known for the step. */ private static Answer answer(BodyTypeModel side, Map<String, String> set, Set<String> configurable) { - if (side == null) { - return null; - } - Set<String> options = new LinkedHashSet<>(side.getDependsOn()); - side.getByOption().forEach(o -> options.add(o.getOption())); - if (side.getJavaTypeOption() != null) { - options.add(side.getJavaTypeOption()); - } - side.getByOption().stream().map(OptionBodyTypeModel::getJavaTypeOption).filter(o -> o != null) - .forEach(options::add); - for (String option : options) { - String value = set.get(option); - if (value == null ? configurable.contains(option) : value.contains("{{") || value.contains("${")) { - // the component may set it, or its value is only known when the route runs - return null; - } - } - for (String option : side.getDependsOn()) { - String value = set.get(option); - if (value != null && side.getByOption().stream() - .noneMatch(w -> w.getOption().equals(option) && isSameValue(value, w.getValue()))) { - // the body depends on this option, and no option value of the side says the body for its value: the - // value another option names does not hold then, such as output=bytes with a resultHandlerFactory - return null; - } - } - boolean dependsOnSet = side.getDependsOn().stream().anyMatch(set::containsKey); - if (!dependsOnSet && side.getJavaTypeOption() != null && set.containsKey(side.getJavaTypeOption())) { - // the option names the class of the body, whatever the values of the other options say - return ofClass(set.get(side.getJavaTypeOption())); - } - Answer matched = null; - for (OptionBodyTypeModel when : side.getByOption()) { - String value = set.get(when.getOption()); - if (value != null && isSameValue(value, when.getValue())) { - Answer answer = when.getJavaTypeOption() != null && set.containsKey(when.getJavaTypeOption()) - ? ofClass(set.get(when.getJavaTypeOption())) - : new Answer(when.getCarriers(), when.getJavaType(), side.getFormat()); - if (answer == null || matched != null && !matched.carriers().equals(answer.carriers())) { - // two options the step sets say different bodies - return null; - } - matched = answer; - } - } - if (matched != null) { - return matched; - } - if (side.isUnknown() || side.getDependsOn().stream().anyMatch(set::containsKey)) { - return null; - } - return new Answer(side.getCarriers(), side.getJavaType(), side.getFormat()); - } - - /** - * The body of the class an option names, such as unmarshalType: an object of it, or null when it does not convert. - * Object says nothing. - */ - private static Answer ofClass(String javaType) { - String carrier = BodyTypeModel.carrierOf(javaType); - if ("unknown".equals(carrier)) { - return null; - } - return new Answer(List.of(carrier, "null"), javaType.trim(), null); - } - - /** - * Whether the value a step sets is the value of an option body: in any case, and in any style of an enum constant, - * as the catalog and the enum type converter take it, so operation=convert-to-json is CONVERT_TO_JSON. - */ - private static boolean isSameValue(String value, String other) { - return value.equalsIgnoreCase(other) - || StringHelper.asEnumConstantValue(value).equals(StringHelper.asEnumConstantValue(other)); + BodyTypeResolver.Resolved resolved = BodyTypeResolver.resolve(side, set, configurable); + return resolved != null ? new Answer(resolved.carriers(), resolved.javaType(), resolved.format()) : null; } /** The options of a node, by name in camel case: allowNullBody as well for allow-null-body. */ diff --git a/tooling/camel-tooling-model/src/main/java/org/apache/camel/tooling/model/BodyTypeResolver.java b/tooling/camel-tooling-model/src/main/java/org/apache/camel/tooling/model/BodyTypeResolver.java new file mode 100644 index 000000000000..0a920d681319 --- /dev/null +++ b/tooling/camel-tooling-model/src/main/java/org/apache/camel/tooling/model/BodyTypeResolver.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.tooling.model; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.apache.camel.tooling.model.BodyTypeModel.OptionBodyTypeModel; + +/** + * What one side of the body types of the catalog says for the options a step sets (CAMEL-24961, CAMEL-25928): the rule + * that the YAML validator and the other tools share, so they all read the catalog the same way. + * <p/> + * The rule, in order: + * <ol> + * <li>An option the side names (in dependsOn, byOption or javaTypeOption) whose value is only known when the route + * runs, a {@code {{placeholder}}} or a {@code ${expression}}, or that the step does not set but can be set elsewhere, + * such as on the component: not known.</li> + * <li>An option of dependsOn the step sets: the body of byOption for that value decides, and without one it is not + * known.</li> + * <li>Otherwise, the javaTypeOption the step sets: an object of the class it names, or null.</li> + * <li>Otherwise, the bodies of byOption for the values the step sets; when two say different bodies, not known.</li> + * <li>Otherwise, the carriers of the side; unknown is not known.</li> + * </ol> + * A body of byOption that has a javaTypeOption the step sets is an object of that class, or null. The values of an + * option compare in any case, and in any style of an enum constant, as Camel takes them: convert-to-json is + * CONVERT_TO_JSON. A side with from is the body of a side of another model, which the caller resolves. + */ +public final class BodyTypeResolver { + + private BodyTypeResolver() { + } + + /** + * What a side says for a step: the carriers of the body, and its Java type and format when it has them. + */ + public record Resolved(List<String> carriers, String javaType, String format) { + } + + /** + * What a side says for the options a step sets. + * + * @param side the side, null when the catalog has none + * @param options the options the step sets, by name: the endpoint uri, the parameters or the options of a data + * format + * @param hidden the options that can be set elsewhere when the step does not set them, such as the options of the + * component + * @return the body, or null when it is not known for the step + */ + public static Resolved resolve(BodyTypeModel side, Map<String, String> options, Set<String> hidden) { + if (side == null || side.getFrom() != null) { + return null; + } + Set<String> names = new LinkedHashSet<>(side.getDependsOn()); + side.getByOption().forEach(o -> names.add(o.getOption())); + if (side.getJavaTypeOption() != null) { + names.add(side.getJavaTypeOption()); + } + side.getByOption().stream().map(OptionBodyTypeModel::getJavaTypeOption).filter(Objects::nonNull) + .forEach(names::add); + for (String name : names) { + String value = options.get(name); + if (value == null ? hidden.contains(name) : value.contains("{{") || value.contains("${")) { + // it may be set elsewhere, or its value is only known when the route runs + return null; + } + } + boolean dependsOnSet = false; + for (String name : side.getDependsOn()) { + String value = options.get(name); + if (value != null) { + dependsOnSet = true; + if (side.getByOption().stream() + .noneMatch(w -> w.getOption().equals(name) && isSameValue(value, w.getValue()))) { + // the body depends on this option, and no option value of the side says the body for its value + return null; + } + } + } + if (!dependsOnSet && side.getJavaTypeOption() != null && options.containsKey(side.getJavaTypeOption())) { + return ofClass(options.get(side.getJavaTypeOption())); + } + Resolved matched = null; + for (OptionBodyTypeModel when : side.getByOption()) { + String value = options.get(when.getOption()); + if (value != null && isSameValue(value, when.getValue())) { + Resolved resolved = when.getJavaTypeOption() != null && options.containsKey(when.getJavaTypeOption()) + ? ofClass(options.get(when.getJavaTypeOption())) + : new Resolved(when.getCarriers(), when.getJavaType(), side.getFormat()); + if (resolved == null || matched != null && !matched.carriers().equals(resolved.carriers())) { + // two options the step sets say different bodies + return null; + } + matched = resolved; + } + } + if (matched != null) { + return matched; + } + if (dependsOnSet || side.isUnknown()) { + return null; + } + return new Resolved(side.getCarriers(), side.getJavaType(), side.getFormat()); + } + + /** + * The body of the class an option names: an object of it, or null when it does not convert. Object says nothing. + */ + private static Resolved ofClass(String javaType) { + String carrier = BodyTypeModel.carrierOf(javaType); + if ("unknown".equals(carrier)) { + return null; + } + return new Resolved(List.of(carrier, "null"), javaType.trim(), null); + } + + /** + * Whether a value of an option is the value a body is for: in any case, and in any style of an enum constant, as + * the catalog and the enum type converter take it, so convert-to-json and convertToJson are CONVERT_TO_JSON. + */ + public static boolean isSameValue(String value, String other) { + return value.equalsIgnoreCase(other) || asEnumConstant(value).equals(asEnumConstant(other)); + } + + /** The value as an enum constant, as StringHelper.asEnumConstantValue in camel-util gives it. */ + private static String asEnumConstant(String value) { + return camelCaseToDash(value).replaceAll("-+", "-").replace('-', '_').toUpperCase(Locale.ENGLISH); + } + + /** As StringHelper.camelCaseToDash in camel-util, which this module does not depend on. */ + private static String camelCaseToDash(String text) { + StringBuilder answer = new StringBuilder(text.length() + 8); + char prev = 0; + char[] arr = text.toCharArray(); + for (int i = 0; i < arr.length; i++) { + char ch = arr[i]; + if (ch == '-' || ch == '_') { + answer.append('-'); + } else if (Character.isUpperCase(ch) && prev != 0) { + char next = i < arr.length - 1 ? arr[i + 1] : 0; + if (!Character.isUpperCase(prev) || next != 0 && Character.isLowerCase(next)) { + if (prev != '-' && prev != '_') { + answer.append('-'); + } + } + answer.append(Character.toLowerCase(ch)); + } else { + answer.append(Character.toLowerCase(ch)); + } + prev = ch; + } + return answer.toString(); + } +} diff --git a/tooling/camel-tooling-model/src/test/java/org/apache/camel/tooling/model/BodyTypeResolverTest.java b/tooling/camel-tooling-model/src/test/java/org/apache/camel/tooling/model/BodyTypeResolverTest.java new file mode 100644 index 000000000000..31f4fd10654e --- /dev/null +++ b/tooling/camel-tooling-model/src/test/java/org/apache/camel/tooling/model/BodyTypeResolverTest.java @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.tooling.model; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.tooling.model.BodyTypeModel.OptionBodyTypeModel; +import org.apache.camel.tooling.model.BodyTypeResolver.Resolved; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The rule the tools share to read a side of the body types for the options a step sets (CAMEL-25928). + */ +class BodyTypeResolverTest { + + @Test + void theCarriersWithTheDefaultOptions() { + BodyTypeModel side = side(List.of("map", "list")); + side.setFormat("json"); + assertEquals(new Resolved(List.of("map", "list"), null, "json"), resolve(side, Map.of())); + assertNull(resolve(side(List.of("unknown")), Map.of())); + assertNull(resolve(null, Map.of())); + } + + @Test + void aSideFromAnotherModelIsResolvedByTheCaller() { + BodyTypeModel side = new BodyTypeModel(); + side.setFrom(BodyTypeModel.MARSHAL_OUT); + assertNull(resolve(side, Map.of())); + } + + @Test + void aValueKnownOnlyWhenTheRouteRunsIsNotKnown() { + BodyTypeModel side = jackson(); + assertNull(resolve(side, Map.of("unmarshalType", "{{type}}"))); + assertNull(resolve(side, Map.of("useList", "${header.list}"))); + // an option the component can set, and the step does not + assertNull(BodyTypeResolver.resolve(side, Map.of(), Set.of("useList"))); + } + + @Test + void anOptionTheBodyDependsOnDecidesFirst() { + BodyTypeModel side = jackson(); + assertEquals(List.of("list"), resolve(side, Map.of("useList", "true", "unmarshalType", "com.acme.Order")).carriers()); + // no body for its value: not known, also with the class + assertNull(resolve(side, Map.of("collectionType", "java.util.Set", "unmarshalType", "com.acme.Order"))); + assertNull(resolve(side, Map.of("useList", "false"))); + } + + @Test + void theOptionThatNamesTheClassOfTheBody() { + BodyTypeModel side = jackson(); + Resolved order = resolve(side, Map.of("unmarshalType", "com.acme.Order")); + assertEquals(new Resolved(List.of("pojo", "null"), "com.acme.Order", null), order); + assertEquals(List.of("text", "null"), resolve(side, Map.of("unmarshalType", "String")).carriers()); + // Object can be anything + assertNull(resolve(side, Map.of("unmarshalType", "java.lang.Object"))); + } + + @Test + void theOptionThatNamesTheClassWinsOverTheValuesOfOtherOptions() { + BodyTypeModel side = side(List.of("pojo")); + side.setJavaType("org.w3c.dom.NodeList"); + side.setJavaTypeOption("resultType"); + side.getByOption().add(when("resultQName", "STRING", List.of("text"), null)); + assertEquals(List.of("text"), resolve(side, Map.of("resultQName", "string")).carriers()); + assertEquals(List.of("value", "null"), + resolve(side, Map.of("resultQName", "STRING", "resultType", "java.lang.Integer")).carriers()); + } + + @Test + void anOptionValueCanNameTheClassToo() { + BodyTypeModel side = side(List.of("list", "unchanged")); + side.getByOption().add(when("outputType", "SelectOne", List.of("map", "value", "null"), "outputClass")); + side.getByOption().add(when("outputType", "StreamList", List.of("iterator"), null)); + assertEquals(List.of("map", "value", "null"), resolve(side, Map.of("outputType", "SelectOne")).carriers()); + assertEquals(new Resolved(List.of("pojo", "null"), "com.acme.Order", null), + resolve(side, Map.of("outputType", "SelectOne", "outputClass", "com.acme.Order"))); + // the class does not change the List of the default outputType + assertEquals(List.of("list", "unchanged"), resolve(side, Map.of("outputClass", "com.acme.Order")).carriers()); + assertEquals(List.of("iterator"), resolve(side, Map.of("outputType", "stream-list")).carriers()); + } + + @Test + void twoOptionValuesThatSayDifferentBodiesAreNotKnown() { + BodyTypeModel side = side(List.of("text")); + side.getByOption().add(when("output", "bytes", List.of("bytes"), null)); + side.getByOption().add(when("binary", "true", List.of("stream"), null)); + assertNull(resolve(side, Map.of("output", "bytes", "binary", "true"))); + assertEquals(List.of("bytes"), resolve(side, Map.of("output", "BYTES")).carriers()); + } + + @Test + void theValuesOfAnEnumInAnyStyle() { + assertTrue(BodyTypeResolver.isSameValue("convert-to-json", "CONVERT_TO_JSON")); + assertTrue(BodyTypeResolver.isSameValue("convertToJson", "CONVERT_TO_JSON")); + assertTrue(BodyTypeResolver.isSameValue("selectOne", "SelectOne")); + assertFalse(BodyTypeResolver.isSameValue("SelectList", "SelectOne")); + } + + private static Resolved resolve(BodyTypeModel side, Map<String, String> options) { + return BodyTypeResolver.resolve(side, options, Set.of()); + } + + /** The body after unmarshal of jackson: a Map or a List, a List with useList, or the class of unmarshalType. */ + private static BodyTypeModel jackson() { + BodyTypeModel side = side(List.of("map", "list")); + side.setDependsOn(List.of("collectionType", "useList", "allowUnmarshallType")); + side.setJavaTypeOption("unmarshalType"); + side.getByOption().add(when("useList", "true", List.of("list"), null)); + return side; + } + + private static BodyTypeModel side(List<String> carriers) { + BodyTypeModel side = new BodyTypeModel(); + side.setCarriers(carriers); + return side; + } + + private static OptionBodyTypeModel when(String option, String value, List<String> carriers, String javaTypeOption) { + OptionBodyTypeModel when = new OptionBodyTypeModel(); + when.setOption(option); + when.setValue(value); + when.setCarriers(carriers); + when.setJavaTypeOption(javaTypeOption); + return when; + } +} diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/BodyTypesHelper.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/BodyTypesHelper.java index 9c6761e0d4ba..8657b26696df 100644 --- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/BodyTypesHelper.java +++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/BodyTypesHelper.java @@ -21,7 +21,6 @@ import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TreeSet; @@ -32,8 +31,8 @@ import org.apache.camel.spi.BodyTypes; import org.apache.camel.tooling.model.BaseModel; import org.apache.camel.tooling.model.BaseOptionModel; import org.apache.camel.tooling.model.BodyTypeModel; +import org.apache.camel.tooling.model.BodyTypeResolver; import org.apache.camel.tooling.model.ComponentModel; -import org.apache.camel.tooling.util.Strings; /** * Carries the {@link BodyTypes} of a component, data format, language or EIP into its model, which the catalog JSON is @@ -238,26 +237,13 @@ final class BodyTypesHelper { List<String> values = option.getEnums() != null && !option.getEnums().isEmpty() ? option.getEnums() : "boolean".equals(option.getType()) ? List.of("true", "false") : List.of(); - if (!values.isEmpty() && values.stream().noneMatch(v -> isSameValue(v, when.value()))) { + if (!values.isEmpty() && values.stream().noneMatch(v -> BodyTypeResolver.isSameValue(v, when.value()))) { throw new IllegalArgumentException( owner.getName() + ": the @BodyTypes " + side + " has " + when.option() + "=" + when.value() + ", which is not a value of the option: " + String.join(", ", values)); } } - /** - * Whether two values of an option are one value as Camel takes it: in any case, and in any style of an enum - * constant, so convert-to-json and convertToJson are CONVERT_TO_JSON, as for the catalog and the enum type - * converter (StringHelper.asEnumConstantValue in camel-util, which this plugin does not depend on). - */ - private static boolean isSameValue(String value, String other) { - return value.equalsIgnoreCase(other) || asEnumConstant(value).equals(asEnumConstant(other)); - } - - private static String asEnumConstant(String value) { - return Strings.camelCaseToDash(value).replaceAll("-+", "-").replace('-', '_').toUpperCase(Locale.ENGLISH); - } - /** * A body has a carrier: an option value without one would say nothing. UNCHANGED is for the body after a step, such * as a sql query that leaves it unchanged unless it is a select. ANY is for the bodies a step needs, and UNKNOWN
