This is an automated email from the ASF dual-hosted git repository.
Croway pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.22.x by this push:
new 8db61cf10f58 [camel-4.22.x] CAMEL-25490: camel-yaml-io - dump a
top-level onException as top-level, not as a step of every route (#27610)
8db61cf10f58 is described below
commit 8db61cf10f58096f05a2eff1c9104c5b23027b43
Author: Federico Mariani <[email protected]>
AuthorDate: Sun Oct 11 10:36:17 2026 +0200
[camel-4.22.x] CAMEL-25490: camel-yaml-io - dump a top-level onException as
top-level, not as a step of every route (#27610)
* CAMEL-25490: camel-yaml-io - dump a top-level onException as top-level,
not as a step of every route
When routes are prepared, the context scoped onExceptions (the top-level
onException of a YAML file, the onException of a RouteBuilder, and those
of the route configurations that apply) are added to the outputs of each
route. The YAML dumper wrote them as steps of every route, which the YAML
DSL schema rejects, so camel validate normalize turned a valid file into
an invalid one.
The YAML dumper now leaves the context scoped onExceptions out of the
route steps, and writes those that do not belong to a route configuration
once, as top-level onException entries before the routes.
(cherry picked from commit 0f27208b49e730980aa064479e2f7422d05f3512)
* CAMEL-25490: camel-core-engine - dump the routes of all the files
together when they go into one YAML file
When the routes are dumped into a single file (dumpRoutesOutput is a file,
as camel validate normalize does), the routes of each source file were
dumped separately and appended, so the top-level onException of a second
file came after the routes of the first file, which the YAML DSL loader
rejects. The routes of all the files are now dumped together, so every
top-level onException comes before the first route.
(cherry picked from commit 836b39a73ca34f341df88d24b73e7d56f1eed45d)
* CAMEL-25490: camel-yaml-io - guard against a null onException list of a
route configuration, and do not reload the routes in a started context in the
test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-25490: camel-core-engine - dump the routes of one file into a file
with their source, and warn when a top-level onException applies to the routes
of other files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-25490: trim overlapping test assertions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../camel/impl/DefaultDumpRoutesStrategy.java | 60 ++++-
.../org/apache/camel/yaml/LwModelToYAMLDumper.java | 63 +++++
dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml | 5 +
.../camel/dsl/yaml/OnExceptionYamlDumpTest.groovy | 268 +++++++++++++++++++++
4 files changed, 394 insertions(+), 2 deletions(-)
diff --git
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
index ef40fb12617f..f75614c8ac63 100644
---
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
+++
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultDumpRoutesStrategy.java
@@ -23,6 +23,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -34,6 +35,8 @@ import org.apache.camel.NamedNode;
import org.apache.camel.model.BeanFactoryDefinition;
import org.apache.camel.model.DataFormatDefinition;
import org.apache.camel.model.Model;
+import org.apache.camel.model.OnExceptionDefinition;
+import org.apache.camel.model.ProcessorDefinition;
import org.apache.camel.model.RouteConfigurationDefinition;
import org.apache.camel.model.RouteConfigurationsDefinition;
import org.apache.camel.model.RouteDefinition;
@@ -509,14 +512,24 @@ public class DefaultDumpRoutesStrategy extends
ServiceSupport implements DumpRou
if (include.contains("*") || include.contains("all") ||
include.contains("routes")) {
int size = model.getRouteDefinitions().size();
if (size > 0) {
- Map<Resource, RoutesDefinition> groups = new LinkedHashMap<>();
+ List<RouteDefinition> eligible = new ArrayList<>();
for (RouteDefinition route : model.getRouteDefinitions()) {
if ((route.isRest() != null && route.isRest()) ||
(route.isTemplate() != null && route.isTemplate())) {
// skip routes that are rest/templates
continue;
}
+ eligible.add(route);
+ }
+ // the routes of several resources dumped into the same file
are dumped together, so the top-level
+ // elements of the resources (such as onException) come before
the first route
+ boolean merge = outputFileName != null &&
countResources(eligible) > 1;
+ if (merge) {
+ warnIfOnExceptionScopeWidens(model, eligible);
+ }
+ Map<Resource, RoutesDefinition> groups = new LinkedHashMap<>();
+ for (RouteDefinition route : eligible) {
Resource res = route.getResource();
- if (res == null) {
+ if (res == null || merge) {
res = dummy;
}
RoutesDefinition routes = groups.computeIfAbsent(res,
resource -> new RoutesDefinition());
@@ -541,6 +554,49 @@ public class DefaultDumpRoutesStrategy extends
ServiceSupport implements DumpRou
}
+ private static int countResources(List<RouteDefinition> routes) {
+ Set<Resource> resources = new HashSet<>();
+ for (RouteDefinition route : routes) {
+ resources.add(route.getResource());
+ }
+ return resources.size();
+ }
+
+ /**
+ * A top-level onException applies only to the routes of its own resource,
but once the routes of several resources
+ * are dumped into the same file, it applies to all the routes of the file
when the file is loaded.
+ */
+ private void warnIfOnExceptionScopeWidens(Model model,
List<RouteDefinition> routes) {
+ Set<String> locations = new LinkedHashSet<>();
+ for (RouteDefinition route : routes) {
+ for (ProcessorDefinition<?> output : route.getOutputs()) {
+ if (output instanceof OnExceptionDefinition oe &&
!oe.isRouteScoped()
+ && !isInRouteConfiguration(model, oe)) {
+ Resource res = route.getResource();
+ locations.add(res != null && res.getLocation() != null ?
res.getLocation() : "routes without a source");
+ }
+ }
+ }
+ if (!locations.isEmpty()) {
+ LOG.warn("The routes of several resources are dumped together into
{}: the top-level onException of {}"
+ + " applies to all the routes of the dump when it is
loaded",
+ outputFileName, String.join(", ", locations));
+ }
+ }
+
+ private static boolean isInRouteConfiguration(Model model,
OnExceptionDefinition oe) {
+ for (RouteConfigurationDefinition config :
model.getRouteConfigurationDefinitions()) {
+ if (config.getOnExceptions() != null) {
+ for (OnExceptionDefinition o : config.getOnExceptions()) {
+ if (o == oe) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
protected void doDumpRoutesAsJava(CamelContext camelContext) {
final ModelToJavaDumper dumper =
PluginHelper.getModelToJavaDumper(camelContext);
final Model model =
camelContext.getCamelContextExtension().getContextPlugin(Model.class);
diff --git
a/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/LwModelToYAMLDumper.java
b/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/LwModelToYAMLDumper.java
index 12fd0707bda0..b675e5f38599 100644
---
a/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/LwModelToYAMLDumper.java
+++
b/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/LwModelToYAMLDumper.java
@@ -26,6 +26,7 @@ import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
+import java.util.function.Function;
import org.apache.camel.CamelContext;
import org.apache.camel.CamelContextAware;
@@ -37,6 +38,8 @@ import org.apache.camel.model.BeanFactoryDefinition;
import org.apache.camel.model.DataFormatDefinition;
import org.apache.camel.model.ExpressionNode;
import org.apache.camel.model.FromDefinition;
+import org.apache.camel.model.Model;
+import org.apache.camel.model.OnExceptionDefinition;
import org.apache.camel.model.OptionalIdentifiedDefinition;
import org.apache.camel.model.RouteConfigurationDefinition;
import org.apache.camel.model.RouteConfigurationsDefinition;
@@ -105,7 +108,29 @@ public class LwModelToYAMLDumper implements
ModelToYAMLDumper {
extractor.accept(route);
}
+ // context scoped onExceptions (a top-level onException in the YAML
DSL, an onException in the configure method
+ // of a RouteBuilder, or one of a route configuration) are added to
the outputs of each route when the routes
+ // are prepared, but the YAML DSL does not allow them in the steps of
a route
+ List<OnExceptionDefinition> contextScoped = new ArrayList<>();
+ if (definition instanceof RoutesDefinition routes) {
+ routes.getRoutes().forEach(route ->
collectContextScopedOnExceptions(route, contextScoped));
+ } else if (definition instanceof RouteDefinition route) {
+ collectContextScopedOnExceptions(route, contextScoped);
+ }
+ // the onExceptions of a route configuration are dumped with the route
configuration, the others are dumped
+ // once, as top-level onExceptions before the routes
+ List<OnExceptionDefinition> topLevel = new ArrayList<>(contextScoped);
+ topLevel.removeIf(oe -> isInRouteConfiguration(context, oe));
+
YamlModelWriter writer = new YamlModelWriter() {
+ @Override
+ protected <T> void doWriteOutputs(JsonObject jo, List<T> list,
Function<T, JsonObject> writer) {
+ if (list != null && !contextScoped.isEmpty()) {
+ list = list.stream().filter(o ->
!containsInstance(contextScoped, o)).toList();
+ }
+ super.doWriteOutputs(jo, list, writer);
+ }
+
@Override
protected void doWriteOptionalIdentifiedDefinitionAttributes(
JsonObject jo, OptionalIdentifiedDefinition<?> def) {
@@ -176,6 +201,9 @@ public class LwModelToYAMLDumper implements
ModelToYAMLDumper {
List<JsonObject> roots = new ArrayList<>();
try {
+ for (OnExceptionDefinition oe : topLevel) {
+ roots.add(writer.writeOnExceptionDefinition(oe));
+ }
if (definition instanceof RoutesDefinition rd) {
for (RouteDefinition route : rd.getRoutes()) {
roots.add(writer.writeRouteDefinition(route));
@@ -265,6 +293,41 @@ public class LwModelToYAMLDumper implements
ModelToYAMLDumper {
return buffer.toString();
}
+ /**
+ * Collects the context scoped onExceptions that were added to the outputs
of the route when it was prepared
+ */
+ private static void collectContextScopedOnExceptions(RouteDefinition
route, List<OnExceptionDefinition> answer) {
+ for (var output : route.getOutputs()) {
+ if (output instanceof OnExceptionDefinition oe &&
!oe.isRouteScoped() && !containsInstance(answer, oe)) {
+ answer.add(oe);
+ }
+ }
+ }
+
+ private static boolean isInRouteConfiguration(CamelContext context,
OnExceptionDefinition oe) {
+ Model model = context != null ?
context.getCamelContextExtension().getContextPlugin(Model.class) : null;
+ if (model != null) {
+ for (RouteConfigurationDefinition config :
model.getRouteConfigurationDefinitions()) {
+ if (containsInstance(config.getOnExceptions(), oe)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean containsInstance(List<?> list, Object instance) {
+ if (list == null) {
+ return false;
+ }
+ for (Object o : list) {
+ if (o == instance) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Extract all XML namespaces from the expressions in the route
*
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
b/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
index 117e1070d60e..2964ab9ca2c8 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
@@ -90,6 +90,11 @@
</exclusions>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-yaml-io</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-main</artifactId>
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/OnExceptionYamlDumpTest.groovy
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/OnExceptionYamlDumpTest.groovy
new file mode 100644
index 000000000000..7530973761c6
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/OnExceptionYamlDumpTest.groovy
@@ -0,0 +1,268 @@
+/*
+ * 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
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+import org.apache.camel.CamelContext
+import org.apache.camel.NamedNode
+
+import org.apache.camel.dsl.yaml.support.YamlTestSupport
+import org.apache.camel.impl.DefaultCamelContext
+import org.apache.camel.impl.DefaultDumpRoutesStrategy
+import org.apache.camel.model.OnExceptionDefinition
+import org.apache.camel.model.RoutesDefinition
+import org.apache.camel.spi.ModelToYAMLDumper
+import org.apache.camel.spi.Resource
+import org.apache.camel.yaml.LwModelToYAMLDumper
+import org.apache.logging.log4j.Level
+import org.apache.logging.log4j.LogManager
+import org.apache.logging.log4j.core.LogEvent
+import org.apache.logging.log4j.core.Logger
+import org.apache.logging.log4j.core.appender.AbstractAppender
+import org.apache.logging.log4j.core.config.Property
+
+class OnExceptionYamlDumpTest extends YamlTestSupport {
+
+ def "top-level onException is dumped as top-level (started: #started)"() {
+ setup:
+ loadRoutes """
+ - onException:
+ exception:
+ - java.lang.Exception
+ handled:
+ constant:
+ expression: "true"
+ steps:
+ - to: mock:error
+ - route:
+ id: first
+ from:
+ uri: direct:first
+ steps:
+ - to: mock:first
+ - route:
+ id: second
+ from:
+ uri: direct:second
+ steps:
+ - to: mock:second
+ """
+ if (started) {
+ context.start()
+ }
+ when:
+ def yaml = dumpRoutes()
+ // a route dumped alone (JMX dumpRouteAsYaml, the route-dump dev
console)
+ def single = new LwModelToYAMLDumper()
+ .dumpModelAsYaml(context,
context.getRouteDefinition('first'), false, true, false, false)
+ if (started) {
+ // reload the dump in a new context, not in the started one (a
stopped context has no type converter
+ // to build the endpoint uris of the dump with)
+ context.close()
+ context = new DefaultCamelContext()
+ }
+ then:
+ // written once, before the routes, and not in the steps of each
route
+ yaml.startsWith('- onException:')
+ yaml.split('onException:', -1).length == 2
+ // also before a route dumped alone
+ single.startsWith('- onException:')
+ single.contains('id: first')
+ !single.contains('id: second')
+ when:
+ // the dump is valid for the YAML DSL schema, and the onException
still applies to all the routes
+
context.removeRouteDefinitions(List.copyOf(context.routeDefinitions))
+ loadRoutes yaml
+ then:
+ ['first', 'second'].each {
+ def oe = context.getRouteDefinition(it).outputs[0]
+ assert oe instanceof OnExceptionDefinition
+ assert !oe.routeScoped
+ assert oe.exceptions == ['java.lang.Exception']
+ }
+ where:
+ started << [false, true]
+ }
+
+ def "routeConfiguration onException is not dumped in the routes"() {
+ setup:
+ loadRoutes """
+ - routeConfiguration:
+ onException:
+ - onException:
+ exception:
+ - java.lang.Exception
+ handled:
+ constant:
+ expression: "true"
+ steps:
+ - to: mock:error
+ """
+ loadRoutes """
+ - route:
+ id: myRoute
+ from:
+ uri: direct:start
+ steps:
+ - to: mock:result
+ """
+ expect:
+ context.getRouteDefinition('myRoute').outputs[0] instanceof
OnExceptionDefinition
+ when:
+ // the onException is dumped with the route configuration
+ def yaml = dumpRoutes()
+ then:
+ !yaml.contains('onException')
+ yaml.contains('id: myRoute')
+ when:
+
context.removeRouteDefinition(context.getRouteDefinition('myRoute'))
+ loadRoutes yaml
+ then:
+ context.getRouteDefinition('myRoute') != null
+ }
+
+ def "onException of another file is dumped before the routes in one
file"() {
+ setup:
+ // two files normalized into one, as camel validate normalize
a.yaml b.yaml does: the onException of the
+ // second file must come before the route of the first one, or the
dump does not load
+ loadRoutes """
+ - route:
+ id: first
+ from:
+ uri: direct:first
+ steps:
+ - to: mock:first
+ """, """
+ - onException:
+ exception:
+ - java.lang.Exception
+ handled:
+ constant:
+ expression: "true"
+ steps:
+ - to: mock:error
+ - route:
+ id: second
+ from:
+ uri: direct:second
+ steps:
+ - to: mock:second
+ """
+ def output =
Files.createTempDirectory('normalize').resolve('normalized.yaml')
+ def warnings = []
+ when:
+ dumpRoutesToFile(output, [], warnings)
+ def yaml = Files.readString(output)
+ then:
+ yaml.startsWith('- onException:')
+ yaml.split('onException:', -1).length == 2
+ yaml.contains('id: first')
+ yaml.contains('id: second')
+ // the user is told that the onException of the second file
applies to the route of the first file as well
+ warnings.size() == 1
+ warnings[0].contains('route-1.yaml')
+ !warnings[0].contains('route-0.yaml')
+ !context.getRouteDefinition('first').outputs.any { it instanceof
OnExceptionDefinition }
+ when:
+ // the combined dump is valid for the YAML DSL schema and loads
+
context.removeRouteDefinitions(List.copyOf(context.routeDefinitions))
+ loadRoutes yaml
+ then:
+ context.routeDefinitions.size() == 2
+ // the top-level onException of the second file now applies to the
route of the first file as well
+ context.getRouteDefinition('first').outputs[0] instanceof
OnExceptionDefinition
+ cleanup:
+ output.toFile().delete()
+ output.parent.toFile().delete()
+ }
+
+ def "routes of one file dumped into one file keep their source"() {
+ setup:
+ loadRoutes """
+ - onException:
+ exception:
+ - java.lang.Exception
+ handled:
+ constant:
+ expression: "true"
+ steps:
+ - to: mock:error
+ - route:
+ id: first
+ from:
+ uri: direct:first
+ steps:
+ - to: mock:first
+ """
+ def output =
Files.createTempDirectory('normalize').resolve('normalized.yaml')
+ def resources = []
+ def warnings = []
+ when:
+ dumpRoutesToFile(output, resources, warnings)
+ then:
+ // the routes are dumped with their resource, so the log shows its
source, and there is no scope to warn about
+ resources.size() == 1
+ resources[0].location == 'route-0.yaml'
+ warnings.isEmpty()
+ cleanup:
+ output.toFile().delete()
+ output.parent.toFile().delete()
+ }
+
+ def dumpRoutesToFile(Path output, List resources, List warnings) {
+ def appender = new AbstractAppender('dump-routes', null, null, false,
Property.EMPTY_ARRAY) {
+ @Override
+ void append(LogEvent event) {
+ if (event.level == Level.WARN) {
+ warnings.add(event.message.formattedMessage)
+ }
+ }
+ }
+ appender.start()
+ def logger = (Logger) LogManager.getLogger(DefaultDumpRoutesStrategy)
+ logger.addAppender(appender)
+ try {
+ def dump = new DefaultDumpRoutesStrategy() {
+ @Override
+ protected void doDumpYaml(
+ CamelContext camelContext, NamedNode node, Resource
resource, ModelToYAMLDumper dumper,
+ String kind, StringBuilder sbLocal, StringBuilder
sbLog) {
+ resources.add(resource)
+ super.doDumpYaml(camelContext, node, resource, dumper,
kind, sbLocal, sbLog)
+ }
+ }
+ dump.camelContext = context
+ dump.include = 'routes'
+ dump.log = false
+ dump.uriAsParameters = true
+ dump.output = output.toString()
+ dump.dumpRoutes('yaml')
+ } finally {
+ logger.removeAppender(appender)
+ appender.stop()
+ }
+ }
+
+ def dumpRoutes() {
+ // as the routes are dumped by camel.main.dumpRoutes, which camel
validate normalize uses
+ def routes = new RoutesDefinition()
+ routes.routes.addAll(context.routeDefinitions)
+ return new LwModelToYAMLDumper().dumpModelAsYaml(context, routes,
false, true, false, false)
+ }
+}