This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b078bbeaf9a0 CAMEL-24715: camel-yaml-dsl-validator - table-driven
hints; the camel-jbang checks require a catalog
b078bbeaf9a0 is described below
commit b078bbeaf9a01753b411175c68008f855e74a5b6
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 16 11:39:32 2026 +0200
CAMEL-24715: camel-yaml-dsl-validator - table-driven hints; the camel-jbang
checks require a catalog
The four hint methods of YamlValidator and compactNotationHint had grown
into
if chains with the Error.builder() boilerplate copied 22 times. They are now
five row tables (STEP, EXPRESSION, LIST, PROPERTY, COMPACT) in a new
package-private SchemaHints class, with one matcher that picks the first
matching row and builds the rewritten Error in one place. Behaviour-neutral:
every existing validator, camel-jbang-core, -mcp and -tui test that pins a
hint text passes unchanged; SchemaHintsTest pins the matcher itself.
YamlValidator loses ~400 lines.
In camel-jbang the write-time checks now require a catalog, as every other
check already did: the no-catalog fallback list in BeanRefChecks only ever
checked aggregationStrategy (its other entries were Camel 2/3 names), so it
is gone and the interface an option needs always comes from the EIP models.
camel validate yaml now passes its catalog, so the CLI checks the same
interfaces as the MCP and TUI tools; the TUI validates against the CLI's own
catalog when no integration is selected; camel_catalog_sample validates its
samples against the schema of the catalog version they come from through the
new validateYamlSchema(content, catalog). ChecksCatalogDriftTest
cross-checks
the remaining hardcoded lists (INVENTED_OPTIONS, EXCHANGE_PROPERTIES)
against
the catalog so they cannot drift silently.
Closes #26489
Co-authored-by: Claude <[email protected]>
---
.../dsl/jbang/core/commands/ai/BeanRefChecks.java | 43 +--
.../dsl/jbang/core/commands/ai/CatalogSamples.java | 2 +-
.../dsl/jbang/core/commands/ai/HeaderChecks.java | 2 +-
.../jbang/core/commands/ai/PropertiesChecks.java | 58 ++-
.../jbang/core/commands/ai/SourceValidator.java | 50 ++-
.../core/commands/ai/ChecksCatalogDriftTest.java | 109 ++++++
.../commands/ai/SourceValidatorBeanRefsTest.java | 69 ++--
.../commands/ai/SourceValidatorJavaXsltTest.java | 3 +-
.../commands/ai/SourceValidatorVersionTest.java | 4 +-
.../jbang/core/commands/tui/SourceEditAssist.java | 39 +-
.../commands/validate/YamlValidateCommand.java | 2 +-
.../camel/dsl/yaml/validator/SchemaHints.java | 412 ++++++++++++++++++++
.../camel/dsl/yaml/validator/YamlValidator.java | 425 +--------------------
.../camel/dsl/yaml/validator/SchemaHintsTest.java | 127 ++++++
14 files changed, 816 insertions(+), 529 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
index f67f9a3d6440..ad8d2ce43ac6 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
@@ -25,6 +25,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -47,11 +48,6 @@ final class BeanRefChecks {
static final Pattern BEAN_NAME_PATTERN =
Pattern.compile("^\\s*-?\\s*name:\\s*(\\S+)\\s*$");
- static final Pattern BEAN_REF_PATTERN = Pattern.compile(
-
"^\\s*-?\\s*(ref|aggregationStrategy|strategyRef|processorRef|loadBalancerRef|executorServiceRef|onPrepareRef"
- +
"|onRedeliveryRef|aggregationRepositoryRef|comparatorRef|bean|processor)"
- +
":\\s*(\\S+)\\s*$");
-
static final Pattern BEAN_TYPE_PATTERN =
Pattern.compile("^\\s*type:\\s*[\"']?#class:([\\w.$]+)");
/** The beans declared under {@code beans:} with a {@code #class:} type,
name to fully qualified class name. */
@@ -75,15 +71,6 @@ final class BeanRefChecks {
return types;
}
- /**
- * Without a catalog: the options whose bean must implement an interface,
and which (a subset of the EIP models).
- */
- static final Map<String, String> REQUIRED_TYPES = Map.of(
- "aggregationStrategy", "org.apache.camel.AggregationStrategy",
- "strategyRef", "org.apache.camel.AggregationStrategy",
- "processorRef", "org.apache.camel.Processor",
- "processor", "org.apache.camel.Processor");
-
private static final Map<CamelCatalog, Map<String, String>>
REQUIRED_TYPES_BY_CATALOG
= java.util.Collections.synchronizedMap(new
java.util.WeakHashMap<>());
private static final Map<CamelCatalog, Pattern> BEAN_REF_PATTERN_BY_CATALOG
@@ -93,12 +80,9 @@ final class BeanRefChecks {
* The interface the bean of an option must implement, from the EIP models
of the catalog: every object option whose
* javaType is a Camel interface (aggregationStrategy needs
org.apache.camel.AggregationStrategy,
* idempotentRepository needs org.apache.camel.spi.IdempotentRepository,
...), the same metadata the visual editors
- * use to offer the built-in beans. Without a catalog the static subset
above.
+ * use to offer the built-in beans.
*/
static String requiredType(CamelCatalog catalog, String option) {
- if (catalog == null) {
- return REQUIRED_TYPES.get(option);
- }
return requiredTypes(catalog).get(option);
}
@@ -122,16 +106,18 @@ final class BeanRefChecks {
});
}
- /** {@link #BEAN_REF_PATTERN} plus every option the catalog's EIP models
type with a Camel interface. */
+ /** The options whose value is a bean name whatever the catalog says: ref,
bean and the *Ref options. */
+ private static final List<String> REF_OPTIONS = List.of("ref",
"aggregationStrategy", "loadBalancerRef",
+ "executorServiceRef", "onPrepareRef", "onRedeliveryRef",
"aggregationRepositoryRef", "comparatorRef", "bean");
+
+ /**
+ * The lines that reference a bean: option: name, for {@link #REF_OPTIONS}
and every option the catalog's EIP models
+ * type with a Camel interface.
+ */
static Pattern beanRefPattern(CamelCatalog catalog) {
- if (catalog == null) {
- return BEAN_REF_PATTERN;
- }
return BEAN_REF_PATTERN_BY_CATALOG.computeIfAbsent(catalog, c -> {
- java.util.Set<String> names = new
java.util.TreeSet<>(requiredTypes(c).keySet());
- names.addAll(List.of("ref", "aggregationStrategy", "strategyRef",
"processorRef", "loadBalancerRef",
- "executorServiceRef", "onPrepareRef", "onRedeliveryRef",
"aggregationRepositoryRef", "comparatorRef",
- "bean", "processor"));
+ Set<String> names = new TreeSet<>(requiredTypes(c).keySet());
+ names.addAll(REF_OPTIONS);
return Pattern.compile("^\\s*-?\\s*(" + String.join("|", names) +
"):\\s*(\\S+)\\s*$");
});
}
@@ -173,11 +159,6 @@ final class BeanRefChecks {
*/
static final Pattern SIMPLE_BEAN_FUNCTION =
Pattern.compile("\\$\\{bean:([A-Za-z_][\\w-]*)");
- public static List<String> validateYamlBeanRefs(String content,
BeanDeclarations external) {
- return validateYamlBeanRefs(content, external, null);
- }
-
- /** As above, and with a catalog the messages about a strategy name the
built-in implementations. */
public static List<String> validateYamlBeanRefs(String content,
BeanDeclarations external, CamelCatalog catalog) {
List<String> msgs = new ArrayList<>();
if (content == null) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
index f6d0996e6f24..71c4e4f20cf0 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
@@ -172,7 +172,7 @@ public final class CatalogSamples {
if (!yaml.stripLeading().startsWith("- ")) {
continue;
}
- if (SourceValidator.validateCamelYaml(yaml,
null).isEmpty()) {
+ if (SourceValidator.validateYamlSchema(yaml,
catalog).isEmpty()) {
answer.add(Map.of("source", page + ".adoc (Camel " +
catalog.getCatalogVersion() + ")", "yaml", yaml));
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
index 23fd298f82d2..0d520c946ee4 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
@@ -58,7 +58,7 @@ final class HeaderChecks {
public static List<String> validateKnownHeaders(String content,
CamelCatalog catalog) {
List<String> msgs = new ArrayList<>();
- if (content == null || catalog == null) {
+ if (content == null) {
return msgs;
}
Set<String> known = new LinkedHashSet<>();
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
index 7781d6fda0fc..4ddc2265e610 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
@@ -61,41 +61,39 @@ final class PropertiesChecks {
return "logging.level.root=" + rl.group(1) + " also hides the
route's own log steps (they log at INFO under"
+ " the route file's name): keep root at INFO, or add
logging.level.<route-file-name>=INFO";
}
- if (catalog != null) {
- // camel.component.logger.level: the catalog skips a component it
does not know, camel run does not
- Matcher km = COMPONENT_KEY_PATTERN.matcher(line);
- if (km.find()) {
- String kind = km.group(1);
- String name = km.group(2);
- List<String> known = switch (kind) {
- case "component" -> catalog.findComponentNames();
- case "dataformat" -> catalog.findDataFormatNames();
- default -> catalog.findLanguageNames();
- };
- if (!known.contains(name)) {
- String closest = closestName(name, known);
- return name + " Unknown " + kind + (closest != null ? "
(did you mean " + closest + "?)" : "");
- }
+ // camel.component.logger.level: the catalog skips a component it does
not know, camel run does not
+ Matcher km = COMPONENT_KEY_PATTERN.matcher(line);
+ if (km.find()) {
+ String kind = km.group(1);
+ String name = km.group(2);
+ List<String> known = switch (kind) {
+ case "component" -> catalog.findComponentNames();
+ case "dataformat" -> catalog.findDataFormatNames();
+ default -> catalog.findLanguageNames();
+ };
+ if (!known.contains(name)) {
+ String closest = closestName(name, known);
+ return name + " Unknown " + kind + (closest != null ? "
(did you mean " + closest + "?)" : "");
}
- try {
- ConfigurationPropertiesValidationResult result =
catalog.validateConfigurationProperty(line);
- if (result.isAccepted()) {
- if (!result.isSuccess()) {
- String msg = result.summaryErrorMessage(false);
- if (msg != null) {
- msg = msg.trim();
- String hint = mainOptionHint(line, catalog);
- if (hint == null && km.reset().find() &&
"component".equals(km.group(1))) {
- hint = endpointOptionHint(km.group(2), line,
catalog);
- }
- return hint != null ? msg + " " + hint : msg;
+ }
+ try {
+ ConfigurationPropertiesValidationResult result =
catalog.validateConfigurationProperty(line);
+ if (result.isAccepted()) {
+ if (!result.isSuccess()) {
+ String msg = result.summaryErrorMessage(false);
+ if (msg != null) {
+ msg = msg.trim();
+ String hint = mainOptionHint(line, catalog);
+ if (hint == null && km.reset().find() &&
"component".equals(km.group(1))) {
+ hint = endpointOptionHint(km.group(2), line,
catalog);
}
+ return hint != null ? msg + " " + hint : msg;
}
- return null;
}
- } catch (Exception e) {
- // ignore validation errors
+ return null;
}
+ } catch (Exception e) {
+ // ignore validation errors
}
return extra != null ? extra.apply(line) : null;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 939c3672a0a1..a596167f0bcc 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -25,6 +25,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
@@ -71,7 +72,8 @@ public final class SourceValidator {
*
* @param fileName the file name; its extension picks the checks
* @param content the source
- * @param catalog the catalog of the Camel version the source
is for
+ * @param catalog the catalog of the Camel version the source
is for (required: every check that is not
+ * the schema reads it)
* @param extraPropertyLine an extra check for a properties line the
catalog does not know (Spring Boot
* properties), returning the message or null;
may be null
* @return the messages, empty when the source is valid
@@ -99,6 +101,7 @@ public final class SourceValidator {
public static List<String> validate(
String fileName, String content, CamelCatalog catalog,
Function<String, String> extraPropertyLine,
Path directory, YamlValidator schemaValidator) {
+ Objects.requireNonNull(catalog, "catalog");
String name = fileName == null ? "" :
fileName.toLowerCase(Locale.ROOT);
if (name.endsWith(".yaml") || name.endsWith(".yml")) {
List<String> msgs = validateCamelYaml(content, catalog,
schemaValidator);
@@ -147,22 +150,44 @@ public final class SourceValidator {
if (content == null || content.isBlank()) {
return msgs;
}
+ if (validateYamlSchema(content, catalog, schemaValidator, msgs)) {
+ msgs.addAll(validateYamlCatalog(content, catalog));
+ }
+ return msgs;
+ }
+
+ /**
+ * The schema half of {@link #validateCamelYaml(String, CamelCatalog)}:
the YAML DSL schema of the catalog's Camel
+ * version, without the catalog checks. For a sample that is right for its
version but uses what the catalog cannot
+ * know (a custom step, a header a component sets at runtime).
+ */
+ public static List<String> validateYamlSchema(String content, CamelCatalog
catalog) {
+ Objects.requireNonNull(catalog, "catalog");
+ List<String> msgs = new ArrayList<>();
+ if (content != null && !content.isBlank()) {
+ validateYamlSchema(content, catalog, null, msgs);
+ }
+ return msgs;
+ }
+
+ /** Adds the schema errors to msgs; false when the YAML could not be
checked at all (no schema, not YAML). */
+ private static boolean validateYamlSchema(
+ String content, CamelCatalog catalog, YamlValidator
schemaValidator, List<String> msgs) {
YamlValidator validator;
try {
validator = schemaValidator != null ? schemaValidator :
yamlValidator(catalog);
} catch (Exception e) {
msgs.add("Cannot validate against the YAML DSL schema of Camel " +
catalog.getCatalogVersion() + ": "
+ e.getMessage());
- return msgs;
+ return false;
}
try {
msgs.addAll(formatSchemaErrors(validator.validate(content)));
+ return true;
} catch (Exception e) {
msgs.add("Invalid YAML: " + e.getMessage());
- return msgs;
+ return false;
}
- msgs.addAll(validateYamlCatalog(content, catalog));
- return msgs;
}
/**
@@ -170,7 +195,7 @@ public final class SourceValidator {
* for the schema of the version the catalog was loaded for (CAMEL-24711),
built once per version.
*/
static YamlValidator yamlValidator(CamelCatalog catalog) throws Exception {
- String version = catalog != null ? catalog.getCatalogVersion() : null;
+ String version = catalog.getCatalogVersion();
if (version == null || version.equals(BUILTIN_VERSION)) {
return yamlValidator();
}
@@ -198,7 +223,7 @@ public final class SourceValidator {
*/
public static List<String> validateYamlCatalog(String content,
CamelCatalog catalog) {
List<String> msgs = new ArrayList<>();
- if (content == null || content.isBlank() || catalog == null) {
+ if (content == null || content.isBlank()) {
return msgs;
}
msgs.addAll(validateYamlEndpoints(content, catalog));
@@ -207,7 +232,7 @@ public final class SourceValidator {
return msgs;
}
- private static YamlValidator yamlValidator() throws Exception {
+ static YamlValidator yamlValidator() throws Exception {
YamlValidator v = yamlValidator;
if (v == null) {
synchronized (SourceValidator.class) {
@@ -394,11 +419,10 @@ public final class SourceValidator {
return BeanRefChecks.declaredBeans(content);
}
- public static List<String> validateYamlBeanRefs(String content,
BeanDeclarations external) {
- return BeanRefChecks.validateYamlBeanRefs(content, external);
- }
-
- /** As above, and with a catalog the messages about a strategy name the
built-in implementations. */
+ /**
+ * Bean references in the YAML that nothing declares, each with how to
declare it; a bean whose option needs a Camel
+ * interface (an aggregationStrategy, an onPrepare processor...) must
implement it.
+ */
public static List<String> validateYamlBeanRefs(String content,
BeanDeclarations external, CamelCatalog catalog) {
return BeanRefChecks.validateYamlBeanRefs(content, external, catalog);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChecksCatalogDriftTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChecksCatalogDriftTest.java
new file mode 100644
index 000000000000..427e7a5d0908
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChecksCatalogDriftTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.jbang.core.commands.ai;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24715: the write-time checks carry a few lists the catalog has no
metadata for. Each states something about a
+ * component or an EIP that a later release can change; this cross-checks them
against the catalog and the API so they
+ * cannot drift silently. A failure names every entry that drifted.
+ */
+class ChecksCatalogDriftTest {
+
+ private static CamelCatalog catalog;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ /**
+ * The premise of every entry is that the component does not have that
option: the day it does, the hint tells the
+ * user to remove or rename an option that works.
+ */
+ @Test
+ void inventedOptionsAreNotComponentOptions() {
+ List<String> drifted = new ArrayList<>();
+ for (String key : new
TreeSet<>(EndpointChecks.INVENTED_OPTIONS.keySet())) {
+ String scheme = key.substring(0, key.indexOf(':'));
+ String option = key.substring(scheme.length() + 1);
+ ComponentModel model = catalog.componentModel(scheme);
+ if (model == null) {
+ drifted.add(key + ": no component " + scheme + " in the
catalog");
+ continue;
+ }
+ Set<String> options = new TreeSet<>();
+ model.getEndpointOptions().forEach(o -> options.add(o.getName()));
+ model.getComponentOptions().forEach(o -> options.add(o.getName()));
+ if (options.contains(option)) {
+ drifted.add(key + ": " + option + " is a real option of " +
scheme + " now, drop the entry");
+ }
+ }
+ assertThat(drifted).as("INVENTED_OPTIONS entries the catalog
contradicts").isEmpty();
+ }
+
+ /**
+ * The exchange properties of the timer are the Exchange.TIMER_* constants
the consumer sets; a name the catalog
+ * lists as a header of the component is a header, and the hint would send
the user to the wrong place.
+ */
+ @Test
+ void exchangePropertiesAreTheApiConstantsAndNotHeaders() throws Exception {
+ Set<String> timerConstants = new TreeSet<>();
+ for (Field f : Exchange.class.getFields()) {
+ if (f.getName().startsWith("TIMER_") &&
Modifier.isStatic(f.getModifiers()) && f.getType() == String.class) {
+ timerConstants.add((String) f.get(null));
+ }
+ }
+ assertThat(timerConstants).isNotEmpty();
+ List<String> drifted = new ArrayList<>();
+ for (Map.Entry<String, List<String>> entry :
HeaderChecks.EXCHANGE_PROPERTIES.entrySet()) {
+ String scheme = entry.getKey();
+ ComponentModel model = catalog.componentModel(scheme);
+ if (model == null) {
+ drifted.add(scheme + ": no such component in the catalog");
+ continue;
+ }
+ Set<String> headers = new TreeSet<>();
+ model.getEndpointHeaders().forEach(h -> headers.add(h.getName()));
+ for (String name : entry.getValue()) {
+ if ("timer".equals(scheme) && !timerConstants.contains(name)) {
+ drifted.add(scheme + ": " + name + " is not an
Exchange.TIMER_* constant");
+ }
+ if (headers.contains(name)) {
+ drifted.add(scheme + ": " + name + " is a header in the
catalog, not an exchange property");
+ }
+ }
+ }
+ assertThat(drifted).as("EXCHANGE_PROPERTIES entries the API or the
catalog contradicts").isEmpty();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
index cc12a96aab76..cb468d430b02 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
@@ -21,6 +21,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -31,6 +33,8 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
public class SourceValidatorBeanRefsTest {
+ private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
+
private static final String ROUTE = """
- route:
from:
@@ -53,7 +57,8 @@ public class SourceValidatorBeanRefsTest {
}
""");
List<String> msgs
- = SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"));
+ = SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0))
.startsWith("Line 9: aggregationStrategy: bean 'myAggregator'
is not declared")
@@ -68,14 +73,15 @@ public class SourceValidatorBeanRefsTest {
- name: myAggregator
type: "#class:com.example.MyAggregator"
""" + ROUTE;
- assertThat(SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+ assertThat(SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
Files.writeString(dir.resolve("beans.camel.yaml"), """
- beans:
- name: myAggregator
type: "#class:com.example.MyAggregator"
""");
- assertThat(SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+ assertThat(SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG))
.isEmpty();
Files.delete(dir.resolve("beans.camel.yaml"));
@@ -86,7 +92,8 @@ public class SourceValidatorBeanRefsTest {
public class MyAggregator {
}
""");
- assertThat(SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+ assertThat(SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG))
.isEmpty();
}
@@ -94,24 +101,23 @@ public class SourceValidatorBeanRefsTest {
void classReferencesPlaceholdersAndUnknownDirectoryAreLeftAlone(@TempDir
Path dir) {
String yaml = ROUTE.replace("aggregationStrategy: myAggregator",
"aggregationStrategy: \"#class:com.example.MyAggregator\"");
- assertThat(SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+ assertThat(SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
yaml = ROUTE.replace("aggregationStrategy: myAggregator",
"aggregationStrategy: \"{{strategy}}\"");
- assertThat(SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+ assertThat(SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
yaml = ROUTE.replace("aggregationStrategy: myAggregator",
"aggregationStrategy: com.example.MyAggregator");
- assertThat(SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+ assertThat(SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
// no directory given: the check is not run at all by validate(...)
- assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, null,
null)).isEmpty();
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null)).isEmpty();
}
@Test
void theRequiredInterfaceComesFromTheEipModels(@TempDir Path dir) throws
IOException {
- // idempotentRepository is not in the static subset; the catalog's EIP
model says what it needs
- org.apache.camel.catalog.CamelCatalog catalog = new
org.apache.camel.catalog.DefaultCamelCatalog();
- assertThat(BeanRefChecks.requiredType(catalog, "idempotentRepository"))
+ // the catalog's EIP model says what each option needs
+ assertThat(BeanRefChecks.requiredType(CATALOG, "idempotentRepository"))
.isEqualTo("org.apache.camel.spi.IdempotentRepository");
- assertThat(BeanRefChecks.requiredType(catalog, "aggregationStrategy"))
+ assertThat(BeanRefChecks.requiredType(CATALOG, "aggregationStrategy"))
.isEqualTo("org.apache.camel.AggregationStrategy");
- assertThat(BeanRefChecks.requiredType(catalog, "ref")).isNull();
+ assertThat(BeanRefChecks.requiredType(CATALOG, "ref")).isNull();
Files.writeString(dir.resolve("MyRepo.java"), """
package com.example;
public class MyRepo {
@@ -131,7 +137,7 @@ public class SourceValidatorBeanRefsTest {
- log: hi
""";
List<String> msgs = SourceValidator.validateYamlBeanRefs(yaml,
- SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
catalog);
+ SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
CATALOG);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0)).contains("com.example.MyRepo must implement
org.apache.camel.spi.IdempotentRepository")
.contains("the built-in ones are");
@@ -150,13 +156,15 @@ public class SourceValidatorBeanRefsTest {
type: "#class:com.example.MyAggregator"
""" + ROUTE;
List<String> msgs
- = SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"));
+ = SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0)).contains("com.example.MyAggregator must
implement org.apache.camel.AggregationStrategy");
String direct = ROUTE.replace("aggregationStrategy: myAggregator",
"aggregationStrategy: \"#class:com.example.MyAggregator\"");
- assertThat(SourceValidator.validateYamlBeanRefs(direct,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+ assertThat(SourceValidator.validateYamlBeanRefs(direct,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG))
.hasSize(1);
Files.writeString(dir.resolve("MyAggregator.java"), """
@@ -165,7 +173,8 @@ public class SourceValidatorBeanRefsTest {
public class MyAggregator implements AggregationStrategy {
}
""");
- assertThat(SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+ assertThat(SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG))
.isEmpty();
}
@@ -182,7 +191,8 @@ public class SourceValidatorBeanRefsTest {
- log: "${bean:counter?method=count}"
""";
List<String> msgs
- = SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"));
+ = SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG);
assertThat(msgs).hasSize(2);
assertThat(msgs.get(0))
.startsWith("Line 5: ${bean:counter}: bean 'counter' is not
declared: Counter.java is in the directory")
@@ -192,7 +202,8 @@ public class SourceValidatorBeanRefsTest {
- name: counter
type: "#class:com.example.Counter"
""" + yaml;
- assertThat(SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+ assertThat(SourceValidator.validateYamlBeanRefs(declared,
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+ CATALOG))
.isEmpty();
}
@@ -208,17 +219,17 @@ public class SourceValidatorBeanRefsTest {
- setBody:
simple: "${bean:counter:count}"
""";
- List<String> msgs = SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE);
+ List<String> msgs = SourceValidator.validateYamlBeanRefs(yaml,
SourceValidator.BeanDeclarations.NONE, CATALOG);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0)).contains("${bean:counter.count}").contains("${bean:counter?method=count}")
.contains("not with a single colon");
assertThat(SourceValidator.validateYamlBeanRefs(yaml.replace("counter:count",
"counter::count"),
- SourceValidator.BeanDeclarations.NONE)).isEmpty();
+ SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
}
@Test
void unknownBeanWithoutAJavaFileSaysHowToDeclareIt() {
- List<String> msgs = SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.NONE);
+ List<String> msgs = SourceValidator.validateYamlBeanRefs(ROUTE,
SourceValidator.BeanDeclarations.NONE, CATALOG);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0)).contains("not declared in this file or its
directory").contains("- beans:");
}
@@ -240,7 +251,7 @@ public class SourceValidatorBeanRefsTest {
uri: velocity:missing.vm
- to:
uri: xslt:http://example.com/x.xsl
- """, null, null, dir);
+ """, CATALOG, null, dir);
assertThat(msgs).hasSize(3);
assertThat(msgs.get(0)).startsWith("Line 5: xslt: the file
stylesheets/customers-to-html.xsl does not exist")
.contains("write xslt:customers-to-html.xsl");
@@ -263,7 +274,7 @@ public class SourceValidatorBeanRefsTest {
- name: myAggregator
type: "#class:MyAggregator"
""" + ROUTE;
- assertThat(SourceValidator.validate("r.camel.yaml", declared, null,
null, dir)).isEmpty();
+ assertThat(SourceValidator.validate("r.camel.yaml", declared, CATALOG,
null, dir)).isEmpty();
Files.writeString(dir.resolve("MyAggregator.java"), """
public class MyAggregator {
@@ -277,7 +288,7 @@ public class SourceValidatorBeanRefsTest {
}
}
""");
- List<String> msgs = SourceValidator.validate("r.camel.yaml", declared,
null, null, dir);
+ List<String> msgs = SourceValidator.validate("r.camel.yaml", declared,
CATALOG, null, dir);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0)).contains("has 2 public
methods").contains("aggregationStrategyMethodName");
}
@@ -307,7 +318,7 @@ public class SourceValidatorBeanRefsTest {
ref: leakSimulator
method: addObjects
""";
- List<String> msgs = SourceValidator.validate("r.camel.yaml", route,
null, null, dir);
+ List<String> msgs = SourceValidator.validate("r.camel.yaml", route,
CATALOG, null, dir);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0)).startsWith("Line 8: bean leakSimulator has 2
public methods (addObjects, getLeakedObjectCount)")
.contains("add method: <name>");
@@ -332,7 +343,7 @@ public class SourceValidatorBeanRefsTest {
aggregationStrategy:
"#class:org.apache.camel.processor.aggregate.StringAggregationStrategy"
steps:
- log: "${body}"
- """, null, null, dir);
+ """, CATALOG, null, dir);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0))
.startsWith("Line 3: type: class
org.apache.camel.support.StringAggregationStrategy was not found")
@@ -356,7 +367,7 @@ public class SourceValidatorBeanRefsTest {
return new MemoryLeakSimulator().count();
}
}
- """, null, null, dir);
+ """, CATALOG, null, dir);
assertThat(msgs).isNotEmpty();
assertThat(msgs.get(0)).contains("cannot find symbol")
.contains("MemoryLeakSimulator is the class in
MemoryLeakSimulator.java next to this file")
@@ -374,7 +385,7 @@ public class SourceValidatorBeanRefsTest {
uri: timer:tick
steps:
- log: "a"
- """, null, null, dir);
+ """, CATALOG, null, dir);
assertThat(msgs).hasSize(1);
assertThat(msgs.get(0)).contains("is an inner class of
Sim").contains("Leak.java next to the route");
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
index 032407fe32cd..6c82698c8c2a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
import java.util.List;
+import org.apache.camel.catalog.DefaultCamelCatalog;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -214,7 +215,7 @@ public class SourceValidatorJavaXsltTest {
assertThat(SourceValidator.isValidatableFile("t.xsl")).isTrue();
assertThat(SourceValidator.isValidatableFile("in.xml")).isTrue();
assertThat(SourceValidator.isValidatableFile("README.md")).isFalse();
- assertThat(SourceValidator.validate("t.xsl", "<not-xslt/>", null,
null)).isNotEmpty();
+ assertThat(SourceValidator.validate("t.xsl", "<not-xslt/>", new
DefaultCamelCatalog(), null)).isNotEmpty();
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
index b105e6e0c8e4..687d6bbd0fcb 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
@@ -74,7 +74,7 @@ class SourceValidatorVersionTest {
@Test
void theBuiltInCatalogUsesTheBuiltInSchema() throws Exception {
YamlValidator v = SourceValidator.yamlValidator(new
DefaultCamelCatalog());
- assertThat(v).isSameAs(SourceValidator.yamlValidator(null));
+ assertThat(v).isSameAs(SourceValidator.yamlValidator());
}
@Test
@@ -85,7 +85,7 @@ class SourceValidatorVersionTest {
assertThat(older.getCatalogVersion()).isEqualTo("4.18.0");
YamlValidator v = SourceValidator.yamlValidator(older);
- assertThat(v).isNotSameAs(SourceValidator.yamlValidator(null));
+ assertThat(v).isNotSameAs(SourceValidator.yamlValidator());
assertThat(SourceValidator.yamlValidator(older)).as("one validator per
version").isSameAs(v);
// a route both versions accept validates; the schema read from the
4.18.0 jar is the one that answers
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
index 548950dcc4c2..247d49a96e00 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
@@ -34,6 +34,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.ai.SourceValidator;
import org.apache.camel.tooling.model.BaseOptionModel;
import org.apache.camel.tooling.model.ComponentModel;
@@ -53,6 +54,7 @@ final class SourceEditAssist {
private final MonitorContext ctx;
private final CatalogCache catalogCache = new CatalogCache();
+ private volatile CamelCatalog defaultCatalog;
private Path rootDir;
// ---- YAML DSL completion ----
@@ -118,6 +120,23 @@ final class SourceEditAssist {
return catalogCache.get(ctx.findSelectedIntegration());
}
+ /**
+ * The catalog the validation checks run against: the one of the selected
integration's Camel version, or the CLI's
+ * own when no integration is selected or that version's catalog could not
be loaded (the checks need one).
+ */
+ CamelCatalog validationCatalog() {
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ CamelCatalog fallback = defaultCatalog;
+ if (fallback == null) {
+ fallback = new DefaultCamelCatalog();
+ defaultCatalog = fallback;
+ }
+ catalog = fallback;
+ }
+ return catalog;
+ }
+
Map<Integer, List<SourceViewer.DocEntry>>
provideCamelQuickDocs(List<JsonObject> codeData) {
CamelCatalog catalog = getCatalog();
if (catalog == null || codeData.isEmpty()) {
@@ -1431,7 +1450,7 @@ final class SourceEditAssist {
}
String validatePropertyLine(String line) {
- return SourceValidator.validatePropertyLine(line, getCatalog(),
this::validateSpringBootPropertyLine);
+ return SourceValidator.validatePropertyLine(line, validationCatalog(),
this::validateSpringBootPropertyLine);
}
String validateSpringBootPropertyLine(String line) {
@@ -1478,12 +1497,12 @@ final class SourceEditAssist {
* empty when the source is valid.
*/
List<String> validateCamelYaml(String content) {
- return SourceValidator.validateCamelYaml(content, getCatalog());
+ return SourceValidator.validateCamelYaml(content, validationCatalog());
}
/** Validates a properties file (application.properties) line by line
against the catalog, as the editor does. */
List<String> validateProperties(String content) {
- return SourceValidator.validateProperties(content, getCatalog(),
this::validateSpringBootPropertyLine);
+ return SourceValidator.validateProperties(content,
validationCatalog(), this::validateSpringBootPropertyLine);
}
/**
@@ -1491,7 +1510,7 @@ final class SourceEditAssist {
* Camel and Spring Boot options for .properties files. Other file types
have no validation and yield no messages.
*/
List<String> validateSource(String fileName, String content) {
- return SourceValidator.validate(fileName, content, getCatalog(),
this::validateSpringBootPropertyLine);
+ return SourceValidator.validate(fileName, content,
validationCatalog(), this::validateSpringBootPropertyLine);
}
static boolean isValidatableFile(String fileName) {
@@ -1499,19 +1518,11 @@ final class SourceEditAssist {
}
List<String> validateYamlEndpoints(String content) {
- CamelCatalog catalog = getCatalog();
- if (catalog == null) {
- return List.of();
- }
- return SourceValidator.validateYamlEndpoints(content, catalog);
+ return SourceValidator.validateYamlEndpoints(content,
validationCatalog());
}
List<String> validateYamlSimple(String content) {
- CamelCatalog catalog = getCatalog();
- if (catalog == null) {
- return List.of();
- }
- return SourceValidator.validateYamlSimple(content, catalog);
+ return SourceValidator.validateYamlSimple(content,
validationCatalog());
}
static String extractEipFromLine(String trimmed) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
index 68d8112311c9..816c7b175ac5 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
@@ -85,7 +85,7 @@ public class YamlValidateCommand extends CamelCommand {
File parent = new
File(n).getAbsoluteFile().getParentFile();
var declared =
SourceValidator.BeanDeclarations.scan(parent != null ? parent.toPath() : null,
new File(n).getName());
- for (String msg :
SourceValidator.validateYamlBeanRefs(content, declared)) {
+ for (String msg :
SourceValidator.validateYamlBeanRefs(content, declared, camelCatalog)) {
report.add(catalogError(msg));
}
if (parent != null) {
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
new file mode 100644
index 000000000000..7e2c096c1cf6
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
@@ -0,0 +1,412 @@
+/*
+ * 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.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.networknt.schema.Error;
+
+/**
+ * The hints the validator adds to the schema library's errors so they say
what to write: one table per stage of the
+ * pipeline in {@link YamlValidator}, each row a keyword, a location pattern,
a condition and the text. The first row
+ * that matches an error rewrites it; an error no row matches is kept as is.
+ */
+final class SchemaHints {
+
+ /** What a row's condition and text see: the error and the pieces every
row would otherwise recompute. */
+ record Match(Error error, String location, String name, String message,
String unknown, YamlValidator validator) {
+
+ static Match of(Error error, YamlValidator validator) {
+ String location = String.valueOf(error.getInstanceLocation());
+ String message = error.getMessage();
+ return new Match(
+ error, location,
location.substring(location.lastIndexOf('/') + 1), message,
+ message != null &&
"additionalProperties".equals(error.getKeyword())
+ ? between(message, "property '", "'") : null,
+ validator);
+ }
+
+ /** The key before the last segment: the EIP a when/0 or doCatch/0
item belongs to. */
+ String parentName() {
+ String parent = location.substring(0, location.lastIndexOf('/'));
+ return parent.substring(parent.lastIndexOf('/') + 1);
+ }
+
+ boolean nameIsIndex() {
+ return name.matches("\\d+");
+ }
+
+ boolean locationEndsWith(String suffix) {
+ return location.endsWith(suffix);
+ }
+
+ String schemaLocation() {
+ return String.valueOf(error.getSchemaLocation());
+ }
+ }
+
+ /**
+ * A row of a table. The error's keyword and location select the row, the
condition refines it, and the text is
+ * either appended to the error's message in parentheses, or replaces it.
+ */
+ record Hint(String keyword, Pattern location, Predicate<Match> when,
Function<Match, String> text, String outKeyword,
+ String messageKey, boolean append) {
+
+ boolean matches(Match m) {
+ return keyword.equals(m.error().getKeyword())
+ && (location == null ||
location.matcher(m.location()).matches())
+ && when.test(m);
+ }
+
+ Error rewrite(Match m) {
+ String hint = text.apply(m);
+ // the message is not a MessageFormat pattern (it contains
braces), so pass it as the single argument
+ return Error.builder()
+ .keyword(outKeyword)
+ .instanceLocation(m.error().getInstanceLocation())
+ .messageKey(messageKey)
+ .format(new MessageFormat("{0}"))
+ .arguments(append ? m.message() + " (" + hint + ")" : hint)
+ .build();
+ }
+ }
+
+ /** A row that keeps the error's message and appends the hint in
parentheses. */
+ static Hint append(String keyword, String location, Predicate<Match> when,
Function<Match, String> text) {
+ return new Hint(keyword, location == null ? null :
Pattern.compile(location), when, text, keyword, keyword, true);
+ }
+
+ /** A row that replaces the error's message with the hint, reported under
the given keyword and message key. */
+ static Hint replace(
+ String keyword, String location, Predicate<Match> when,
Function<Match, String> text, String outKeyword,
+ String messageKey) {
+ return new Hint(
+ keyword, location == null ? null : Pattern.compile(location),
when, text, outKeyword, messageKey,
+ false);
+ }
+
+ /** An additionalProperties row: the unknown property name is what the
condition and the text are about. */
+ static Hint unknownProperty(String location, Predicate<Match> when,
Function<Match, String> text) {
+ return append("additionalProperties", location, m -> m.unknown() !=
null && when.test(m), text);
+ }
+
+ private static final Predicate<Match> ANY = m -> true;
+
+ /**
+ * Applies a table to the errors: the first matching row rewrites each
error. Two rewrites that say the same at the
+ * same location (the branches of an anyOf, once the hint no longer names
the branch) are reported once.
+ */
+ static List<Error> apply(List<Hint> table, List<Error> errors,
YamlValidator validator) {
+ List<Error> answer = new ArrayList<>(errors.size());
+ Set<String> seen = new LinkedHashSet<>();
+ for (Error error : errors) {
+ Error hinted = apply(table, error, validator);
+ if (hinted == error || seen.add(hinted.getInstanceLocation() + " "
+ hinted.getMessage())) {
+ answer.add(hinted);
+ }
+ }
+ return answer;
+ }
+
+ static Error apply(List<Hint> table, Error error, YamlValidator validator)
{
+ if (error.getMessage() == null) {
+ return error;
+ }
+ Match m = Match.of(error, validator);
+ for (Hint row : table) {
+ if (row.matches(m)) {
+ return row.rewrite(m);
+ }
+ }
+ return error;
+ }
+
+ //
-------------------------------------------------------------------------------------------------------------
+ // step hints
+ //
-------------------------------------------------------------------------------------------------------------
+
+ /**
+ * "must have at most 1 properties" at a step: a step holds one EIP, and
the second key is either an option that
+ * belongs under the EIP (indented one level more) or another step (its
own - item).
+ */
+ static final List<Hint> STEP = List.of(
+ // - beans:\n myBean: ... : the second key was meant to be inside
the first; it is not indented enough
+ append("maxProperties", "/\\d+", ANY,
+ m -> "a top-level entry is one key: - route:, - beans:, -
rest:...; the lines that belong to it"
+ + " must be indented under it, a second key at the
same level as the entry is read as a"
+ + " separate property"),
+ append("maxProperties", ".*/steps/\\d+", ANY,
+ m -> "a step is one EIP: an option of that EIP is indented
under its key, and the next EIP is its"
+ + " own - item"));
+
+ //
-------------------------------------------------------------------------------------------------------------
+ // expression hints
+ //
-------------------------------------------------------------------------------------------------------------
+
+ private static final String EXPRESSION_SUB_ELEMENT =
"ExpressionSubElementDefinition";
+
+ /**
+ * "string found, object expected" where the schema wants an expression:
says to write it as a language map,
+ * constant for a fixed value and simple for a dynamic one.
+ */
+ static final List<Hint> EXPRESSION = List.of(
+ replace("type", null,
+ m -> {
+ JsonNode instance = m.error().getInstanceNode();
+ return instance != null && instance.isValueNode()
+ &&
(m.schemaLocation().contains(EXPRESSION_SUB_ELEMENT)
+ ||
String.valueOf(m.error().getEvaluationPath()).contains(EXPRESSION_SUB_ELEMENT));
+ },
+ m -> {
+ String value = m.error().getInstanceNode().asText();
+ return String.format(
+ "a plain value (%s) found, an expression
expected: write %s: {constant: {expression: \"%s\"}} for a fixed value, or %s:
{simple: {expression: \"...\"}} for a dynamic one",
+ value, m.name(), value, m.name());
+ },
+ "type", "expression"));
+
+ //
-------------------------------------------------------------------------------------------------------------
+ // list hints
+ //
-------------------------------------------------------------------------------------------------------------
+
+ /**
+ * "object found, array expected" says what the schema wants, not how to
write it: a list, each item starting with
+ * "- ". At the root of the file it also names the entries (route, from,
beans, rest, onException).
+ */
+ static final List<Hint> LIST = List.of(
+ // script: {language: groovy, text: ...}: the language is the key
of the expression, not a property
+ append("type", ".*/language", m -> m.message().contains("object
expected"),
+ m -> "an expression is written with the language as the
key and its expression: property, e.g."
+ + " groovy: {expression: \"...\"}, simple:
{expression: \"...\"}, constant: {expression: \"...\"};"
+ + " the language: form is language: {language:
groovy, expression: \"...\"}"),
+ // message: {simple: "..."}: a string property that is already an
expression, or a plain option
+ append("type", null, m -> m.message().contains("object found,
string expected"),
+ m -> m.name() + " is a plain string"
+ + (m.locationEndsWith("/log/message")
+ ? " that is already a simple expression:
write message: \"... ${body} ...\""
+ : ": write " + m.name() + ": \"...\", not a
language map")),
+ // - onException: [ ... ]: the entry is a map; several handlers
are several - onException: items
+ append("type",
+
"/\\d+/(onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|errorHandler|route|rest|restConfiguration)",
+ m -> m.message().contains("array found, object expected"),
+ m -> m.name() + " is a map, not a list: - " + m.name() +
": followed by its properties indented"
+ + (m.name().equals("onException")
+ ? " (exception: [java.lang.Exception],
handled: {constant: {expression: \"true\"}},"
+ + " steps: [...])"
+ : "")
+ + "; several of them are several - " + m.name() + ":
items"),
+ // otherwise: [- log: ...]: the block is a map whose steps: holds
the list
+ append("type", ".*/(otherwise|doTry|doFinally|doCatch/\\d+)", m ->
m.message().contains("object expected"),
+ m -> {
+ String eip = m.nameIsIndex() ? "doCatch" : m.name();
+ return eip + " holds its EIPs under steps: " + eip +
": {steps: [- log: \"...\"]}"
+ + (eip.equals("doCatch") ? ", each - doCatch:
item with exception: and steps:" : "");
+ }),
+ append("type", "/?", m -> m.message().contains("array expected"),
+ m -> "a Camel YAML file is a list of entries, each
starting with \"- \": - route:, - from:, - beans:,"
+ + " - rest:, - onException:"),
+ append("type", ".*/beans", m -> m.message().contains("array
expected"),
+ m -> "beans is a list: - name: myBean followed by type:
\"#class:com.example.MyBean\" (indented"
+ + " under the -)"),
+ append("type", ".*/(steps|when|get|post|exception|doCatch)", m ->
m.message().contains("array expected"),
+ m -> m.name() + " is a list: each item starts with \"-
\""),
+ append("type", null, m -> m.message().contains("array expected"),
+ m -> "write it as a list: each item starts with \"- \""));
+
+ //
-------------------------------------------------------------------------------------------------------------
+ // property hints
+ //
-------------------------------------------------------------------------------------------------------------
+
+ /** The options of the log component: written on the log EIP, they are
reported as such. */
+ private static final Set<String> LOG_COMPONENT_OPTIONS = Set.of(
+ "showAll", "showBody", "showBodyType", "showHeaders",
"showExchangePattern", "showProperties",
+ "showAllProperties", "showVariables", "showExchangeId",
"showException", "showCaughtException",
+ "showStackTrace", "showStreams", "showFiles", "showFuture",
"showRouteId", "showRouteGroup", "multiline",
+ "maxChars", "skipBodyLineSeparator", "groupSize", "groupInterval",
"groupDelay", "groupActiveOnly",
+ "level", "plain", "sourceLocationLoggerName", "style");
+
+ private static final Pattern CLASS_NAME =
Pattern.compile("([a-z][\\w]*\\.)+[A-Z]\\w*");
+
+ /**
+ * Adds a hint to "property 'x' is not defined in the schema": the closest
property name of that node (did you mean
+ * 'logName'?), or, when the property is a top-level entry such as
onException written inside a route, where it goes
+ * instead.
+ */
+ static final List<Hint> PROPERTY = List.of(
+ append("required", ".*/route/from", m ->
m.message().contains("required property 'steps' not found"),
+ m -> "steps: is a property of from:, next to uri:; a
steps: written at the route level must be"
+ + " indented under from:"),
+ // - id: myBean / class: ... : the bean properties are name and
type
+ unknownProperty("/\\d+/beans/\\d+", m -> Set.of("id", "ref",
"class").contains(m.unknown()),
+ m -> "a bean is - name: myBean followed by type:
\"#class:com.example.MyBean\" (name instead of "
+ + m.unknown() + (m.unknown().equals("class") ? ",
type instead of class" : "") + ")"),
+ // - myBean: {type: ...} instead of - name: myBean / type: ...
+ unknownProperty("/\\d+/beans/\\d+", ANY,
+ m -> "a bean item is written as - name: " + m.unknown()
+ + " followed by type: \"#class:com.example.MyBean\"
(the name is a property, not the key)"),
+ unknownProperty(null,
+ m -> m.validator().topLevelEntries().contains(m.unknown())
+ && m.location().chars().filter(c -> c ==
'/').count() >= 2,
+ m -> "'" + m.unknown() + "' is a top-level entry: write it
as a list item at the same level as the"
+ + " route, not inside it"),
+ // onException: {java.lang.Exception: ...}: the class is a list
item under exception:
+ unknownProperty(".*/(onException|doCatch/\\d+)", m ->
CLASS_NAME.matcher(m.unknown()).matches(),
+ m -> "the exception class is a list item under exception:
("
+ + (m.locationEndsWith("/onException") ? "onException"
: "doCatch") + ": {exception: ["
+ + m.unknown() + "], steps: [...]})"),
+ unknownProperty(".*/circuitBreaker", m ->
m.unknown().equals("name"),
+ m -> "the circuit breaker's name is its id:
circuitBreaker: {id: myBreaker, ...}"),
+ // circuitBreaker: {failureThreshold: 5}: the thresholds and
timeouts are resilience4j configuration
+ unknownProperty(".*/circuitBreaker", m -> {
+ Set<String> resilience = m.validator().resilienceProperties();
+ String lower = m.unknown().toLowerCase(Locale.ROOT);
+ return !resilience.isEmpty()
+ && (resilience.contains(m.unknown()) ||
YamlValidator.closest(m.unknown(), resilience) != null
+ || lower.contains("threshold") ||
lower.contains("timeout"));
+ }, m -> {
+ Set<String> resilience = m.validator().resilienceProperties();
+ String best = resilience.contains(m.unknown()) ? m.unknown() :
YamlValidator.closest(m.unknown(), resilience);
+ return "the thresholds, timeouts and the like are written
under resilience4jConfiguration:"
+ + " (circuitBreaker: {resilience4jConfiguration: {" +
(best != null ? best : "failureRateThreshold")
+ + ": ...}, steps: [...], onFallback: {steps: [...]}})";
+ }),
+ unknownProperty(".*/log", m -> m.unknown().equals("level") ||
m.unknown().equals("logLevel"),
+ m -> "did you mean 'loggingLevel'?"),
+ // log: {message: ..., showHeaders: true}: those are options of
the log component endpoint
+ unknownProperty(".*/log", m ->
LOG_COMPONENT_OPTIONS.contains(m.unknown()),
+ m -> "'" + m.unknown() + "' is an option of the log
component, not of the log EIP: write a to: step"
+ + " with uri: \"log:com.example?" + m.unknown() +
"=...\" (the log EIP has message,"
+ + " loggingLevel, logName, marker)"),
+ // - route: {from: {uri: ...}, steps: [...]}: steps belongs under
from:
+ unknownProperty(".*/route", m -> m.unknown().equals("steps"),
+ m -> "steps: goes under from:, indented at the same level
as uri: (route: {from: {uri: ...,"
+ + " steps: [...]}})"),
+ // otherwise: {log: ...} or when: [- simple: ..., log: ...]: the
EIPs go under steps:
+
unknownProperty(".*/(otherwise|when/\\d+|doTry|doCatch/\\d+|doFinally|split|filter|loop|aggregate"
+ +
"|circuitBreaker|onFallback|multicast|pipeline|saga|resequence|throttle|delay"
+ +
"|onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|route|from)",
+ m -> m.validator().stepNames().contains(m.unknown()),
+ m -> {
+ String eip = m.nameIsIndex() ? m.parentName() :
m.name();
+ return "'" + m.unknown() + "' is a step: the steps of
" + eip + " go under steps: (" + eip
+ + ": {steps: [- " + m.unknown() + ": ...]})";
+ }),
+ // setBody: {script: ...}: script is an EIP; the language is the
key of an expression
+ unknownProperty(null,
+ m -> m.unknown().equals("script") &&
!m.locationEndsWith("/steps")
+ &&
(YamlValidator.EXPRESSION_REQUIRED.contains(m.name()) ||
m.locationEndsWith("/expression")),
+ m -> "script is an EIP step, not a language: write the
language as the key of the expression"
+ + " (expression: {groovy: {expression: \"...\"}},
expression: {simple: {expression: \"...\"}}),"
+ + " or run a script as its own step with - script:
{expression: {groovy: {expression: \"...\"}}}"),
+ // setBody: {bean: myBean} : the bean language is method:
+ unknownProperty(null, m -> m.unknown().equals("bean") &&
!m.locationEndsWith("/steps"),
+ m -> "the bean language is written as method: (expression:
{method: {ref: myBean, method:"
+ + " process}}), or call the bean as a step with -
bean: {ref: myBean, method: process}"),
+ // setHeader: {CamelNumberA: {simple: ...}} : the name is a
property, not the key
+
unknownProperty(".*/(setHeader|setProperty|setVariable|removeHeader|removeProperty|removeVariable)",
+ m -> YamlValidator.closest(m.unknown(),
m.validator().knownProperties(m.schemaLocation())) == null,
+ m -> "the name is a property: " + m.name() + ": {name: " +
m.unknown()
+ + (m.name().startsWith("set") ? ", expression:
{simple: {expression: \"...\"}}}" : "}")
+ + " (" + m.unknown() + " is not the key)"),
+ unknownProperty(".*/bean", m -> Set.of("parameters", "args",
"arguments").contains(m.unknown()),
+ m -> "arguments are written in the method call: bean:
{ref: myBean, method: \"process(${body},"
+ + " 'x')\"}"),
+ unknownProperty(null,
+ m -> YamlValidator.closest(m.unknown(),
m.validator().knownProperties(m.schemaLocation())) != null,
+ m -> "did you mean '"
+ + YamlValidator.closest(m.unknown(),
m.validator().knownProperties(m.schemaLocation())) + "'?"));
+
+ //
-------------------------------------------------------------------------------------------------------------
+ // compact notation hints (canonical schema only)
+ //
-------------------------------------------------------------------------------------------------------------
+
+ /** The property a step written as a string sets: the argument of the
definition's String constructor. */
+ private static final Map<String, String> STRING_STEP_PROPERTY =
Map.ofEntries(
+ Map.entry("bean", "ref"), Map.entry("convertBodyTo", "type"),
Map.entry("log", "message"),
+ Map.entry("poll", "uri"), Map.entry("removeHeader", "name"),
Map.entry("removeHeaders", "pattern"),
+ Map.entry("removeProperties", "pattern"),
Map.entry("removeProperty", "name"),
+ Map.entry("removeVariable", "name"), Map.entry("rollback",
"message"),
+ Map.entry("setExchangePattern", "pattern"), Map.entry("to",
"uri"), Map.entry("toD", "uri"));
+
+ private static final String NORMALIZE_HINT = "; camel validate normalize
rewrites a file in the canonical format";
+
+ private static final String COMPACT_NOTATION = "compactNotation";
+
+ /**
+ * The canonical schema rejects the compact notation as a schema error
that says nothing about it: "property
+ * 'simple' is not defined" for a language key directly on the EIP,
"string found, object expected" for a step or a
+ * language written as a string. Each is replaced with a message that
names the notation, the canonical form of that
+ * line, and the normalize command.
+ */
+ static final List<Hint> COMPACT = List.of(
+ // setBody: {simple: ...} or when: [- simple: ...]: the language
key sits on the EIP, not under expression:
+ replace("additionalProperties", null,
+ m -> m.unknown() != null &&
m.validator().languageKeys().contains(m.unknown()),
+ m -> {
+ String form = m.validator().languageForm(m.unknown());
+ if (m.nameIsIndex()) {
+ return "a " + m.parentName() + " item with " +
m.unknown() + ": ... is the deprecated compact"
+ + " notation: an expression is written
under expression: (- expression: {"
+ + m.unknown() + ": {" + form + "}})" +
NORMALIZE_HINT;
+ }
+ return m.name() + ": {" + m.unknown() + ": ...} is the
deprecated compact notation: an"
+ + " expression is written under expression: ("
+ m.name() + ": {expression: {"
+ + m.unknown() + ": {" + form + "}}})" +
NORMALIZE_HINT;
+ },
+ COMPACT_NOTATION, COMPACT_NOTATION),
+ // simple: "..." : the language is a map with its expression
+ replace("type", null,
+ m -> m.message().contains("string found, object expected")
+ && m.validator().languageKeys().contains(m.name()),
+ m -> m.name() + ": \"...\" is the deprecated compact
notation: write " + m.name() + ": {"
+ + m.validator().languageForm(m.name()) + "}" +
NORMALIZE_HINT,
+ COMPACT_NOTATION, COMPACT_NOTATION),
+ // log: "..." : the step is a map with its properties
+ replace("type", null,
+ m -> m.message().contains("string found, object expected")
+ && (m.validator().stepNames().contains(m.name())
+ ||
m.validator().topLevelEntries().contains(m.name())),
+ m -> {
+ String property = STRING_STEP_PROPERTY.get(m.name());
+ return m.name() + ": \"...\" is the deprecated compact
notation: write " + m.name()
+ + (property != null ? ": {" + property + ":
\"...\"}" : " as a map with its properties")
+ + NORMALIZE_HINT;
+ },
+ COMPACT_NOTATION, COMPACT_NOTATION));
+
+ static String between(String text, String start, String end) {
+ int i = text.indexOf(start);
+ if (i < 0) {
+ return null;
+ }
+ int j = text.indexOf(end, i + start.length());
+ return j < 0 ? null : text.substring(i + start.length(), j);
+ }
+
+ private SchemaHints() {
+ }
+}
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 9a050d5ad4c0..148be7445cf9 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
@@ -285,12 +285,12 @@ public class YamlValidator {
var errors = filterOneOfNoise(new
ArrayList<>(schema.validate(target)));
errors.removeIf(YamlValidator::isRuntimeAcceptedScalar);
if (canonical) {
- errors = withCompactNotationHints(errors);
+ errors = SchemaHints.apply(SchemaHints.COMPACT, errors, this);
}
- errors = withExpressionHints(errors);
- errors = withPropertyHints(errors);
- errors = withListHints(errors);
- errors = withStepHints(errors);
+ errors = SchemaHints.apply(SchemaHints.EXPRESSION, errors, this);
+ errors = SchemaHints.apply(SchemaHints.PROPERTY, errors, this);
+ errors = SchemaHints.apply(SchemaHints.LIST, errors, this);
+ errors = SchemaHints.apply(SchemaHints.STEP, errors, this);
// CAMEL-24707: the schema requires the expression, so a node without
one fails its oneOf with "0 are valid"
// plus one "required property <language> not found" per language;
replace that with one line that says
// what to write, at the node's own location
@@ -323,82 +323,8 @@ public class YamlValidator {
return errors;
}
- /** The property a step written as a string sets: the argument of the
definition's String constructor. */
- private static final Map<String, String> STRING_STEP_PROPERTY =
Map.ofEntries(
- Map.entry("bean", "ref"), Map.entry("convertBodyTo", "type"),
Map.entry("log", "message"),
- Map.entry("poll", "uri"), Map.entry("removeHeader", "name"),
Map.entry("removeHeaders", "pattern"),
- Map.entry("removeProperties", "pattern"),
Map.entry("removeProperty", "name"),
- Map.entry("removeVariable", "name"), Map.entry("rollback",
"message"),
- Map.entry("setExchangePattern", "pattern"), Map.entry("to",
"uri"), Map.entry("toD", "uri"));
-
- private static final String NORMALIZE_HINT = "; camel validate normalize
rewrites a file in the canonical format";
-
- /**
- * The canonical schema rejects the compact notation as a schema error
that says nothing about it: "property
- * 'simple' is not defined" for a language key directly on the EIP,
"string found, object expected" for a step or a
- * language written as a string. Each is replaced with a message that
names the notation, the canonical form of that
- * line, and the normalize command.
- */
- List<Error> withCompactNotationHints(List<Error> errors) {
- List<Error> answer = new ArrayList<>(errors.size());
- for (Error error : errors) {
- String hint = compactNotationHint(error);
- if (hint == null) {
- answer.add(error);
- continue;
- }
- answer.add(Error.builder()
- .keyword("compactNotation")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("compactNotation")
- .format(new MessageFormat("{0}"))
- .arguments(hint + NORMALIZE_HINT)
- .build());
- }
- return answer;
- }
-
- private String compactNotationHint(Error error) {
- String message = error.getMessage();
- if (message == null) {
- return null;
- }
- String location = String.valueOf(error.getInstanceLocation());
- String name = location.substring(location.lastIndexOf('/') + 1);
- if ("additionalProperties".equals(error.getKeyword())) {
- // setBody: {simple: ...} or when: [- simple: ...]: the language
key sits on the EIP, not under expression:
- String unknown = between(message, "property '", "'");
- if (unknown == null || !languageKeys.contains(unknown)) {
- return null;
- }
- if (name.matches("\\d+")) {
- String parent = location.substring(0,
location.lastIndexOf('/'));
- name = parent.substring(parent.lastIndexOf('/') + 1);
- return "a " + name + " item with " + unknown + ": ... is the
deprecated compact notation: an expression"
- + " is written under expression: (- expression: {" +
unknown + ": {" + languageForm(unknown)
- + "}})";
- }
- return name + ": {" + unknown + ": ...} is the deprecated compact
notation: an expression is written under"
- + " expression: (" + name + ": {expression: {" + unknown +
": {" + languageForm(unknown) + "}}})";
- }
- if ("type".equals(error.getKeyword()) && message.contains("string
found, object expected")) {
- if (languageKeys.contains(name)) {
- // simple: "..." : the language is a map with its expression
- return name + ": \"...\" is the deprecated compact notation:
write " + name + ": {" + languageForm(name)
- + "}";
- }
- if (stepNames.contains(name) || topLevelEntries.contains(name)) {
- // log: "..." : the step is a map with its properties
- String property = STRING_STEP_PROPERTY.get(name);
- return name + ": \"...\" is the deprecated compact notation:
write " + name
- + (property != null ? ": {" + property + ": \"...\"}" :
" as a map with its properties");
- }
- }
- return null;
- }
-
/** The canonical body of a language: its expression property, or token
for tokenize, as key: "...". */
- private String languageForm(String language) {
+ String languageForm(String language) {
JsonNode ref =
model.at("/items/definitions/org.apache.camel.model.language.ExpressionDefinition/properties/"
+ language + "/$ref");
JsonNode properties = ref.isTextual() ?
model.at(ref.asText().substring(1) + "/properties") : null;
@@ -407,56 +333,12 @@ public class YamlValidator {
return property + ": \"...\"";
}
- /**
- * "must have at most 1 properties" at a step: a step holds one EIP, and
the second key is either an option that
- * belongs under the EIP (indented one level more) or another step (its
own - item).
- */
- static List<Error> withStepHints(List<Error> errors) {
- List<Error> answer = new ArrayList<>(errors.size());
- for (Error error : errors) {
- String location = String.valueOf(error.getInstanceLocation());
- if ("maxProperties".equals(error.getKeyword()) &&
location.matches("/\\d+")) {
- // - beans:\n myBean: ... : the second key was meant to be
inside the first; it is not indented enough
- answer.add(Error.builder()
- .keyword("maxProperties")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("maxProperties")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (a top-level entry
is one key: - route:, - beans:, - rest:...;"
- + " the lines that belong to it must be
indented under it, a second key at the same"
- + " level as the entry is read as a
separate property)")
- .build());
- continue;
- }
- if ("maxProperties".equals(error.getKeyword()) &&
location.matches(".*/steps/\\d+")) {
- answer.add(Error.builder()
- .keyword("maxProperties")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("maxProperties")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (a step is one EIP:
an option of that EIP is indented under"
- + " its key, and the next EIP is its own -
item)")
- .build());
- } else {
- answer.add(error);
- }
- }
- return answer;
- }
-
/**
* The EIPs whose expression the runtime needs: the schema leaves it
optional for every expression node, and a split
* written with only delimiter: "," (or a filter, setBody, when... with
only options) fails when the route is
* created with "Unsupported definition: null".
*/
- private static final Set<String> LOG_COMPONENT_OPTIONS = Set.of(
- "showAll", "showBody", "showBodyType", "showHeaders",
"showExchangePattern", "showProperties",
- "showAllProperties", "showVariables", "showExchangeId",
"showException", "showCaughtException",
- "showStackTrace", "showStreams", "showFiles", "showFuture",
"showRouteId", "showRouteGroup", "multiline",
- "maxChars", "skipBodyLineSeparator", "groupSize", "groupInterval",
"groupDelay", "groupActiveOnly",
- "level", "plain", "sourceLocationLoggerName", "style");
-
- private static final Set<String> EXPRESSION_REQUIRED = Set.of(
+ static final Set<String> EXPRESSION_REQUIRED = Set.of(
"split", "filter", "when", "setBody", "setHeader", "setProperty",
"setVariable", "transform", "loop",
"delay", "recipientList", "routingSlip", "dynamicRouter",
"validate", "script", "throttle", "resequence",
"idempotentConsumer");
@@ -860,290 +742,30 @@ public class YamlValidator {
return (instance.isNumber() || instance.isBoolean()) &&
isExpectedType(error, "string");
}
- /**
- * Replaces the schema's "boolean found, object expected" for a plain
value at an option that takes an expression
- * (such as {@code handled: true} on onException, or {@code
completionSizeExpression: 10} on aggregate) with a
- * message that shows the expression form, and drops the duplicates the
schema composition produces for it.
- */
- static List<Error> withExpressionHints(List<Error> errors) {
- List<Error> answer = new ArrayList<>(errors.size());
- Set<String> seen = new LinkedHashSet<>();
- for (Error error : errors) {
- Error hinted = withExpressionHint(error);
- if (hinted == error || seen.add(hinted.getInstanceLocation() + " "
+ hinted.getMessage())) {
- answer.add(hinted);
- }
- }
- return answer;
- }
-
- static Error withExpressionHint(Error error) {
- if (!"type".equals(error.getKeyword())) {
- return error;
- }
- JsonNode instance = error.getInstanceNode();
- if (instance == null || !instance.isValueNode()) {
- return error;
- }
- String schemaLocation = String.valueOf(error.getSchemaLocation());
- String evaluationPath = String.valueOf(error.getEvaluationPath());
- if (!schemaLocation.contains(EXPRESSION_SUB_ELEMENT) &&
!evaluationPath.contains(EXPRESSION_SUB_ELEMENT)) {
- return error;
- }
- String location = String.valueOf(error.getInstanceLocation());
- String name = location.substring(location.lastIndexOf('/') + 1);
- String value = instance.asText();
- String message = String.format(
- "a plain value (%s) found, an expression expected: write %s:
{constant: {expression: \"%s\"}} for a fixed value, or %s: {simple:
{expression: \"...\"}} for a dynamic one",
- value, name, value, name);
- // the message is not a MessageFormat pattern (it contains braces), so
pass it as the single argument
- return Error.builder()
- .keyword("type")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("expression")
- .format(new MessageFormat("{0}"))
- .arguments(message)
- .build();
- }
-
- private static final String EXPRESSION_SUB_ELEMENT =
"ExpressionSubElementDefinition";
-
private JsonNode model;
private Set<String> topLevelEntries = Set.of();
private Set<String> languageKeys = Set.of();
private Set<String> stepNames = Set.of();
private Set<String> resilienceProperties = Set.of();
- /**
- * "object found, array expected" says what the schema wants, not how to
write it: a list, each item starting with
- * "- ". At the root of the file it also names the entries (route, from,
beans, rest, onException).
- */
- static List<Error> withListHints(List<Error> errors) {
- List<Error> answer = new ArrayList<>(errors.size());
- for (Error error : errors) {
- answer.add(withListHint(error));
- }
- return answer;
+ /** The keys of the file's entries (route, from, beans, rest,
onException...), from the schema. */
+ Set<String> topLevelEntries() {
+ return topLevelEntries;
}
- static Error withListHint(Error error) {
- if (!"type".equals(error.getKeyword()) || error.getMessage() == null) {
- return error;
- }
- String location = String.valueOf(error.getInstanceLocation());
- if (location.endsWith("/language") &&
error.getMessage().contains("object expected")) {
- // script: {language: groovy, text: ...}: the language is the key
of the expression, not a property
- return Error.builder()
- .keyword("type")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("type")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (an expression is
written with the language as the key and its"
- + " expression: property, e.g. groovy:
{expression: \"...\"}, simple: {expression: \"...\"},"
- + " constant: {expression: \"...\"}; the
language: form is"
- + " language: {language: groovy, expression:
\"...\"})")
- .build();
- }
- if (error.getMessage().contains("object found, string expected")) {
- // message: {simple: "..."}: a string property that is already an
expression, or a plain option
- String prop = location.substring(location.lastIndexOf('/') + 1);
- return Error.builder()
- .keyword("type")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("type")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (" + prop + " is a plain
string"
- + (location.endsWith("/log/message")
- ? " that is already a simple
expression: write message: \"... ${body} ...\""
- : ": write " + prop + ": \"...\", not a
language map")
- + ")")
- .build();
- }
- if (location.matches(
-
"/\\d+/(onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|errorHandler|route|rest|restConfiguration)")
- && error.getMessage().contains("array found, object
expected")) {
- // - onException: [ ... ]: the entry is a map; several handlers
are several - onException: items
- String entry = location.substring(location.lastIndexOf('/') + 1);
- return Error.builder()
- .keyword("type")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("type")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (" + entry + " is a map,
not a list: - " + entry + ": followed by its"
- + " properties indented" +
(entry.equals("onException")
- ? " (exception: [java.lang.Exception],
handled: {constant: {expression: \"true\"}},"
- + " steps: [...])"
- : "")
- + "; several of them are several - " + entry +
": items)")
- .build();
- }
- if (location.matches(".*/(otherwise|doTry|doFinally|doCatch/\\d+)") &&
error.getMessage().contains("object expected")) {
- // otherwise: [- log: ...]: the block is a map whose steps: holds
the list
- String eip = location.substring(location.lastIndexOf('/') + 1);
- if (eip.matches("\\d+")) {
- eip = "doCatch";
- }
- return Error.builder()
- .keyword("type")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("type")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (" + eip + " holds its
EIPs under steps: " + eip
- + ": {steps: [- log: \"...\"]}" +
(eip.equals("doCatch")
- ? ", each - doCatch: item with"
- + " exception: and steps:"
- : "")
- + ")")
- .build();
- }
- if (!error.getMessage().contains("array expected")) {
- return error;
- }
- String name = location.substring(location.lastIndexOf('/') + 1);
- String hint;
- if (location.isEmpty() || location.equals("/")) {
- hint = "a Camel YAML file is a list of entries, each starting with
\"- \": - route:, - from:, - beans:, - rest:,"
- + " - onException:";
- } else if (name.equals("beans")) {
- hint = "beans is a list: - name: myBean followed by type:
\"#class:com.example.MyBean\" (indented under the -)";
- } else if (name.equals("steps") || name.equals("when") ||
name.equals("get") || name.equals("post")
- || name.equals("exception") || name.equals("doCatch")) {
- hint = name + " is a list: each item starts with \"- \"";
- } else {
- hint = "write it as a list: each item starts with \"- \"";
- }
- return Error.builder()
- .keyword("type")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("type")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (" + hint + ")")
- .build();
+ /** The language keys of an expression (simple, constant, groovy...), from
the schema. */
+ Set<String> languageKeys() {
+ return languageKeys;
}
- /**
- * Adds a hint to "property 'x' is not defined in the schema": the closest
property name of that node (did you mean
- * 'logName'?), or, when the property is a top-level entry such as
onException written inside a route, where it goes
- * instead.
- */
- List<Error> withPropertyHints(List<Error> errors) {
- List<Error> answer = new ArrayList<>(errors.size());
- for (Error error : errors) {
- answer.add(withPropertyHint(error));
- }
- return answer;
+ /** The names of the EIP steps, from the schema. */
+ Set<String> stepNames() {
+ return stepNames;
}
- Error withPropertyHint(Error error) {
- if ("required".equals(error.getKeyword()) && error.getMessage() != null
- && error.getMessage().contains("required property 'steps' not
found")
- &&
String.valueOf(error.getInstanceLocation()).matches(".*/route/from")) {
- return Error.builder()
- .keyword("required")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("required")
- .format(new MessageFormat("{0}"))
- .arguments(error.getMessage() + " (steps: is a property of
from:, next to uri:; a steps: written at"
- + " the route level must be indented under
from:)")
- .build();
- }
- if (!"additionalProperties".equals(error.getKeyword()) ||
error.getMessage() == null) {
- return error;
- }
- String message = error.getMessage();
- String unknown = between(message, "property '", "'");
- if (unknown == null) {
- return error;
- }
- String location = String.valueOf(error.getInstanceLocation());
- String hint = null;
- if (location.matches("/\\d+/beans/\\d+")
- && (unknown.equals("id") || unknown.equals("ref") ||
unknown.equals("class"))) {
- // - id: myBean / class: ... : the bean properties are name and
type
- hint = "a bean is - name: myBean followed by type:
\"#class:com.example.MyBean\" (name instead of " + unknown
- + (unknown.equals("class") ? ", type instead of class" :
"") + ")";
- } else if (location.matches("/\\d+/beans/\\d+")) {
- // - myBean: {type: ...} instead of - name: myBean / type: ...
- hint = "a bean item is written as - name: " + unknown + " followed
by type: \"#class:com.example.MyBean\" "
- + "(the name is a property, not the key)";
- } else if (topLevelEntries.contains(unknown) &&
location.chars().filter(c -> c == '/').count() >= 2) {
- hint = "'" + unknown + "' is a top-level entry: write it as a list
item at the same level as the route, "
- + "not inside it";
- } else if (location.matches(".*/(onException|doCatch/\\d+)") &&
unknown.matches("([a-z][\\w]*\\.)+[A-Z]\\w*")) {
- // onException: {java.lang.Exception: ...}: the class is a list
item under exception:
- String eip = location.endsWith("/onException") ? "onException" :
"doCatch";
- hint = "the exception class is a list item under exception: (" +
eip + ": {exception: [" + unknown
- + "], steps: [...]})";
- } else if (location.endsWith("/circuitBreaker") &&
unknown.equals("name")) {
- hint = "the circuit breaker's name is its id: circuitBreaker: {id:
myBreaker, ...}";
- } else if (location.endsWith("/circuitBreaker") &&
!resilienceProperties.isEmpty()
- && (resilienceProperties.contains(unknown) || closest(unknown,
resilienceProperties) != null
- ||
unknown.toLowerCase(Locale.ROOT).contains("threshold")
- ||
unknown.toLowerCase(Locale.ROOT).contains("timeout"))) {
- // circuitBreaker: {failureThreshold: 5}: the thresholds and
timeouts are resilience4j configuration
- String best = resilienceProperties.contains(unknown) ? unknown :
closest(unknown, resilienceProperties);
- hint = "the thresholds, timeouts and the like are written under
resilience4jConfiguration: (circuitBreaker:"
- + " {resilience4jConfiguration: {" + (best != null ? best :
"failureRateThreshold") + ": ...}, steps:"
- + " [...], onFallback: {steps: [...]}})";
- } else if (location.endsWith("/log") && (unknown.equals("level") ||
unknown.equals("logLevel"))) {
- hint = "did you mean 'loggingLevel'?";
- } else if (location.endsWith("/log") &&
LOG_COMPONENT_OPTIONS.contains(unknown)) {
- // log: {message: ..., showHeaders: true}: those are options of
the log component endpoint
- hint = "'" + unknown + "' is an option of the log component, not
of the log EIP: write a to: step with"
- + " uri: \"log:com.example?" + unknown + "=...\" (the log
EIP has message, loggingLevel, logName,"
- + " marker)";
- } else if (unknown.equals("steps") && location.matches(".*/route")) {
- // - route: {from: {uri: ...}, steps: [...]}: steps belongs under
from:
- hint = "steps: goes under from:, indented at the same level as
uri: (route: {from: {uri: ..., steps: [...]}})";
- } else if (stepNames.contains(unknown) &&
!location.matches(".*/steps/\\d+")
- &&
location.matches(".*/(otherwise|when/\\d+|doTry|doCatch/\\d+|doFinally|split|filter|loop|aggregate"
- +
"|circuitBreaker|onFallback|multicast|pipeline|saga|resequence|throttle|delay"
- +
"|onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|route|from)"))
{
- // otherwise: {log: ...} or when: [- simple: ..., log: ...]: the
EIPs go under steps:
- String eip = location.substring(location.lastIndexOf('/') + 1);
- if (eip.matches("\\d+")) {
- String parent = location.substring(0,
location.lastIndexOf('/'));
- eip = parent.substring(parent.lastIndexOf('/') + 1);
- }
- hint = "'" + unknown + "' is a step: the steps of " + eip + " go
under steps: (" + eip
- + ": {steps: [- " + unknown + ": ...]})";
- } else if (unknown.equals("script") && !location.endsWith("/steps")
- &&
(EXPRESSION_REQUIRED.contains(location.substring(location.lastIndexOf('/') + 1))
- || location.endsWith("/expression"))) {
- // setBody: {script: ...}: script is an EIP; the language is the
key of an expression
- hint = "script is an EIP step, not a language: write the language
as the key of the expression (expression:"
- + " {groovy: {expression: \"...\"}}, expression: {simple:
{expression: \"...\"}}), or run a script as"
- + " its own step with - script: {expression: {groovy:
{expression: \"...\"}}}";
- } else if (unknown.equals("bean") && !location.endsWith("/steps")) {
- // setBody: {bean: myBean} : the bean language is method:
- hint = "the bean language is written as method: (expression:
{method: {ref: myBean, method: process}}), or"
- + " call the bean as a step with - bean: {ref: myBean,
method: process}";
- } else if
(location.matches(".*/(setHeader|setProperty|setVariable|removeHeader|removeProperty|removeVariable)")
- && closest(unknown,
knownProperties(String.valueOf(error.getSchemaLocation()))) == null) {
- // setHeader: {CamelNumberA: {simple: ...}} : the name is a
property, not the key
- String eip = location.substring(location.lastIndexOf('/') + 1);
- hint = "the name is a property: " + eip + ": {name: " + unknown
- + (eip.startsWith("set") ? ", expression: {simple:
{expression: \"...\"}}}" : "}")
- + " (" + unknown + " is not the key)";
- } else if (location.endsWith("/bean")
- && (unknown.equals("parameters") || unknown.equals("args") ||
unknown.equals("arguments"))) {
- hint = "arguments are written in the method call: bean: {ref:
myBean, method: \"process(${body}, 'x')\"}";
- } else {
- String best = closest(unknown,
knownProperties(String.valueOf(error.getSchemaLocation())));
- if (best != null) {
- hint = "did you mean '" + best + "'?";
- }
- }
- if (hint == null) {
- return error;
- }
- return Error.builder()
- .keyword("additionalProperties")
- .instanceLocation(error.getInstanceLocation())
- .messageKey("additionalProperties")
- .format(new MessageFormat("{0}"))
- .arguments(message + " (" + hint + ")")
- .build();
+ /** The properties of resilience4jConfiguration, from the schema. */
+ Set<String> resilienceProperties() {
+ return resilienceProperties;
}
/** The property names the schema allows at the definition an
additionalProperties error points to. */
@@ -1229,15 +851,6 @@ public class YamlValidator {
return prev[b.length()];
}
- private static String between(String text, String start, String end) {
- int i = text.indexOf(start);
- if (i < 0) {
- return null;
- }
- int j = text.indexOf(end, i + start.length());
- return j < 0 ? null : text.substring(i + start.length(), j);
- }
-
private static boolean isBooleanText(String text) {
String s = text.trim();
return "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s);
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
new file mode 100644
index 000000000000..3dfc0d49d529
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.text.MessageFormat;
+import java.util.List;
+
+import com.networknt.schema.Error;
+import com.networknt.schema.path.NodePath;
+import com.networknt.schema.path.PathType;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24715: the matcher behind the hint tables. The texts of the rows are
pinned by the YamlValidator*Test classes
+ * through the validator; this pins how a row is selected and how the error is
rewritten.
+ */
+public class SchemaHintsTest {
+
+ private static final YamlValidator VALIDATOR = new YamlValidator();
+
+ private static Error error(String keyword, String location, String
message) {
+ NodePath path = new NodePath(PathType.JSON_POINTER);
+ for (String segment : location.split("/")) {
+ if (!segment.isEmpty()) {
+ path = segment.matches("\\d+") ?
path.append(Integer.parseInt(segment)) : path.append(segment);
+ }
+ }
+ return
Error.builder().keyword(keyword).instanceLocation(path).messageKey(keyword)
+ .format(new MessageFormat("{0}")).arguments(message).build();
+ }
+
+ @Test
+ public void testFirstMatchingRowWins() {
+ List<SchemaHints.Hint> table = List.of(
+ SchemaHints.append("type", ".*/beans", m -> true, m ->
"first"),
+ SchemaHints.append("type", ".*/beans", m -> true, m ->
"second"),
+ SchemaHints.append("type", null, m -> true, m -> "any"));
+ Error beans = SchemaHints.apply(table, error("type", "/0/beans",
"object found, array expected"), VALIDATOR);
+ assertThat(beans.getMessage()).isEqualTo("object found, array expected
(first)");
+ Error other = SchemaHints.apply(table, error("type", "/0/route/from",
"object found, array expected"), VALIDATOR);
+ assertThat(other.getMessage()).isEqualTo("object found, array expected
(any)");
+ }
+
+ @Test
+ public void testKeywordLocationAndConditionSelectTheRow() {
+ List<SchemaHints.Hint> table = List.of(
+ SchemaHints.append("type", "/\\d+/steps/\\d+", m ->
m.message().contains("array expected"), m -> "hint"));
+ Error unrelatedKeyword = error("required", "/0/steps/1", "array
expected");
+ Error unrelatedLocation = error("type", "/0/steps", "array expected");
+ Error unrelatedMessage = error("type", "/0/steps/1", "object
expected");
+ Error match = error("type", "/0/steps/1", "array expected");
+ assertThat(SchemaHints.apply(table, unrelatedKeyword,
VALIDATOR)).isSameAs(unrelatedKeyword);
+ assertThat(SchemaHints.apply(table, unrelatedLocation,
VALIDATOR)).isSameAs(unrelatedLocation);
+ assertThat(SchemaHints.apply(table, unrelatedMessage,
VALIDATOR)).isSameAs(unrelatedMessage);
+ assertThat(SchemaHints.apply(table, match,
VALIDATOR).getMessage()).isEqualTo("array expected (hint)");
+ }
+
+ @Test
+ public void testReplaceRewritesKeywordAndMessage() {
+ List<SchemaHints.Hint> table = List.of(
+ SchemaHints.replace("additionalProperties", null, m ->
"simple".equals(m.unknown()),
+ m -> m.name() + ": {" + m.unknown() + ": ...} is the
compact notation", "compactNotation",
+ "compactNotation"));
+ Error hinted = SchemaHints.apply(table,
+ error("additionalProperties", "/0/route/from/steps/0/setBody",
"property 'simple' is not defined"),
+ VALIDATOR);
+ assertThat(hinted.getKeyword()).isEqualTo("compactNotation");
+ assertThat(hinted.getMessageKey()).isEqualTo("compactNotation");
+ assertThat(hinted.getMessage()).isEqualTo("setBody: {simple: ...} is
the compact notation");
+
assertThat(String.valueOf(hinted.getInstanceLocation())).isEqualTo("/0/route/from/steps/0/setBody");
+ }
+
+ @Test
+ public void testUnknownPropertyRowNeedsTheName() {
+ List<SchemaHints.Hint> table = List.of(
+ SchemaHints.unknownProperty(null, m -> true, m -> "did you
mean '" + m.unknown() + "'?"));
+ Error noName = error("additionalProperties", "/0/log", "not defined in
the schema");
+ assertThat(SchemaHints.apply(table, noName,
VALIDATOR)).isSameAs(noName);
+ Error named = error("additionalProperties", "/0/log", "property 'lvl'
is not defined in the schema");
+ assertThat(SchemaHints.apply(table, named, VALIDATOR).getMessage())
+ .isEqualTo("property 'lvl' is not defined in the schema (did
you mean 'lvl'?)");
+ }
+
+ @Test
+ public void testSameHintAtSameLocationReportedOnce() {
+ List<SchemaHints.Hint> table = List.of(
+ SchemaHints.replace("type", null, m -> true, m -> "an
expression expected", "type", "expression"));
+ List<Error> errors = List.of(
+ error("type", "/0/onException/handled", "boolean found, object
expected"),
+ error("type", "/0/onException/handled", "boolean found, string
expected"),
+ error("type", "/0/onException/continued", "boolean found,
object expected"),
+ error("required", "/0/onException", "required property 'steps'
not found"));
+ List<Error> hinted = SchemaHints.apply(table, errors, VALIDATOR);
+ assertThat(hinted).hasSize(3);
+ assertThat(hinted.get(0).getMessage()).isEqualTo("an expression
expected");
+
assertThat(String.valueOf(hinted.get(1).getInstanceLocation())).isEqualTo("/0/onException/continued");
+ assertThat(hinted.get(2)).isSameAs(errors.get(3));
+ }
+
+ @Test
+ public void testMatchNamesTheItemAndItsParent() {
+ SchemaHints.Match m = SchemaHints.Match.of(
+ error("additionalProperties",
"/0/route/from/steps/2/choice/when/0", "property 'log' is not defined"),
+ VALIDATOR);
+ assertThat(m.name()).isEqualTo("0");
+ assertThat(m.nameIsIndex()).isTrue();
+ assertThat(m.parentName()).isEqualTo("when");
+ assertThat(m.unknown()).isEqualTo("log");
+ assertThat(m.locationEndsWith("/when/0")).isTrue();
+ }
+}