This is an automated email from the ASF dual-hosted git repository. christophd pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit c9f339ed8091c96f0e60d4b9309ea5b3f1bc1988 Author: Christoph Deppisch <[email protected]> AuthorDate: Wed Nov 11 23:07:34 2026 +0100 CAMEL-26764: Backward-compatible appliesTo filtering, docs, test improvements - Missing appliesTo in config is treated as legacy (load for all built-in commands like run/export) to avoid breaking third-party plugins that haven't declared appliesTo yet. - When appliesTo is explicitly present (even empty []), filtering applies — only declared commands load the plugin. - Known PluginType appliesTo is merged at runtime without writing to the config file or emitting warnings. - After a plugin is downloaded, appliesTo is learned from its @CamelJBangPlugin annotation and cached in the resolved block. - Added upgrade guide entry documenting the new appliesTo attribute. - Tests use proper import for DefaultCamelCatalog, broken entries prove plugin inclusion, and legacy backward-compat is tested. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 14 +++ .../camel/dsl/jbang/core/common/PluginHelper.java | 57 ++++++--- .../dsl/jbang/core/common/PluginFilteringTest.java | 138 ++++++++++++--------- 3 files changed, 133 insertions(+), 76 deletions(-) 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 d9bf677e1888..4c554e0fe7fb 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 @@ -1178,6 +1178,20 @@ built for Camel 4.24: * A test sets the options of a command with `CommandLineTestHelper.parse(command, args...)` (in the test jar of `camel-jbang-core`) instead of `CommandLine.populateCommand`. +=== camel-jbang - plugins declare which commands they contribute to + +A CLI plugin can now declare which built-in commands it participates in beyond its own command, using the +`appliesTo` attribute on `@CamelJBangPlugin`. For example, a plugin that provides a `PluginExporter` should declare +`@CamelJBangPlugin(name = "...", firstVersion = "...", appliesTo = {"export"})` so it is loaded during `camel export` +but not during `camel run`. + +Existing plugins that do not declare `appliesTo` are loaded for all built-in commands as before (backward compatible). +Once a plugin declares `appliesTo` (even an empty array), only the declared commands load it. + +When adding a plugin via `camel plugin add`, the `appliesTo` value from the `@CamelJBangPlugin` annotation (for known +Camel plugins) or from the plugin's metadata is stored in the configuration. After a plugin is downloaded for the first +time, its `appliesTo` is learned from its annotation and cached for subsequent invocations. + === camel-resilience4j, camel-microprofile-fault-tolerance - a circuit breaker in a doTry A circuit breaker in the steps of a `doTry` no longer ends that `doTry` for the steps after it. Before, when the diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index a6ab03f9dd98..9fb61f22bc64 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -361,23 +361,14 @@ public final class PluginHelper { final String firstVersion = properties.getOrDefault("firstVersion", "").toString(); final String gav = properties.getOrDefault("dependency", "").toString(); - // auto-migrate old config entries for known plugins that now declare appliesTo - if (properties.get("appliesTo") == null) { - Optional<PluginType> knownType = PluginType.findByName(name); - if (knownType.isPresent() && knownType.get().getAppliesTo().length > 0) { - properties.put("appliesTo", toJsonArray(knownType.get().getAppliesTo())); - configDirty = true; - main.getOut().printWarn( - ("Plugin '%s' configuration updated with 'appliesTo' filter. " - + "Run 'camel plugin add %s' to refresh the full configuration.") - .formatted(name, name)); - } - } - // skip plugins that don't match the target command to avoid unnecessary downloads if (target != null && !"shell".equals(target) && !target.equals(command)) { - Object appliesToObj = properties.get("appliesTo"); - if (!(appliesToObj instanceof Collection<?> appliesToList) || !appliesToList.contains(target)) { + Collection<?> appliesTo = effectiveAppliesTo(properties, name); + if (appliesTo != null) { + if (!appliesTo.contains(target)) { + continue; + } + } else if (!PLUGIN_CONSUMING_BUILTINS.contains(target)) { continue; } } @@ -440,6 +431,16 @@ public final class PluginHelper { if (dr.plugin().isPresent() && entry != null && dr.classLoader() != null && dr.className() != null) { cacheWritten = writeCache(entry, defaultVersion, gav, repos, dr.className(), dr.classLoader(), name, depVersion); } + if (dr.plugin().isPresent() && entry != null && entry.get("appliesTo") == null) { + CamelJBangPlugin ann = dr.plugin().get().getClass().getAnnotation(CamelJBangPlugin.class); + if (ann != null && ann.appliesTo().length > 0) { + JsonObject resolved = entry.getMap("resolved"); + if (resolved != null && resolved.get("appliesTo") == null) { + resolved.put("appliesTo", toJsonArray(ann.appliesTo())); + cacheWritten = true; + } + } + } return new ResolveResult(dr.plugin(), cacheWritten); } @@ -624,6 +625,32 @@ public final class PluginHelper { return o == null ? null : o.toString(); } + /** + * Returns the effective {@code appliesTo} for a plugin entry, checking (in order) the top-level config, the + * {@code resolved} block from a previous download, and the {@link PluginType} enum for known Camel plugins. Returns + * {@code null} when no {@code appliesTo} information is available — the caller treats this as a legacy plugin that + * should be loaded for all targets. + */ + private static Collection<?> effectiveAppliesTo(JsonObject properties, String name) { + Object obj = properties.get("appliesTo"); + if (obj instanceof Collection<?> list) { + return list; + } + JsonObject resolved = properties.getMap("resolved"); + if (resolved != null) { + Object resolvedObj = resolved.get("appliesTo"); + if (resolvedObj instanceof Collection<?> list) { + return list; + } + } + Optional<PluginType> knownType = PluginType.findByName(name); + if (knownType.isPresent()) { + String[] at = knownType.get().getAppliesTo(); + return at.length > 0 ? Arrays.asList(at) : List.of(); + } + return null; + } + public static JsonArray toJsonArray(String[] values) { JsonArray array = new JsonArray(); array.addAll(Arrays.asList(values)); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginFilteringTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginFilteringTest.java index 7b6948acfe4d..23eb812bd09f 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginFilteringTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginFilteringTest.java @@ -27,6 +27,7 @@ import java.util.Map; import org.aesh.command.Command; import org.aesh.command.CommandResult; import org.aesh.command.invocation.CommandInvocation; +import org.apache.camel.catalog.DefaultCamelCatalog; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.commands.CliExportContext; import org.apache.camel.dsl.jbang.run.common.StringPrinter; @@ -62,7 +63,7 @@ class PluginFilteringTest { void setup() { previousHome = CommandLineHelper.getHomeDir(); CommandLineHelper.useHomeDir(tempDir.toString()); - camelVersion = new org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion(); + camelVersion = new DefaultCamelCatalog().getCatalogVersion(); } @AfterEach @@ -105,30 +106,28 @@ class PluginFilteringTest { .containsKey("beta"); } - // ---- The "run" command should not load unrelated plugins ---- + // ---- Legacy plugins without appliesTo are loaded for built-in commands (backward compat) ---- @Test - void runCommandDoesNotLoadUnrelatedPlugin() throws Exception { + void legacyPluginWithoutAppliesToIsLoadedForRunTarget() throws Exception { writeTwoPluginConfig("alpha", "beta"); CamelJBangMain main = new CamelJBangMain(); Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "run"); - assertThat(plugins).doesNotContainKey("alpha") - .doesNotContainKey("beta"); + assertThat(plugins).containsKey("alpha") + .containsKey("beta"); } - // ---- The "export" command should not load unrelated plugins ---- - @Test - void exportCommandDoesNotLoadUnrelatedPlugin() throws Exception { + void legacyPluginWithoutAppliesToIsLoadedForExportTarget() throws Exception { writeTwoPluginConfig("alpha", "beta"); CamelJBangMain main = new CamelJBangMain(); Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "export"); - assertThat(plugins).doesNotContainKey("alpha") - .doesNotContainKey("beta"); + assertThat(plugins).containsKey("alpha") + .containsKey("beta"); } // ---- A broken unrelated plugin should not break the "run" target ---- @@ -203,95 +202,101 @@ class PluginFilteringTest { } @Test - void pluginWithAppliesToExportIsLoadedByCliExportContext() throws Exception { - Path jar = tempDir.resolve("fake-exporter.jar"); - FakePluginJar.write(jar, "exporter"); + void pluginWithEmptyAppliesToIsNotLoadedForBuiltinTarget() throws Exception { + Path jar = tempDir.resolve("fake-alpha.jar"); + FakePluginJar.write(jar, "alpha"); - JsonObject entry = buildEntry("exporter", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); - entry.put("appliesTo", jsonArray("export")); + JsonObject entry = buildEntry("alpha", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); + entry.put("appliesTo", jsonArray()); writeConfig(entry); - StringPrinter printer = new StringPrinter(); - CamelJBangMain main = new CamelJBangMain().withPrinter(printer); - - ExportContext context = CliExportContext.create(main, printer, true); + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "export"); - assertThatCode(() -> context.getPlugins(null)) - .doesNotThrowAnyException(); + assertThat(plugins).doesNotContainKey("alpha"); } - // ---- auto-migration of old configs for known plugins ---- - @Test - void oldConfigForKnownPluginIsAutoMigrated() throws Exception { - Path jar = tempDir.resolve("fake-test.jar"); - FakePluginJar.write(jar, "test"); - - // simulate old config: known plugin "test" without appliesTo - JsonObject entry = buildEntry("test", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); + void pluginWithAppliesToExportIsLoadedByCliExportContext() throws Exception { + // A broken entry with appliesTo ["export"]: if CliExportContext includes it, resolution + // fails — proving the plugin was included in the export target. + JsonObject entry = buildBrokenEntry("exporter"); + entry.put("appliesTo", jsonArray("export")); writeConfig(entry); StringPrinter printer = new StringPrinter(); CamelJBangMain main = new CamelJBangMain().withPrinter(printer); - // target "export" — without migration, "test" plugin would be skipped - Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "export"); - assertThat(plugins).containsKey("test"); + ExportContext context = CliExportContext.create(main, printer, true); - // verify the config was updated on disk - JsonObject savedConfig = PluginHelper.getPluginConfig(); - JsonObject savedPlugins = savedConfig.getMap("plugins"); - JsonObject savedPlugin = savedPlugins.getMap("test"); - assertThat(savedPlugin.get("appliesTo")) - .isInstanceOf(List.class) - .asList().contains("export"); + assertThatThrownBy(() -> context.getPlugins(null)) + .isInstanceOf(CliException.class) + .hasMessageContaining("camel-jbang-plugin-exporter not found"); } + // ---- Known PluginType appliesTo is used at runtime without config entry ---- + @Test - void oldConfigForKnownPluginEmitsWarning() throws Exception { + void knownPluginTypeAppliesToIsUsedAtRuntime() throws Exception { Path jar = tempDir.resolve("fake-test.jar"); FakePluginJar.write(jar, "test"); + // config entry for "test" plugin without explicit appliesTo JsonObject entry = buildEntry("test", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); writeConfig(entry); - StringPrinter printer = new StringPrinter(); - CamelJBangMain main = new CamelJBangMain().withPrinter(printer); + CamelJBangMain main = new CamelJBangMain(); - PluginHelper.getActivePlugins(main, null, "export"); + // PluginType.TEST declares appliesTo = {"export"}, so it should be loaded for "export" + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "export"); + assertThat(plugins).containsKey("test"); - assertThat(printer.getOutput()) - .contains("appliesTo"); + // ... but not for "run" + Map<String, Plugin> runPlugins = PluginHelper.getActivePlugins(main, null, "run"); + assertThat(runPlugins).doesNotContainKey("test"); } - // ---- CliExportContext should not resolve unrelated plugins ---- + // ---- CliExportContext should not resolve unrelated plugins (with explicit appliesTo) ---- @Test void cliExportContextDoesNotResolveUnrelatedPlugins() throws Exception { Path goodJar = tempDir.resolve("fake-good.jar"); FakePluginJar.write(goodJar, "good"); - writeConfig( - buildEntry("good", camelVersion, goodJar, Files.getLastModifiedTime(goodJar).toMillis()), - buildBrokenEntry("broken")); + JsonObject goodEntry = buildEntry("good", camelVersion, goodJar, Files.getLastModifiedTime(goodJar).toMillis()); + goodEntry.put("appliesTo", jsonArray("export")); + + // broken plugin with empty appliesTo — explicitly opted out of other commands + JsonObject brokenEntry = buildBrokenEntry("broken"); + brokenEntry.put("appliesTo", jsonArray()); + + writeConfig(goodEntry, brokenEntry); StringPrinter printer = new StringPrinter(); CamelJBangMain main = new CamelJBangMain().withPrinter(printer); ExportContext context = CliExportContext.create(main, printer, true); - // CliExportContext.create() passes "export" as target, so the broken plugin - // (whose command is "broken") is filtered out before resolution. + // the broken plugin has appliesTo [] (explicitly empty) so it is filtered out assertThatCode(() -> context.getPlugins(null)) - .as("CliExportContext should not resolve plugins unrelated to export") + .as("CliExportContext should not resolve plugins that opted out of export") .doesNotThrowAnyException(); } - // ---- addPlugins should not customize unrelated plugins for "run" ---- + // ---- addPlugins should not customize unrelated plugins ---- @Test - void addPluginsForRunDoesNotCustomizeUnrelatedPlugin() throws Exception { - writeTwoPluginConfig("alpha", "beta"); + void addPluginsForRunDoesNotCustomizePluginWithNonMatchingAppliesTo() throws Exception { + Path jarA = tempDir.resolve("fake-alpha.jar"); + FakePluginJar.write(jarA, "alpha"); + Path jarB = tempDir.resolve("fake-beta.jar"); + FakePluginJar.write(jarB, "beta"); + + JsonObject entryA = buildEntry("alpha", camelVersion, jarA, Files.getLastModifiedTime(jarA).toMillis()); + entryA.put("appliesTo", jsonArray("export")); + JsonObject entryB = buildEntry("beta", camelVersion, jarB, Files.getLastModifiedTime(jarB).toMillis()); + entryB.put("appliesTo", jsonArray("export")); + writeConfig(entryA, entryB); CamelJBangMain main = new CamelJBangMain().withPrinter(new StringPrinter()); CommandTree commandLine = new CommandTree(main); @@ -299,16 +304,23 @@ class PluginFilteringTest { PluginHelper.addPlugins(commandLine, main, "run", "foo.yaml"); - // "run" is a plugin-consuming builtin, but neither plugin's command is "run", - // so they should be filtered out assertThat(commandLine.getSubcommands()) .doesNotContainKey("alpha") .doesNotContainKey("beta"); } @Test - void addPluginsForExportDoesNotCustomizeUnrelatedPlugin() throws Exception { - writeTwoPluginConfig("alpha", "beta"); + void addPluginsForExportDoesNotCustomizePluginWithNonMatchingAppliesTo() throws Exception { + Path jarA = tempDir.resolve("fake-alpha.jar"); + FakePluginJar.write(jarA, "alpha"); + Path jarB = tempDir.resolve("fake-beta.jar"); + FakePluginJar.write(jarB, "beta"); + + JsonObject entryA = buildEntry("alpha", camelVersion, jarA, Files.getLastModifiedTime(jarA).toMillis()); + entryA.put("appliesTo", jsonArray("run")); + JsonObject entryB = buildEntry("beta", camelVersion, jarB, Files.getLastModifiedTime(jarB).toMillis()); + entryB.put("appliesTo", jsonArray("run")); + writeConfig(entryA, entryB); CamelJBangMain main = new CamelJBangMain().withPrinter(new StringPrinter()); CommandTree commandLine = new CommandTree(main); @@ -328,15 +340,19 @@ class PluginFilteringTest { Path goodJar = tempDir.resolve("fake-good.jar"); FakePluginJar.write(goodJar, "good"); + // broken plugin with empty appliesTo — explicitly not related to "run" + JsonObject brokenEntry = buildBrokenEntry("broken"); + brokenEntry.put("appliesTo", jsonArray()); + writeConfig( buildEntry("good", camelVersion, goodJar, Files.getLastModifiedTime(goodJar).toMillis()), - buildBrokenEntry("broken")); + brokenEntry); CamelJBangMain main = new CamelJBangMain().withPrinter(new StringPrinter()); CommandTree commandLine = new CommandTree(main); commandLine.addSubcommand("run", new CommandTree(new NoOpCommand())); - // addPlugins for "run" should skip the broken plugin since it doesn't match + // addPlugins for "run" should skip the broken plugin since its appliesTo excludes "run" assertThatCode(() -> PluginHelper.addPlugins(commandLine, main, "run", "foo.yaml")) .doesNotThrowAnyException(); }
