diff --git a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java index e75d2a90d1fef..0930cd1553442 100644 --- a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java +++ b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java @@ -1976,6 +1976,64 @@ public void testValidateConfigurationPropertyMainMap() { assertTrue(result.isSuccess()); } + @Test + public void testValidateConfigurationPropertyNestedGroup() { + // camel.vault.aws.* is a nested group (3-segment prefix) — the catalog + // must recognise the group and validate the option name after the third dot. + String text = "camel.vault.aws.accessKey=AKIA1234"; + ConfigurationPropertiesValidationResult result = catalog.validateConfigurationProperty(text); + assertTrue(result.isSuccess()); + assertTrue(result.isAccepted()); + + text = "camel.vault.aws.region=us-east-1"; + result = catalog.validateConfigurationProperty(text); + assertTrue(result.isSuccess()); + assertTrue(result.isAccepted()); + + // invented option under the nested group must be accepted (group is known) but invalid + text = "camel.vault.aws.inventedOption=foo"; + result = catalog.validateConfigurationProperty(text); + assertFalse(result.isSuccess()); + assertTrue(result.isAccepted()); + assertTrue(result.getUnknown().contains("camel.vault.aws.inventedOption")); + } + + @Test + public void testValidateConfigurationPropertyDashedGroupName() { + // The metadata group is "camel.errorRegistry" — the dashed form + // "camel.error-registry" must be accepted and validated. + String text = "camel.error-registry.enabled=true"; + ConfigurationPropertiesValidationResult result = catalog.validateConfigurationProperty(text); + assertTrue(result.isSuccess()); + assertTrue(result.isAccepted()); + + text = "camel.error-registry.maximumEntries=100"; + result = catalog.validateConfigurationProperty(text); + assertTrue(result.isSuccess()); + assertTrue(result.isAccepted()); + + text = "camel.ai-observability.enabled=true"; + result = catalog.validateConfigurationProperty(text); + assertTrue(result.isSuccess()); + assertTrue(result.isAccepted()); + } + + @Test + public void testValidateConfigurationPropertyServerInventedOption() { + // camel.server.* is a known group — a real option must pass, + // and an invented option must fail validation. + String text = "camel.server.port=9999"; + ConfigurationPropertiesValidationResult result = catalog.validateConfigurationProperty(text); + assertTrue(result.isSuccess()); + assertTrue(result.isAccepted()); + + text = "camel.server.inventedOption=foo"; + result = catalog.validateConfigurationProperty(text); + assertFalse(result.isSuccess()); + assertTrue(result.isAccepted()); + assertTrue(result.getUnknown().contains("camel.server.inventedOption")); + } + @Test public void validateEnvVariableInSyntax() { EndpointValidationResult result diff --git a/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java b/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java index 896cb50400e88..b00450882a44e 100644 --- a/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java +++ b/core/camel-core-catalog/src/main/java/org/apache/camel/catalog/impl/AbstractCamelCatalog.java @@ -1185,7 +1185,15 @@ public ConfigurationPropertiesValidationResult validateConfigurationProperty(Str } ConfigurationPropertiesValidationResult result = new ConfigurationPropertiesValidationResult(); - boolean accept = acceptConfigurationPropertyKey(key); + // Load the main model once here to avoid repeated JSON parsing in acceptConfigurationPropertyKey + // and matchMainModelGroup. Only load it for keys that may reach the main-group branch: + // component/dataformat/language are accepted by a static prefix check and never use the model. + MainModel cachedMainModel = key != null && key.startsWith("camel.") + && !key.startsWith("camel.component.") + && !key.startsWith("camel.dataformat.") + && !key.startsWith("camel.language.") + ? mainModel() : null; + boolean accept = acceptConfigurationPropertyKey(key, cachedMainModel); if (!accept) { result.setAccepted(false); return result; @@ -1232,33 +1240,38 @@ public ConfigurationPropertiesValidationResult validateConfigurationProperty(Str int posBracket = nOption.indexOf('['); validateConfigurationProperty(posDot, posBracket, suffix, nOption, result, rows, name, value, longKey); } - } else if (key.startsWith("main.") - || key.startsWith("resilience4j.") - || key.startsWith("faulttolerance.") - || key.startsWith("threadpool.") - || key.startsWith("lra.") - || key.startsWith("health.") - || key.startsWith("rest.")) { - String name = StringHelper.before(key, "."); - if (value != null) { - MainModel model = mainModel(); - if (model == null) { - result.addIncapable("camel-main not detected on classpath"); - return result; - } - Map rows = new HashMap<>(); - model.getOptions().forEach(o -> rows.put(dashToCamelCase(o.getName()), o)); - - // lower case option and remove dash - String nOption = longKey.replace("-", "").toLowerCase(Locale.ENGLISH); - - // look for suffix or array index after 2nd dot - int secondDot = nOption.indexOf('.', nOption.indexOf('.') + 1) + 1; + } else { + // Check if the key matches a main-model group (dynamic — replaces the hardcoded list). + // Must use longKey (the full camel.* key) because matchMainModelGroup compares against + // the full group names (e.g. "camel.server"), and key has already had "camel." stripped. + MainModel.MainGroupModel matchedGroup = matchMainModelGroup(longKey, cachedMainModel); + if (matchedGroup != null) { + String name = StringHelper.before(key, "."); + if (value != null) { + // cachedMainModel is guaranteed non-null here: a match in matchMainModelGroup + // implies the model was available (the groups come from the model itself). + Map rows = new HashMap<>(); + cachedMainModel.getOptions().forEach(o -> rows.put(dashToCamelCase(o.getName()), o)); + + // lower case option and remove dash + String nOption = longKey.replace("-", "").toLowerCase(Locale.ENGLISH); + + // look for suffix or array index after the group prefix + // group name depth tells us how many dots to skip: "camel.vault.aws" has 2 dots, + // so the option starts after the 3rd dot in the full key + int groupDots = (int) matchedGroup.getName().chars().filter(c -> c == '.').count(); + int pos = 0; + for (int d = 0; d <= groupDots; d++) { + pos = nOption.indexOf('.', pos) + 1; + } + // pos now points just after the group prefix in nOption (e.g. after "camel.server.") + int afterGroup = pos; - String suffix = null; - int posDot = nOption.indexOf('.', secondDot); - int posBracket = nOption.indexOf('[', secondDot); - validateConfigurationProperty(posDot, posBracket, suffix, nOption, result, rows, name, value, longKey); + String suffix = null; + int posDot = nOption.indexOf('.', afterGroup); + int posBracket = nOption.indexOf('[', afterGroup); + validateConfigurationProperty(posDot, posBracket, suffix, nOption, result, rows, name, value, longKey); + } } } @@ -1408,20 +1421,64 @@ private static boolean isValidSuffix(String suffix) { return suffix == null || suffix.isEmpty() || suffix.equals("."); } - private static boolean acceptConfigurationPropertyKey(String key) { + private boolean acceptConfigurationPropertyKey(String key, MainModel cachedModel) { if (key == null) { return false; } - return key.startsWith("camel.component.") + // Keys outside the camel.* namespace are never checked by this catalog + if (!key.startsWith("camel.")) { + return false; + } + // Always-accepted static prefixes (component/dataformat/language have their own models) + if (key.startsWith("camel.component.") || key.startsWith("camel.dataformat.") - || key.startsWith("camel.language.") - || key.startsWith("camel.main.") - || key.startsWith("camel.resilience4j.") - || key.startsWith("camel.faulttolerance.") - || key.startsWith("camel.threadpool.") - || key.startsWith("camel.health.") - || key.startsWith("camel.lra.") - || key.startsWith("camel.rest."); + || key.startsWith("camel.language.")) { + return true; + } + // Dynamic: accept any prefix that matches a group in the main-model metadata. + // camel-main binds keys after normalising dashes and case (MainHelper.optionKey / + // startsWithIgnoreCase), so we compare normalised forms to match e.g. + // camel.error-registry.* against the group name "camel.errorRegistry". + String normKey = key.replace("-", "").toLowerCase(Locale.ENGLISH); + MainModel model = cachedModel; + if (model != null) { + for (MainModel.MainGroupModel group : model.getGroups()) { + String normGroup = group.getName().replace("-", "").toLowerCase(Locale.ENGLISH) + "."; + if (normKey.startsWith(normGroup)) { + return true; + } + } + } + return false; + } + + /** + * Returns the matched group from the main-model metadata when the key (full {@code camel.*} key) corresponds to one + * of the registered groups, or {@code null} if no group matches. + *

+ * Comparison is normalised (dashes removed, lower-cased) to mirror how camel-main binds keys at runtime via + * {@code MainHelper.optionKey} and {@code startsWithIgnoreCase}. + *

+ * Note: {@link #acceptConfigurationPropertyKey} also iterates the groups with the same normalisation, but it + * returns a boolean rather than the matched group. Both methods receive the full {@code camel.*} key and compare it + * against the full group name (including the {@code camel.} prefix). + */ + private MainModel.MainGroupModel matchMainModelGroup(String key, MainModel cachedModel) { + MainModel model = cachedModel; + if (model == null) { + return null; + } + // Normalise the incoming key once (full key, e.g. "camel.errorregistry.enabled") + String normKey = key.replace("-", "").toLowerCase(Locale.ENGLISH); + for (MainModel.MainGroupModel group : model.getGroups()) { + // group name is e.g. "camel.errorRegistry" — normalise the whole name (including "camel." prefix) + // to match the full key; do NOT strip "camel." here, because normKey still carries it. + String normGroupName = group.getName().replace("-", "").toLowerCase(Locale.ENGLISH); + if (normKey.startsWith(normGroupName + ".")) { + return group; + } + } + return null; } /** diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 0594c881d9fa0..6ee9132c02d2f 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -25,6 +25,40 @@ in its model, so clearing them on the route saved little memory at the cost of m Applications that called `Route.clearRouteModel()` directly can remove the call. Implementations of the `Route` interface that override `clearRouteModel()` can remove the override. +=== camel-report-maven-plugin:validate / configuration-validate + +The catalog now accepts all `camel.*` groups defined in `camel-main-configuration-metadata.json` +(previously only 7 of the 36 groups were recognized, besides `camel.component.*`, `camel.dataformat.*` +and `camel.language.*`). As a result, +`camel-report-maven-plugin:validate` and the camel-jbang MCP `camel_configuration_validate` tool +now validate 29 additional `camel.*` groups (including `camel.server.*`, +`camel.management.*`, `camel.vault.*`, `camel.ssl.*`, and others). + +If your project uses Spring Boot and has options such as `camel.metrics.uriTagEnabled` (from a +Camel starter) that are not in the camel-main schema, `camel-report-maven-plugin:validate` may +report them as "Unknown option", which fails the build when `failOnError=true`. The +`camel_configuration_validate` MCP tool with `runtime=spring-boot` reports such keys as +`checked=false` instead. + +Group names are now matched with dash- and case-insensitive normalisation, matching how +camel-main binds keys at runtime. For example, `camel.error-registry.enabled` and +`camel.ai-observability.enabled` are now accepted alongside their camel-case equivalents. + +If your project uses `camel-report-maven-plugin:validate` with `failOnError=true`, +a key that was silently ignored before (e.g. a misspelled option under one of the newly accepted +groups) may now cause the build to fail. Review the validation output and correct any misspelled keys. + +The `camel_configuration_validate` MCP tool response format has changed: + +* `PropertyValidationInfo.accepted` is renamed to `checked`. +* `ConfigurationValidateSummary` gains a new `unchecked` integer field counting lines whose + namespace is not recognized as a Camel property (e.g. `server.port=8080` or `quarkus.*` keys). + Before, such lines were returned with `accepted=false` and `valid=true`; they are now + `checked=false` and are left out of `summary.valid` and `summary.errors`. + +If you call `camel_configuration_validate` programmatically and read the JSON response, update +field references from `accepted` to `checked` and handle the new `summary.unchecked` field. + === camel-semantic - expert artifacts TypeSafe AI Semantic applications must add `org.apache.camel:camel-semantic-typesafe-ai`. diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java index 5594ce1276c88..65c780318be47 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java @@ -144,9 +144,11 @@ public CamelCatalog loadCatalog(String runtime, String camelVersion, String plat try { loaded = doLoadCatalog(runtimeType, camelVersion, platformBomGav); } catch (Exception e) { - if (runtimeType == RuntimeType.main && hasVersion && !hasBom) { - // a version that cannot be downloaded (not released, no network): answer from the default catalog - // rather than fail the tool call + if (!hasBom && (runtimeType == RuntimeType.main && hasVersion + || runtimeType == RuntimeType.springBoot && !hasVersion)) { + // a version/artifact that cannot be downloaded (not released, no network): answer from the default + // catalog rather than fail the tool call. For spring-boot without a pinned version, the default + // camel-main catalog is close enough for property validation and avoids flaky network failures. return defaultCatalog; } throw e; diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateTools.java index de13bfd8647b6..f9d6075c53942 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateTools.java @@ -20,7 +20,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -53,7 +55,17 @@ public class ConfigurationValidateTools { + "returns suggestions when available. The input can be a single line or a multi-line string with " + - "one property per line; blank lines and comments (starting with # or !) are skipped.") + "one property per line; blank lines and comments (starting with # or !) are skipped. " + + + "Lines that are not recognised as Camel properties (e.g. server.port, quarkus.* or camel.beans.*) " + + + "are reported with checked=false and are excluded from summary.valid and summary.errors; " + + + "they are counted in summary.unchecked instead. " + + + "For runtime=spring-boot, starter-specific options (e.g. camel.metrics.uriTagEnabled) " + + + "are not present in the camel-main metadata and are also reported as checked=false.") public ConfigurationValidateResult camel_configuration_validate( @ToolArg(description = "Configuration property lines to validate. Can be a single line " + "(e.g. \"camel.main.streamCaching=true\") or multiple lines separated by newlines.") String properties, @@ -72,6 +84,14 @@ public ConfigurationValidateResult camel_configuration_validate( int totalErrors = 0; int totalWarnings = 0; int validCount = 0; + int uncheckedCount = 0; + + // For spring-boot, starter-specific options (e.g. camel.metrics.uriTagEnabled from + // camel-micrometer-starter) are not present in the camel-main metadata that the catalog + // uses. The catalog accepts the group (camel.metrics is a valid main-model group) but + // then reports "Unknown option" for starter-only properties. Treat such lines as + // unchecked (not inspected) rather than invalid, to avoid false positives. + boolean isSpringBoot = "spring-boot".equalsIgnoreCase(runtime); String[] lines = properties.split("\\r?\\n"); for (int i = 0; i < lines.length; i++) { @@ -82,17 +102,23 @@ public ConfigurationValidateResult camel_configuration_validate( } ConfigurationPropertiesValidationResult result = catalog.validateConfigurationProperty(line); - PropertyValidationInfo info = toPropertyValidationInfo(i + 1, line, result); + PropertyValidationInfo info = toPropertyValidationInfo(i + 1, line, result, isSpringBoot); results.add(info); - totalErrors += info.errors(); - totalWarnings += info.warnings(); - if (info.valid()) { - validCount++; + if (!info.checked()) { + uncheckedCount++; + } else { + totalErrors += info.errors(); + totalWarnings += info.warnings(); + if (info.valid()) { + validCount++; + } } } ConfigurationValidateSummary summary - = new ConfigurationValidateSummary(results.size(), validCount, totalErrors, totalWarnings); + = new ConfigurationValidateSummary( + results.size(), validCount, totalErrors, totalWarnings, + uncheckedCount); return new ConfigurationValidateResult(catalog.getCatalogVersion(), summary, results); } catch (ToolCallException e) { @@ -105,16 +131,66 @@ public ConfigurationValidateResult camel_configuration_validate( } private PropertyValidationInfo toPropertyValidationInfo( - int lineNumber, String text, ConfigurationPropertiesValidationResult result) { - boolean valid = result.isSuccess(); - boolean accepted = result.isAccepted(); - int errors = result.getNumberOfErrors(); - int warnings = result.getNumberOfWarnings(); + int lineNumber, String text, ConfigurationPropertiesValidationResult result, boolean isSpringBoot) { + boolean checked = result.isAccepted(); + // A checked line is valid when the catalog found no errors; an unchecked line + // (not recognised as a Camel property) is neither valid nor invalid — it is simply + // not inspected by this catalog. + boolean valid = checked && result.isSuccess(); + + // For Spring Boot, the catalog uses camel-main metadata which does not include + // starter-specific options (e.g. camel.metrics.uriTagEnabled from camel-micrometer-starter). + // When the only issues are "unknown option" errors AND the key belongs to a group that is + // newly accepted by this fix (not one of the original 10 hardcoded prefixes), downgrade to + // unchecked to avoid false positives — the starter may legitimately bind options outside the + // main schema. Keys under the original 10 groups (component, dataformat, language, main, + // resilience4j, faulttolerance, threadpool, health, lra, rest) retain full validation since + // the catalog has always covered them. + boolean springBootDowngraded = false; + if (isSpringBoot && checked && !valid) { + String propertyKey = text != null && text.contains("=") ? text.substring(0, text.indexOf('=')).trim() : text; + // Normalize to dash-free lowercase so that Spring Boot relaxed-binding forms + // (e.g. camel.fault-tolerance.*, camel.thread-pool.*) match the original group + // prefixes the same way the catalog normalises them internally. + String normKey = propertyKey != null ? propertyKey.replace("-", "").toLowerCase(Locale.ENGLISH) : null; + boolean isOriginalGroup = normKey != null && (normKey.startsWith("camel.component.") + || normKey.startsWith("camel.dataformat.") + || normKey.startsWith("camel.language.") + || normKey.startsWith("camel.main.") + || normKey.startsWith("camel.resilience4j.") + || normKey.startsWith("camel.faulttolerance.") + || normKey.startsWith("camel.threadpool.") + || normKey.startsWith("camel.health.") + || normKey.startsWith("camel.lra.") + || normKey.startsWith("camel.rest.")); + if (!isOriginalGroup) { + Set unknownKeys = result.getUnknown() != null ? result.getUnknown() : Collections.emptySet(); + boolean onlyUnknownOptionErrors = !unknownKeys.isEmpty() + && result.getNumberOfErrors() == unknownKeys.size() + && result.getSyntaxError() == null + && result.getIncapable() == null + && result.getInvalidBoolean() == null + && result.getInvalidEnum() == null + && result.getInvalidInteger() == null + && result.getInvalidNumber() == null + && result.getInvalidDuration() == null; + if (onlyUnknownOptionErrors) { + checked = false; + valid = false; + springBootDowngraded = true; + } + } + } + + int errors = checked ? result.getNumberOfErrors() : 0; + int warnings = checked ? result.getNumberOfWarnings() : 0; - List issues = collectIssues(result); - String summary = result.summaryErrorMessage(false, true, true); + // On a Spring Boot downgrade, still surface suggestions so the user can act on them + // even though the line is classified as unchecked (the count is excluded from summary.errors). + List issues = (checked || springBootDowngraded) ? collectIssues(result) : Collections.emptyList(); + String summary = checked ? result.summaryErrorMessage(false, true, true) : null; - return new PropertyValidationInfo(lineNumber, text, accepted, valid, errors, warnings, summary, issues); + return new PropertyValidationInfo(lineNumber, text, checked, valid, errors, warnings, summary, issues); } private List collectIssues(ConfigurationPropertiesValidationResult result) { @@ -203,10 +279,10 @@ public record ConfigurationValidateResult(String camelVersion, ConfigurationVali List properties) { } - public record ConfigurationValidateSummary(int total, int valid, int errors, int warnings) { + public record ConfigurationValidateSummary(int total, int valid, int errors, int warnings, int unchecked) { } - public record PropertyValidationInfo(int lineNumber, String text, boolean accepted, boolean valid, int errors, + public record PropertyValidationInfo(int lineNumber, String text, boolean checked, boolean valid, int errors, int warnings, String summary, List issues) { } diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java index 164994d1c193c..a6714a548d12f 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java @@ -152,6 +152,18 @@ void platformBomVersionTakesPrecedenceOverCamelVersion() { assertThat(version(createService(null), null, "9.99.99", bom)).isEqualTo(BUILTIN_VERSION); } + @Test + void springBootWithoutVersionFallsBackToDefaultCatalogWhenOffline() { + // When runtime=spring-boot with no explicit version or BOM, the catalog loader tries to download + // the Spring Boot catalog provider from Maven Central. In environments without network access (CI), + // this fails with a DownloadException. The fix is to fall back to the default catalog rather than + // surfacing a confusing network error to the user. + // This test uses a non-existent repo so the download always fails, simulating offline/CI behaviour. + CatalogService service = createService("file:///nonexistent-repo-that-never-exists"); + + assertThat(version(service, "spring-boot", null, null)).isEqualTo(BUILTIN_VERSION); + } + // Download tests — require Maven Central access. // Disabled in CI environments to avoid flaky network-dependent failures. diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateToolsTest.java index 1be1ce6fc1eaa..9de2372fa479d 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateToolsTest.java @@ -51,7 +51,7 @@ void validatesValidMainProperty() { ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); assertThat(info.lineNumber()).isEqualTo(1); - assertThat(info.accepted()).isTrue(); + assertThat(info.checked()).isTrue(); assertThat(info.valid()).isTrue(); assertThat(info.errors()).isZero(); assertThat(info.issues()).isEmpty(); @@ -113,7 +113,7 @@ void acceptsPlaceholderValuesAcrossTypes() { assertThat(result.summary().errors()).isZero(); assertThat(result.properties()) .allSatisfy(info -> { - assertThat(info.accepted()).isTrue(); + assertThat(info.checked()).isTrue(); assertThat(info.valid()).isTrue(); assertThat(info.errors()).isZero(); assertThat(info.issues()).isEmpty(); @@ -130,7 +130,7 @@ void acceptsSpringStylePlaceholderValue() { null); ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); - assertThat(info.accepted()).isTrue(); + assertThat(info.checked()).isTrue(); assertThat(info.valid()).isTrue(); assertThat(info.errors()).isZero(); assertThat(info.issues()).isEmpty(); @@ -172,14 +172,183 @@ void multipleLinesAggregated() { } @Test - void nonCamelKeyMarkedNotAccepted() { + void nonCamelKeyMarkedNotChecked() { + // Non-Camel keys (e.g. server.port) are not checked by this catalog: checked=false, + // valid=false, and counted in summary.unchecked (not in summary.errors or summary.valid). ConfigurationValidateTools tools = createTools(); ConfigurationValidateTools.ConfigurationValidateResult result = tools.camel_configuration_validate("server.port=8080", null, null, null); ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); - assertThat(info.accepted()).isFalse(); + assertThat(info.checked()).isFalse(); + assertThat(info.valid()).isFalse(); + assertThat(result.summary().unchecked()).isEqualTo(1); + assertThat(result.summary().errors()).isZero(); + assertThat(result.summary().valid()).isZero(); + } + + // --- CAMEL-25538 regression tests --- + + @Test + void camelServerPortIsAcceptedAndValid() { + // camel.server.* was previously not in the hardcoded prefix list — it must now be + // accepted and validated against the main-model metadata. + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate("camel.server.port=9999", null, null, null); + + assertThat(result.summary().total()).isEqualTo(1); + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isTrue(); + assertThat(info.valid()).isTrue(); + assertThat(info.errors()).isZero(); + assertThat(info.issues()).isEmpty(); + } + + @Test + void camelServerInventedKeyIsCheckedButNotValid() { + // An invented key under an accepted camel.server.* group must be reported as unknown, + // not silently passed as valid. + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate("camel.server.bindPort=9999", null, null, null); + + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isTrue(); + assertThat(info.valid()).isFalse(); + assertThat(info.issues()).anyMatch(i -> "unknown".equals(i.kind())); + } + + @Test + void uncheckedKeyImpliesValidFalseAndNotCountedInErrors() { + // Invented key outside any known camel.* group: checked=false AND valid=false. + // Before the fix, valid was true despite accepted=false (CAMEL-25538 bug #2). + // The key must not appear in summary.errors — it is unchecked, not rejected. + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate("camel.invented.completely.unknown=foo", null, null, null); + + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isFalse(); + assertThat(info.valid()).isFalse(); + // The summary must not count unchecked keys as valid or as errors + assertThat(result.summary().valid()).isZero(); + assertThat(result.summary().errors()).isZero(); + assertThat(result.summary().unchecked()).isEqualTo(1); + } + + @Test + void camelManagementKeyIsCheckedAndValid() { + // camel.management.* was also missing from the old hardcoded list. + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate("camel.management.enabled=true", null, null, null); + + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isTrue(); + assertThat(info.valid()).isTrue(); + } + + @Test + void camelVaultAwsKeyIsCheckedAndValid() { + // camel.vault.aws.* is a nested group (3-segment name) — must also be accepted. + // The dot-offset logic that skips the group prefix is exercised here. + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate("camel.vault.aws.accessKey=AKIA1234", null, null, null); + + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isTrue(); + assertThat(info.valid()).isTrue(); + assertThat(info.issues()).isEmpty(); + } + + @Test + void camelVaultAwsInventedKeyIsCheckedButNotValid() { + // An invented option under a nested group (camel.vault.aws.*) exercises the dot-offset + // logic: the option name starts after the 3rd dot and must be checked against the model. + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate("camel.vault.aws.inventedOption=foo", null, null, null); + + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isTrue(); + assertThat(info.valid()).isFalse(); + } + + @Test + void dashedGroupNameIsCheckedAndValid() { + // camel-main binds camel.error-registry.* to the camel.errorRegistry group + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate("camel.error-registry.enabled=true", null, null, null); + + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isTrue(); + assertThat(info.valid()).isTrue(); + } + + @Test + void springBootStarterOnlyOptionIsUnchecked() { + // camel.metrics.uriTagEnabled exists in camel-micrometer-starter but not in the + // camel-main metadata the catalog uses — it must not be reported as an error for + // runtime=spring-boot. + ConfigurationValidateTools tools = createTools(); + + ConfigurationValidateTools.ConfigurationValidateResult result + = tools.camel_configuration_validate( + "camel.metrics.uriTagEnabled=true", "spring-boot", null, null); + + ConfigurationValidateTools.PropertyValidationInfo info = result.properties().get(0); + assertThat(info.checked()).isFalse(); + assertThat(info.valid()).isFalse(); + assertThat(result.summary().unchecked()).isEqualTo(1); + assertThat(result.summary().errors()).isZero(); + assertThat(info.issues()).anyMatch(i -> "unknown".equals(i.kind())); + + // Counterpart: without the spring-boot flag, camel.metrics.uriTagEnabled IS checked + // (camel.metrics is a known main-model group) but flagged as unknown option. + // This assertion FAILS on the old code (camel.metrics not in hardcoded prefix list) + // and confirms the dynamic group-matching path is exercised, not just the old fallback. + ConfigurationValidateTools.ConfigurationValidateResult nonSbResult + = tools.camel_configuration_validate( + "camel.metrics.uriTagEnabled=true", null, null, null); + ConfigurationValidateTools.PropertyValidationInfo nonSbInfo = nonSbResult.properties().get(0); + assertThat(nonSbInfo.checked()).isTrue(); // camel.metrics group is known + assertThat(nonSbInfo.valid()).isFalse(); // but uriTagEnabled is not in main metadata + } + + @Test + void springBootKeepsValidationForOriginalGroups() { + // Original groups (camel.component.*, camel.fault-tolerance.*, etc.) must remain fully + // validated under Spring Boot — only newly-accepted dynamic groups are downgraded. + ConfigurationValidateTools tools = createTools(); + + // camel.component.kafka.brokerz is a typo — must still be checked and invalid with a suggestion + ConfigurationValidateTools.ConfigurationValidateResult componentResult + = tools.camel_configuration_validate( + "camel.component.kafka.brokerz=localhost:9092", "spring-boot", null, null); + ConfigurationValidateTools.PropertyValidationInfo componentInfo = componentResult.properties().get(0); + assertThat(componentInfo.checked()).isTrue(); + assertThat(componentInfo.valid()).isFalse(); + assertThat(componentInfo.issues()).anyMatch(i -> "unknown".equals(i.kind())); + + // camel.fault-tolerance.* (dashed form of camel.faulttolerance.*) must also stay validated + ConfigurationValidateTools.ConfigurationValidateResult ftResult + = tools.camel_configuration_validate( + "camel.fault-tolerance.timeoutEnabledz=true", "spring-boot", null, null); + ConfigurationValidateTools.PropertyValidationInfo ftInfo = ftResult.properties().get(0); + assertThat(ftInfo.checked()).isTrue(); + assertThat(ftInfo.valid()).isFalse(); + assertThat(ftInfo.issues()).anyMatch(i -> "unknown".equals(i.kind())); } @Test