This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-24694 in repository https://gitbox.apache.org/repos/asf/camel.git
commit e1d223d3c3a791e81e44a392b757bacdec19726d Author: Claus Ibsen <[email protected]> AuthorDate: Sat Sep 12 11:08:53 2026 +0200 CAMEL-24694: camel-yaml-dsl - validator must accept the scalar forms the runtime accepts The generated schema types scalar attributes from the model metadata, but the model fields are String and the runtime converts the text (resolving property placeholders) when the route starts. Every schema consumer - camel validate yaml, the TUI save-time validation and its MCP tools, camel-jbang-mcp, IDE plugins and Kaoto - therefore refused input that camel run accepts. The schema keeps its real types, since Kaoto forms, TUI completion and catalog docs rely on them. The leniency goes in YamlValidator instead, in both classic and canonical mode: * typeLoose(true) accepts a quoted scalar that parses as the expected type (parallelProcessing: "true"); "yes please" is still rejected. * isRuntimeAcceptedScalar drops a type error when the value is a property placeholder at a typed attribute, or a number/boolean at a string-typed attribute such as a duration. For the validator to stay honest the runtime has to defer the conversion for every scalar attribute the schema exposes. Two were still converted while deserializing, so a property placeholder silently became false: * ParamDefinition.required * RouteTemplateParameterDefinition.required Both are now String fields with @Metadata(javaType) like the rest of the model, so the schema still emits boolean while the runtime resolves the placeholder. The three attributes that are still converted eagerly (inheritErrorHandler on circuitBreaker and failover, and BeanConstructorDefinition.index) are not reachable from the generated schema, so the leniency cannot affect them. They are tracked in CAMEL-24696. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/schemas/camel-spring.xsd | 4 +- .../apache/camel/catalog/schemas/camel-xml-io.xsd | 4 +- .../apache/camel/openapi/RestOpenApiReader.java | 15 ++- .../java/org/apache/camel/impl/DefaultModel.java | 11 +- .../camel/model/RouteTemplateDefinition.java | 2 +- .../model/RouteTemplateParameterDefinition.java | 15 ++- .../apache/camel/model/rest/ParamDefinition.java | 20 ++- .../apache/camel/model/rest/RestDefinition.java | 2 +- .../apache/camel/builder/RouteTemplateTest.java | 41 ++++++ .../camel/component/rest/FromRestGetTest.java | 4 +- .../apache/camel/java/out/JavaDslModelWriter.java | 4 +- .../camel/java/out/JavaDslModelWriterSupport.java | 2 +- .../java/org/apache/camel/xml/in/ModelParser.java | 4 +- .../java/org/apache/camel/xml/out/ModelWriter.java | 4 +- .../org/apache/camel/yaml/out/YamlModelWriter.java | 4 +- .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 29 +++++ .../dsl/yaml/deserializers/ModelDeserializers.java | 4 +- .../camel/dsl/yaml/validator/YamlValidator.java | 66 +++++++++- .../validator/YamlValidatorScalarLeniencyTest.java | 139 +++++++++++++++++++++ .../camel/dsl/yaml/KameletRoutesBuilderLoader.java | 2 +- 20 files changed, 342 insertions(+), 34 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd index 74709394fc46..5ee9404250ed 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd @@ -12863,7 +12863,7 @@ The name of the template parameter. </xs:documentation> </xs:annotation> </xs:attribute> - <xs:attribute name="required" type="xs:boolean"> + <xs:attribute name="required" type="xs:string"> <xs:annotation> <xs:documentation xml:lang="en"> <![CDATA[ @@ -15993,7 +15993,7 @@ The default value of the parameter. </xs:documentation> </xs:annotation> </xs:attribute> - <xs:attribute name="required" type="xs:boolean"> + <xs:attribute name="required" type="xs:string"> <xs:annotation> <xs:documentation xml:lang="en"> <![CDATA[ diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd index c0902dd541fe..dbbcbd1ba490 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd @@ -12006,7 +12006,7 @@ The name of the template parameter. </xs:documentation> </xs:annotation> </xs:attribute> - <xs:attribute name="required" type="xs:boolean"> + <xs:attribute name="required" type="xs:string"> <xs:annotation> <xs:documentation xml:lang="en"> <![CDATA[ @@ -15136,7 +15136,7 @@ The default value of the parameter. </xs:documentation> </xs:annotation> </xs:attribute> - <xs:attribute name="required" type="xs:boolean"> + <xs:attribute name="required" type="xs:string"> <xs:annotation> <xs:documentation xml:lang="en"> <![CDATA[ diff --git a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java index 13ff8f7a4091..2f05c6f6d303 100644 --- a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java +++ b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java @@ -121,6 +121,15 @@ public class RestOpenApiReader { return camelContext.resolvePropertyPlaceholders(text); } + /** + * The required flag of a rest parameter. It is a String on the model so that a property placeholder can be used, + * and is resolved here. + */ + private static boolean isRequired(CamelContext camelContext, ParamDefinition param) { + Boolean required = CamelContextHelper.parseBoolean(camelContext, param.getRequired()); + return required != null && required; + } + private static List<String> getValue(CamelContext camelContext, List<String> list) { if (list == null) { return null; @@ -565,7 +574,7 @@ public class RestOpenApiReader { if (org.apache.camel.util.ObjectHelper.isNotEmpty(param.getDescription())) { parameter.setDescription(getValue(camelContext, param.getDescription())); } - parameter.setRequired(param.getRequired()); + parameter.setRequired(isRequired(camelContext, param)); final String dataType = getValue(camelContext, param.getDataType() != null ? param.getDataType() : "string"); @@ -660,7 +669,7 @@ public class RestOpenApiReader { // In OpenAPI 3x, body parameters are replaced by requestBody if (parameter.getIn().equals("body")) { RequestBody reqBody = new RequestBody().content(new Content()); - reqBody.setRequired(param.getRequired()); + reqBody.setRequired(isRequired(camelContext, param)); reqBody.setDescription(getValue(camelContext, param.getDescription())); op.setRequestBody(reqBody); String type = getValue(camelContext, verb.getType()); @@ -727,7 +736,7 @@ public class RestOpenApiReader { fieldSchema.setDefault(getValue(camelContext, param.getDefaultValue())); } formSchema.addProperty(name, fieldSchema); - if (param.getRequired()) { + if (isRequired(camelContext, param)) { requiredFields.add(name); } } diff --git a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java index 8419f9f61992..1fbf9a703805 100644 --- a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java +++ b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java @@ -557,7 +557,7 @@ public class DefaultModel implements Model { } else if (temp.getDefaultValue() != null) { addProperty(prop, temp.getName(), temp.getDefaultValue()); addProperty(propDefaultValues, temp.getName(), temp.getDefaultValue()); - } else if (temp.isRequired() && !routeTemplateContext.hasParameter(temp.getName())) { + } else if (isTemplateParameterRequired(temp) && !routeTemplateContext.hasParameter(temp.getName())) { // this is a required parameter which is missing missingParameters.add(temp.getName()); } @@ -654,6 +654,15 @@ public class DefaultModel implements Model { return def.getId(); } + /** + * Whether the given route template parameter is required. The attribute is a String so that a property placeholder + * can be used, and it is assumed to be required unless explicitly set to false. + */ + private boolean isTemplateParameterRequired(RouteTemplateParameterDefinition temp) { + Boolean required = CamelContextHelper.parseBoolean(camelContext, temp.getRequired()); + return required == null || required; + } + private static void addProperty(Map<String, Object> prop, String key, Object value) { prop.put(key, value); // support also camelCase and kebab-case because route templates (kamelets) diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateDefinition.java index 44baa45e6dc3..579b96fefe67 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateDefinition.java @@ -418,7 +418,7 @@ public class RouteTemplateDefinition extends OptionalIdentifiedDefinition<RouteT this.templateParameters = new ArrayList<>(); } RouteTemplateParameterDefinition def = new RouteTemplateParameterDefinition(name, null, description); - def.setRequired(false); + def.setRequired("false"); this.templateParameters.add(def); } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateParameterDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateParameterDefinition.java index 5fc40fccab36..ccf09be6e337 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateParameterDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/RouteTemplateParameterDefinition.java @@ -38,7 +38,7 @@ public class RouteTemplateParameterDefinition { @XmlAttribute @Metadata(description = "Whether this template parameter is required. A required parameter must have a value provided when creating a route from the template.", javaType = "java.lang.Boolean") - Boolean required; + String required; @XmlAttribute @Metadata(description = "The default value of the template parameter. Used when no explicit value is provided when creating a route from the template.") String defaultValue; @@ -55,9 +55,16 @@ public class RouteTemplateParameterDefinition { this.defaultValue = defaultValue; } + /** + * Whether this template parameter is required, assuming required unless explicitly set to false. + * <p> + * Note that this does not resolve property placeholders; use + * {@code CamelContextHelper.parseBoolean(camelContext, getRequired())} where a + * {@link org.apache.camel.CamelContext} is available. + */ public boolean isRequired() { // assumed to be required if not set explicit to false - return required == null || required; + return required == null || !"false".equalsIgnoreCase(required); } public String getName() { @@ -68,11 +75,11 @@ public class RouteTemplateParameterDefinition { this.name = name; } - public Boolean getRequired() { + public String getRequired() { return required; } - public void setRequired(Boolean required) { + public void setRequired(String required) { this.required = required; } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/ParamDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/ParamDefinition.java index 68607024c1b6..54c8f04eb85b 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/ParamDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/ParamDefinition.java @@ -59,9 +59,9 @@ public class ParamDefinition { @Metadata(description = "The default value of the parameter.") private String defaultValue; @XmlAttribute - @Metadata(description = "Sets the parameter required flag.", + @Metadata(description = "Sets the parameter required flag.", javaType = "java.lang.Boolean", defaultValue = "true") - private Boolean required; + private String required; @XmlAttribute @Metadata(description = "Sets the parameter collection format.", defaultValue = "csv") @@ -125,11 +125,11 @@ public class ParamDefinition { this.defaultValue = defaultValue; } - public Boolean getRequired() { - return required != null ? required : true; + public String getRequired() { + return required != null ? required : "true"; } - public void setRequired(Boolean required) { + public void setRequired(String required) { this.required = required; } @@ -211,6 +211,16 @@ public class ParamDefinition { * Whether the parameter is required */ public ParamDefinition required(Boolean required) { + setRequired(required != null ? required.toString() : null); + return this; + } + + /** + * Whether the parameter is required. + * <p> + * The value can be a property placeholder, which is resolved when the route starts. + */ + public ParamDefinition required(String required) { setRequired(required); return this; } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java index d3e48812684d..5b3163f48d20 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java @@ -1201,7 +1201,7 @@ public class RestDefinition extends OptionalIdentifiedDefinition<RestDefinition> binding.addAllowedValue(name, parseText(camelContext, param.getAllowableValuesAsCommaString())); } // register which parameters are required - Boolean required = param.getRequired(); + Boolean required = parseBoolean(camelContext, param.getRequired()); if (required != null && required) { if (RestParamType.query == type) { binding.addRequiredQueryParameter(name); diff --git a/core/camel-core/src/test/java/org/apache/camel/builder/RouteTemplateTest.java b/core/camel-core/src/test/java/org/apache/camel/builder/RouteTemplateTest.java index 81454f8ee875..7291d9391c4b 100644 --- a/core/camel-core/src/test/java/org/apache/camel/builder/RouteTemplateTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/builder/RouteTemplateTest.java @@ -168,6 +168,47 @@ public class RouteTemplateTest extends ContextTestSupport { assertMockEndpointsSatisfied(); } + @Test + public void testRequiredTemplateParameterSupportsPropertyPlaceholder() { + // the required attribute is a String on the model so it can be a property placeholder, which is + // resolved when the route is created from the template. Before CAMEL-24694 the text was converted + // while loading, so Boolean.valueOf("{{barRequired}}") silently yielded false and turned a required + // parameter into an optional one. + context.getPropertiesComponent().addInitialProperty("barRequired", "true"); + + RouteTemplateDefinition routeTemplate = context.getRouteTemplateDefinition("myTemplate"); + routeTemplate.getTemplateParameters().get(1).setRequired("{{barRequired}}"); + + Map<String, Object> parameters = new HashMap<>(); + parameters.put("foo", "one"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> context.addRouteFromTemplate(null, "myTemplate", parameters), + "Should throw exception"); + + assertEquals("Route template myTemplate the following mandatory parameters must be provided: bar", e.getMessage()); + } + + @Test + public void testOptionalTemplateParameterSupportsPropertyPlaceholder() throws Exception { + context.getPropertiesComponent().addInitialProperty("barRequired", "false"); + + RouteTemplateDefinition routeTemplate = context.getRouteTemplateDefinition("myTemplate"); + routeTemplate.getTemplateParameters().get(1).setRequired("{{barRequired}}"); + + getMockEndpoint("mock:cheese").expectedBodiesReceived("Hello Cheese"); + + // bar is not required, so it may be supplied without being mandatory + Map<String, Object> parameters = new HashMap<>(); + parameters.put("foo", "one"); + parameters.put("bar", "cheese"); + context.addRouteFromTemplate("first", "myTemplate", parameters); + + template.sendBody("direct:one", "Hello Cheese"); + + assertMockEndpointsSatisfied(); + } + @Test public void testCreateRouteFromRouteTemplateMissingParameter() throws Exception { assertEquals(1, context.getRouteTemplateDefinitions().size()); diff --git a/core/camel-core/src/test/java/org/apache/camel/component/rest/FromRestGetTest.java b/core/camel-core/src/test/java/org/apache/camel/component/rest/FromRestGetTest.java index 4e56dd864737..85297a072d4f 100644 --- a/core/camel-core/src/test/java/org/apache/camel/component/rest/FromRestGetTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/component/rest/FromRestGetTest.java @@ -89,8 +89,8 @@ public class FromRestGetTest extends ContextTestSupport { assertEquals("header_count", rest.getVerbs().get(0).getParams().get(0).getName()); assertEquals("header_letter", rest.getVerbs().get(0).getParams().get(1).getName()); - assertEquals(Boolean.TRUE, rest.getVerbs().get(0).getParams().get(0).getRequired()); - assertEquals(Boolean.FALSE, rest.getVerbs().get(0).getParams().get(1).getRequired()); + assertEquals("true", rest.getVerbs().get(0).getParams().get(0).getRequired()); + assertEquals("false", rest.getVerbs().get(0).getParams().get(1).getRequired()); assertEquals("300", rest.getVerbs().get(0).getResponseMsgs().get(0).getCode()); assertEquals("rate", rest.getVerbs().get(0).getResponseMsgs().get(0).getHeaders().get(0).getName()); diff --git a/core/camel-java-io/src/generated/java/org/apache/camel/java/out/JavaDslModelWriter.java b/core/camel-java-io/src/generated/java/org/apache/camel/java/out/JavaDslModelWriter.java index 7c67c5722ef3..14d9a9bde751 100644 --- a/core/camel-java-io/src/generated/java/org/apache/camel/java/out/JavaDslModelWriter.java +++ b/core/camel-java-io/src/generated/java/org/apache/camel/java/out/JavaDslModelWriter.java @@ -2471,7 +2471,7 @@ public class JavaDslModelWriter extends JavaDslModelWriterSupport { protected void doWriteRouteTemplateParameterDefinition(StringBuilder sb, RouteTemplateParameterDefinition def) { doWriteAttribute(sb, "description", def.getDescription(), null); doWriteAttribute(sb, "name", def.getName(), null); - doWriteAttribute(sb, "required", toString(def.getRequired()), null); + doWriteAttribute(sb, "required", def.getRequired(), null); doWriteAttribute(sb, "defaultValue", def.getDefaultValue(), null); } protected void doWriteRouteTemplatesDefinition(StringBuilder sb, RouteTemplatesDefinition def) { @@ -3754,7 +3754,7 @@ public class JavaDslModelWriter extends JavaDslModelWriterSupport { doWriteAttribute(sb, "name", def.getName(), null); doWriteAttribute(sb, "type", toString(def.getType()), "path"); doWriteAttribute(sb, "defaultValue", def.getDefaultValue(), null); - doWriteAttribute(sb, "required", toString(def.getRequired()), "true"); + doWriteAttribute(sb, "required", def.getRequired(), "true"); doWriteAttribute(sb, "collectionFormat", toString(def.getCollectionFormat()), "csv"); doWriteAttribute(sb, "arrayType", def.getArrayType(), "string"); doWriteAttribute(sb, "dataType", def.getDataType(), "string"); diff --git a/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java b/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java index 95ba89355078..4912d1d8c09e 100644 --- a/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java +++ b/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java @@ -429,7 +429,7 @@ public abstract class JavaDslModelWriterSupport { private void writeTemplateParameter(StringBuilder sb, RouteTemplateParameterDefinition param) { boolean hasDefault = param.getDefaultValue() != null; boolean hasDescription = param.getDescription() != null; - boolean isOptional = Boolean.FALSE.equals(param.getRequired()); + boolean isOptional = "false".equalsIgnoreCase(param.getRequired()); if (isOptional && !hasDefault) { sb.append(NL).append(indent()).append(".templateOptionalParameter(").append(quote(param.getName())); diff --git a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/in/ModelParser.java b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/in/ModelParser.java index 009c61821024..9393502a94eb 100644 --- a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/in/ModelParser.java +++ b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/in/ModelParser.java @@ -1014,7 +1014,7 @@ public class ModelParser extends BaseParser { case "defaultValue": def.setDefaultValue(val); yield true; case "description": def.setDescription(val); yield true; case "name": def.setName(val); yield true; - case "required": def.setRequired(Boolean.valueOf(val)); yield true; + case "required": def.setRequired(val); yield true; default: yield false; }, noElementHandler(), noValueHandler()); } @@ -2531,7 +2531,7 @@ public class ModelParser extends BaseParser { case "defaultValue": def.setDefaultValue(val); yield true; case "description": def.setDescription(val); yield true; case "name": def.setName(val); yield true; - case "required": def.setRequired(Boolean.valueOf(val)); yield true; + case "required": def.setRequired(val); yield true; case "type": def.setType(RestParamType.valueOf(val)); yield true; default: yield false; }, (def, key) -> switch (key) { diff --git a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java index d9636653097c..4fd5f78262cb 100644 --- a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java +++ b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java @@ -1674,7 +1674,7 @@ public class ModelWriter extends BaseWriter { startElement(name); doWriteAttribute("description", def.getDescription(), null); doWriteAttribute("name", def.getName(), null); - doWriteAttribute("required", toString(def.getRequired()), null); + doWriteAttribute("required", def.getRequired(), null); doWriteAttribute("defaultValue", def.getDefaultValue(), null); endElement(name); } @@ -3275,7 +3275,7 @@ public class ModelWriter extends BaseWriter { doWriteAttribute("name", def.getName(), null); doWriteAttribute("type", toString(def.getType()), "path"); doWriteAttribute("defaultValue", def.getDefaultValue(), null); - doWriteAttribute("required", toString(def.getRequired()), "true"); + doWriteAttribute("required", def.getRequired(), "true"); doWriteAttribute("collectionFormat", toString(def.getCollectionFormat()), "csv"); doWriteAttribute("arrayType", def.getArrayType(), "string"); doWriteAttribute("dataType", def.getDataType(), "string"); diff --git a/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java b/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java index 9a2fdee0b5e0..af8cd632617c 100644 --- a/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java +++ b/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java @@ -1670,7 +1670,7 @@ public class YamlModelWriter extends YamlModelWriterSupport { JsonObject jo = new JsonObject(); doWriteAttribute(jo, "description", def.getDescription(), null); doWriteAttribute(jo, "name", def.getName(), null); - doWriteAttribute(jo, "required", toString(def.getRequired()), null); + doWriteAttribute(jo, "required", def.getRequired(), null); doWriteAttribute(jo, "defaultValue", def.getDefaultValue(), null); return jo; } @@ -3273,7 +3273,7 @@ public class YamlModelWriter extends YamlModelWriterSupport { doWriteAttribute(jo, "name", def.getName(), null); doWriteAttribute(jo, "type", toString(def.getType()), "path"); doWriteAttribute(jo, "defaultValue", def.getDefaultValue(), null); - doWriteAttribute(jo, "required", toString(def.getRequired()), "true"); + doWriteAttribute(jo, "required", def.getRequired(), "true"); doWriteAttribute(jo, "collectionFormat", toString(def.getCollectionFormat()), "csv"); doWriteAttribute(jo, "arrayType", def.getArrayType(), "string"); doWriteAttribute(jo, "dataType", def.getDataType(), "string"); diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 68aa3ebbb47f..21a6fd92ea74 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -1890,3 +1890,32 @@ Documents carrying an internal DTD subset still parse. To restore the previous behaviour and allow external entity resolution, set the new `allowExternalEntities` option to `true` on the data format or on the endpoint (`smooks:config.xml?allowExternalEntities=true`). + +=== camel-core - the required attribute on rest param and route template parameter is now a String + +`ParamDefinition.required` (the rest DSL `param`) and `RouteTemplateParameterDefinition.required` +(the `templateParameter` of a route template) are now declared as `String` instead of `Boolean`, the +same way nearly every other scalar attribute in the Camel model is declared. This allows a property +placeholder to be used, which is resolved when the route starts: + +[source,yaml] +---- +- route: + templateParameters: + - name: greeting + required: "{{myRequiredFlag}}" +---- + +Before this change the value was converted to a `Boolean` while the route was being loaded, so +`required: "{{myRequiredFlag}}"` silently evaluated to `false` instead of resolving the placeholder. + +The accessors changed accordingly: + +* `ParamDefinition.getRequired()` and `setRequired(...)` now use `String` instead of `Boolean`. +* `RouteTemplateParameterDefinition.getRequired()` and `setRequired(...)` now use `String` instead of + `Boolean`. `isRequired()` is unchanged and still returns `boolean`, but it does not resolve property + placeholders — use `CamelContextHelper.parseBoolean(camelContext, getRequired())` where a + `CamelContext` is available. + +The fluent builder `ParamDefinition.required(Boolean)` is unchanged, and a `required(String)` overload +was added for placeholders. Routes written in XML, YAML or the Java DSL do not need any change. diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java index afc9760da112..a39a89bf599b 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java @@ -11610,7 +11610,7 @@ public final class ModelDeserializers extends YamlDeserializerSupport { } case "required": { String val = asText(node); - target.setRequired(java.lang.Boolean.valueOf(val)); + target.setRequired(val); break; } case "type": { @@ -15771,7 +15771,7 @@ public final class ModelDeserializers extends YamlDeserializerSupport { } case "required": { String val = asText(node); - target.setRequired(java.lang.Boolean.valueOf(val)); + target.setRequired(val); break; } default: { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java index 5b10bcc80123..18885aed2e9f 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java @@ -117,6 +117,7 @@ public class YamlValidator { private List<Error> validate(JsonNode target) { var errors = filterOneOfNoise(new ArrayList<>(schema.validate(target))); + errors.removeIf(YamlValidator::isRuntimeAcceptedScalar); if (canonical) { checkOneOfCardinality(target, new NodePath(PathType.JSON_POINTER), errors); } @@ -336,6 +337,65 @@ public class YamlValidator { return groups; } + /** + * Whether the schema rejected a scalar that the Camel runtime accepts, in which case the error is dropped. + * <p> + * Camel's model declares nearly every scalar attribute as a {@code String} field carrying the real type in + * {@code @Metadata(javaType = ...)}, so that property placeholders can be used and the text is converted when the + * route starts. The generated schema keeps the real type because tooling (Kaoto forms, TUI completion, catalog + * docs) relies on it, which makes the schema stricter than the runtime in two ways: + * <ul> + * <li>a property placeholder at a typed attribute - the runtime resolves it before converting;</li> + * <li>a number or boolean at a string-typed attribute (e.g. a {@code duration}) - the runtime converts any scalar + * to text.</li> + * </ul> + * Quoted scalars that parse as the expected type are already handled by the registry's type-loose mode, see + * {@link #init()}. Everything else stays strict: unknown properties, structure, enums, and strings that do not + * parse as the expected type. + * <p> + * This assumes the runtime defers the conversion for every scalar attribute the schema exposes. The few model + * attributes that are still converted while deserializing (so a placeholder is never resolved for them) are not + * reachable from the schema today - see CAMEL-24696 before exposing one of them. + */ + static boolean isRuntimeAcceptedScalar(Error error) { + if (!"type".equals(error.getKeyword())) { + return false; + } + JsonNode instance = error.getInstanceNode(); + if (instance == null) { + return false; + } + if (instance.isTextual()) { + return hasPropertyPlaceholder(instance.asText()); + } + // the runtime converts any scalar to text, so a number or boolean is fine wherever a string is expected + return (instance.isNumber() || instance.isBoolean()) && isExpectedType(error, "string"); + } + + private static boolean hasPropertyPlaceholder(String text) { + int start = text.indexOf("{{"); + return start >= 0 && text.indexOf("}}", start + 2) > start; + } + + private static boolean isExpectedType(Error error, String type) { + JsonNode schemaNode = error.getSchemaNode(); + if (schemaNode == null) { + return false; + } + if (schemaNode.isTextual()) { + return type.equals(schemaNode.asText()); + } + // "type" may also be declared as an array of accepted types + if (schemaNode.isArray()) { + for (JsonNode t : schemaNode) { + if (type.equals(t.asText())) { + return true; + } + } + } + return false; + } + private static Error parseError(Exception e) { String msg = e.getClass().getName() + ": " + e.getMessage(); return Error.builder() @@ -349,7 +409,11 @@ public class YamlValidator { String location = canonical ? LOCATION_CANONICAL : LOCATION; var model = mapper.readTree(YamlValidator.class.getResourceAsStream(location)); var version = getSpecificationVersion(model).orElse(SpecificationVersion.DRAFT_4); - var config = SchemaRegistryConfig.builder().locale(Locale.ENGLISH).build(); + // typeLoose lets a quoted scalar that parses as the expected type validate (e.g. parallelProcessing: "true" + // at a boolean attribute). Camel's runtime accepts it because the model field is a String, so the schema + // would otherwise be stricter than the runtime. Values that do not parse (e.g. "yes please") are still + // rejected. See isRuntimeAcceptedScalar for the cases typeLoose does not cover. + var config = SchemaRegistryConfig.builder().locale(Locale.ENGLISH).typeLoose(true).build(); // Register "deprecated" as a known non-validation keyword to suppress warnings Dialect base = getBaseDialect(version); diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java new file mode 100644 index 000000000000..0be66e4b5ee3 --- /dev/null +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java @@ -0,0 +1,139 @@ +/* + * 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.dsl.yaml.validator; + +import java.util.List; + +import com.networknt.schema.Error; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * CAMEL-24694: the generated schema types scalar attributes from the model metadata, but the model fields are String + * and the runtime converts (and resolves property placeholders) when the route starts. The validator must therefore + * accept the string forms the runtime accepts, in both classic and canonical mode, while staying strict about values + * the runtime would genuinely reject. + */ +public class YamlValidatorScalarLeniencyTest { + + private static YamlValidator classic; + private static YamlValidator canonical; + + @BeforeAll + public static void setup() throws Exception { + classic = new YamlValidator(); + classic.init(); + canonical = new YamlValidator(true); + canonical.init(); + } + + @Test + public void testQuotedBooleanAccepted() { + assertAccepted("split", "parallelProcessing: \"true\""); + } + + @Test + public void testNativeBooleanStillAccepted() { + assertAccepted("split", "parallelProcessing: true"); + } + + @Test + public void testPlaceholderAtBooleanAccepted() { + assertAccepted("split", "parallelProcessing: \"{{myParallel}}\""); + } + + @Test + public void testQuotedIntegerAccepted() { + assertAccepted("split", "group: \"100\""); + } + + @Test + public void testPlaceholderAtIntegerAccepted() { + assertAccepted("split", "group: \"{{myGroup}}\""); + } + + @Test + public void testQuotedNumberAccepted() { + assertAccepted("split", "errorThreshold: \"0.5\""); + } + + @Test + public void testIntegerAtDurationAccepted() { + // timeout is a duration, which the schema emits as string - the runtime converts any scalar to text + assertAccepted("split", "timeout: 5000"); + } + + @Test + public void testPlaceholderAtDurationAccepted() { + assertAccepted("split", "timeout: \"{{myTimeout}}\""); + } + + @Test + public void testUnparsableStringAtBooleanStillRejected() { + assertRejected("split", "parallelProcessing: \"yes please\"", "boolean expected"); + } + + @Test + public void testUnparsableStringAtIntegerStillRejected() { + assertRejected("split", "group: \"a lot\"", "number expected"); + } + + @Test + public void testUnknownPropertyStillRejected() { + assertRejected("split", "cheese: true", "cheese"); + } + + private void assertAccepted(String eip, String attribute) { + for (YamlValidator validator : List.of(classic, canonical)) { + String mode = validator.isCanonical() ? "canonical" : "classic"; + assertThat(validate(validator, eip, attribute)) + .as("%s: '%s' is accepted by the runtime and must validate in %s mode", eip, attribute, mode) + .isEmpty(); + } + } + + private void assertRejected(String eip, String attribute, String expectedInMessage) { + for (YamlValidator validator : List.of(classic, canonical)) { + String mode = validator.isCanonical() ? "canonical" : "classic"; + assertThat(validate(validator, eip, attribute)) + .as("%s: '%s' must still be rejected in %s mode", eip, attribute, mode) + .isNotEmpty() + .anyMatch(e -> e.getMessage().contains(expectedInMessage)); + } + } + + private List<Error> validate(YamlValidator validator, String eip, String attribute) { + String yaml = """ + - route: + from: + uri: timer:tick + steps: + - %s: + %s + steps: + - log: + message: "${body}" + """.formatted(eip, attribute); + try { + return validator.validate(yaml); + } catch (Exception e) { + throw new AssertionError("Failed to validate:\n" + yaml, e); + } + } +} diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/KameletRoutesBuilderLoader.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/KameletRoutesBuilderLoader.java index 50b0aa75c6e6..d15bb4a257fc 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/KameletRoutesBuilderLoader.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/KameletRoutesBuilderLoader.java @@ -84,7 +84,7 @@ public class KameletRoutesBuilderLoader extends YamlRoutesBuilderLoaderSupport { RouteTemplateParameterDefinition rtpd = new RouteTemplateParameterDefinition(); rtpd.setName(key); rtpd.setDefaultValue(asText(def)); - rtpd.setRequired(required.contains(key)); + rtpd.setRequired(Boolean.toString(required.contains(key))); rtd.getTemplateParameters().add(rtpd); } }
