diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc index c4517c7b59e8e..4b54aeb69976e 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc @@ -1328,7 +1328,7 @@ Java:: [source,java] ---- from("direct:start") - .to("mongodb:mongoBean?database=${mongodb.database}&collection=${mongodb.collection}&operation=getDbStats") + .to("mongodb:mongoBean?database={{mongodb.database}}&collection={{mongodb.collection}}&operation=getDbStats") .to("direct:result"); ---- @@ -1339,7 +1339,7 @@ XML:: - + ---- @@ -1355,8 +1355,8 @@ YAML:: - to: uri: mongodb:mongoBean parameters: - database: "${mongodb.database}" - collection: "${mongodb.collection}" + database: "{{mongodb.database}}" + collection: "{{mongodb.collection}}" operation: getDbStats - to: uri: direct:result diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc index 9fbbf6a7e143b..1e4b984069e39 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc @@ -139,7 +139,7 @@ Java:: [source,java] ---- from("direct:start") - .to("mongodb-gridfs:mongoBean?database=${mongodb.database}&operation=findOne") + .to("mongodb-gridfs:mongoBean?database={{mongodb.database}}&operation=findOne") .to("direct:result"); ---- @@ -150,7 +150,7 @@ XML:: - + ---- @@ -166,7 +166,7 @@ YAML:: - to: uri: mongodb-gridfs:mongoBean parameters: - database: "${mongodb.database}" + database: "{{mongodb.database}}" operation: findOne - to: uri: direct:result diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc index 0d6241f663232..4ac8352ac0c27 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc @@ -86,9 +86,9 @@ Java:: + [source,java] ---- -from("direct") +from("direct:start") .setHeader("MyHeader", simple("{{spring-config:custom.property}}")) - .to("log"); + .to("log:info"); ---- XML:: @@ -96,11 +96,11 @@ XML:: [source,xml] ---- - + {{spring-config:custom.property}} - + ---- @@ -110,13 +110,13 @@ YAML:: ---- - route: from: - uri: direct + uri: direct:start steps: - setHeader: name: MyHeader simple: "{{spring-config:custom.property}}" - to: - uri: log + uri: log:info ---- ==== diff --git a/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc b/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc index 9fbbf6a7e143b..1e4b984069e39 100644 --- a/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc +++ b/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc @@ -139,7 +139,7 @@ Java:: [source,java] ---- from("direct:start") - .to("mongodb-gridfs:mongoBean?database=${mongodb.database}&operation=findOne") + .to("mongodb-gridfs:mongoBean?database={{mongodb.database}}&operation=findOne") .to("direct:result"); ---- @@ -150,7 +150,7 @@ XML:: - + ---- @@ -166,7 +166,7 @@ YAML:: - to: uri: mongodb-gridfs:mongoBean parameters: - database: "${mongodb.database}" + database: "{{mongodb.database}}" operation: findOne - to: uri: direct:result diff --git a/components/camel-mongodb/src/main/docs/mongodb-component.adoc b/components/camel-mongodb/src/main/docs/mongodb-component.adoc index c4517c7b59e8e..4b54aeb69976e 100644 --- a/components/camel-mongodb/src/main/docs/mongodb-component.adoc +++ b/components/camel-mongodb/src/main/docs/mongodb-component.adoc @@ -1328,7 +1328,7 @@ Java:: [source,java] ---- from("direct:start") - .to("mongodb:mongoBean?database=${mongodb.database}&collection=${mongodb.collection}&operation=getDbStats") + .to("mongodb:mongoBean?database={{mongodb.database}}&collection={{mongodb.collection}}&operation=getDbStats") .to("direct:result"); ---- @@ -1339,7 +1339,7 @@ XML:: - + ---- @@ -1355,8 +1355,8 @@ YAML:: - to: uri: mongodb:mongoBean parameters: - database: "${mongodb.database}" - collection: "${mongodb.collection}" + database: "{{mongodb.database}}" + collection: "{{mongodb.collection}}" operation: getDbStats - to: uri: direct:result diff --git a/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc b/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc index 0d6241f663232..4ac8352ac0c27 100644 --- a/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc +++ b/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc @@ -86,9 +86,9 @@ Java:: + [source,java] ---- -from("direct") +from("direct:start") .setHeader("MyHeader", simple("{{spring-config:custom.property}}")) - .to("log"); + .to("log:info"); ---- XML:: @@ -96,11 +96,11 @@ XML:: [source,xml] ---- - + {{spring-config:custom.property}} - + ---- @@ -110,13 +110,13 @@ YAML:: ---- - route: from: - uri: direct + uri: direct:start steps: - setHeader: name: MyHeader simple: "{{spring-config:custom.property}}" - to: - uri: log + uri: log:info ---- ==== diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 4bef77ec50044..1bb07917d249a 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -819,6 +819,11 @@ same `camel_` names by both MCP servers, `camel mcp` and `camel tui --mcp`: `cam `camel_catalog_find`, `camel_validate_source`, `camel_get_files`, `camel_write_file`, `camel_run`, `camel_control`, `camel_get_log`, `camel_get_errors`, `camel_eval_expression` and `camel_error_diagnose`. +The `camel_write_file` tool, when an integration of the project is selected and runs in dev mode, waits up to +eight seconds for the reload of the written file and answers with its outcome (`reload.status` reloaded, failed +with the cause and the validator's report, properties, or unknown), so an agent does not go on with a route that +did not load. A write with no selected integration answers as before. + In the TUI MCP server the tools `tui_catalog_doc`, `tui_validate_source`, `tui_write_file`, `tui_get_files`, `tui_control`, `tui_get_log`, `tui_get_errors` and `tui_eval_expression` were renamed to the `camel_` names above; the `tui_` prefix is now reserved for the tools that only make sense in front of the screen. Update diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java index 56f5c5b87a496..684cc67872a2d 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java @@ -359,6 +359,9 @@ public static JsonObject validate(ToolContext ctx, Path dir, String file, String return result; } + /** How long a write waits for the running integration's reload record before answering without it. */ + static final long RELOAD_WAIT_MILLIS = 8000; + /** Writes a file after validating it, as {@code camel_write_file} does; no confirmation is asked here. */ public static JsonObject writeFile(ToolContext ctx, Path dir, String file, String content, boolean validate) { Path path = resolveFile(dir, file); @@ -378,6 +381,16 @@ public static JsonObject writeFile(ToolContext ctx, Path dir, String file, Strin return result; } } + // the reload of a running integration is reported in the answer (CAMEL-24859): the reload records newer + // than the ones before the write + String processName = null; + String sinceKey = null; + boolean watch = ctx.hasProcess() && SourceValidator.isValidatableFile(file); + if (watch) { + RuntimeHelper.ProcessInfo p = RuntimeHelper.findProcess(Long.toString(ctx.pid())); + processName = p != null ? p.name() : null; + sinceKey = ReloadOutcome.latestReloadKey(ReloadOutcome.records(ctx.pid(), processName)); + } try { Files.createDirectories(path.getParent()); Files.writeString(path, content, StandardCharsets.UTF_8); @@ -390,8 +403,21 @@ public static JsonObject writeFile(ToolContext ctx, Path dir, String file, Strin result.put("directory", dir.toString()); result.put("lines", content.isEmpty() ? 0 : (int) content.lines().count()); result.put("bytes", content.getBytes(StandardCharsets.UTF_8).length); - result.put("message", "An integration running the file in dev mode reloads it now; otherwise restart the" - + " integration for the change to take effect."); + if (watch) { + JsonObject reload = ReloadOutcome.await(ctx.pid(), processName, sinceKey, RELOAD_WAIT_MILLIS); + result.put("reload", reload); + String status = reload.getString("status"); + result.put("message", switch (status) { + case "reloaded" -> "The running integration reloaded the file."; + case "properties" -> "The running integration reloaded the properties."; + case "failed" -> "The running integration FAILED to reload the file, the route is not running; fix the" + + " content and write again (see reload.message)."; + default -> "Written; " + reload.getString("message"); + }); + } else { + result.put("message", "An integration running the file in dev mode reloads it now; otherwise restart the" + + " integration for the change to take effect."); + } return result; } 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 5ea3ef07ece2f..a6df58c313176 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 @@ -158,6 +158,60 @@ public static Set declaredBeans(String content) { return names; } + /** A bean property line: key: value, under properties: of a bean. */ + private static final Pattern PROPERTY_LINE = Pattern.compile("^\\s*([A-Za-z_][\\w.-]*):\\s*(.+?)\\s*$"); + + /** + * properties: {start: ${order.first-number}} on a bean: a Simple expression, which a bean property is not; the + * placeholder is {{order.first-number}}. The runtime fails to bind the property ("Error binding property + * (start=${order.first-number})"), and camel validate said nothing (CAMEL-24857). + */ + static List validateBeanPropertyPlaceholders(String content) { + List msgs = new ArrayList<>(); + String[] lines = content.split("\n", -1); + int blockIndent = -1; + int propsIndent = -1; + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line.isBlank() || line.trim().startsWith("#")) { + continue; + } + String trimmed = line.trim(); + int indent = countLeadingSpaces(line); + if (blockIndent >= 0 && indent <= blockIndent) { + blockIndent = -1; + propsIndent = -1; + } + if (blockIndent < 0) { + if (trimmed.equals("- beans:") || trimmed.equals("beans:")) { + blockIndent = indent; + } + continue; + } + if (propsIndent >= 0 && indent <= propsIndent) { + propsIndent = -1; + } + if (trimmed.equals("properties:")) { + propsIndent = indent; + continue; + } + if (propsIndent < 0) { + continue; + } + Matcher m = PROPERTY_LINE.matcher(line); + if (m.find()) { + String value = unquote(m.group(2)); + if (YamlLines.isPropertyKeyInSimpleSyntax(value)) { + String key = YamlLines.propertyKeyOf(value); + msgs.add("Line " + (i + 1) + ": " + m.group(1) + ": " + value + " is a Simple expression, which a bean" + + " property is not evaluated as: a property placeholder is written {{key}}, so " + + m.group(1) + ": \"{{" + key + "}}\""); + } + } + } + return msgs; + } + /** * Bean references in the YAML that nothing declares, each with how to declare it. A reference that is a * {@code #class:}, {@code #type:} or {@code #bean:} value, a property placeholder, or a class name is left alone. @@ -169,6 +223,7 @@ public static List validateYamlBeanRefs(String content, BeanDeclarations if (content == null) { return msgs; } + msgs.addAll(validateBeanPropertyPlaceholders(content)); Set declared = new HashSet<>(declaredBeans(content)); if (external != null) { declared.addAll(external.names()); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java index 2c3c0977362b8..1a02f802dd6cf 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -234,6 +235,8 @@ && countLeadingSpaces(lines[k + 1]) > paramIndent) { } checkRegexOptions(errors, fullUri, i, optionLineMap); checkDynamicDirectory(errors, fullUri, i, eipName); + checkSimplePlaceholders(errors, fullUri, i, optionLineMap, eipName); + checkRequiredPathOptions(errors, fullUri, catalog, i, eipName); } catch (Exception e) { // ignore validation errors } @@ -372,6 +375,87 @@ static void checkDynamicDirectory(List errors, String fullUri, int uriLi + "?fileName=${...}), or use toD: with the whole uri, which evaluates it per message"); } + /** The EIPs whose uri is a Simple expression evaluated per message: ${...} is right there. */ + private static final Set DYNAMIC_URI_EIPS = Set.of("toD", "to-d", "wireTap", "wire-tap", "enrich", "pollEnrich", + "poll-enrich", "recipientList", "recipient-list", "routingSlip", "routing-slip", "dynamicRouter", "dynamic-router"); + + /** + * period=${welcome.period} or period=${properties:welcome.period} on a to: or from:: a Simple expression, which an + * endpoint option is not; the property placeholder is {{welcome.period}}. The runtime fails to bind the option at + * startup or on the reload, and camel validate said nothing (CAMEL-24857). toD and the other dynamic EIPs evaluate + * the uri as Simple first and are left alone. + */ + static void checkSimplePlaceholders( + List errors, String fullUri, int uriLineIdx, Map optionLineMap, String eipName) { + if (eipName != null && DYNAMIC_URI_EIPS.contains(eipName)) { + return; + } + int q = fullUri.indexOf('?'); + if (q < 0) { + return; + } + String scheme = fullUri.substring(0, Math.max(0, fullUri.indexOf(':'))); + for (String pair : fullUri.substring(q + 1).split("&")) { + int eq = pair.indexOf('='); + if (eq < 0) { + continue; + } + String name = pair.substring(0, eq); + String value = pair.substring(eq + 1); + if (!YamlLines.isPropertyKeyInSimpleSyntax(value)) { + continue; + } + String key = YamlLines.propertyKeyOf(value); + errors.add(linePrefix(optionLineMap.getOrDefault(name, uriLineIdx)) + scheme + ": " + name + "=" + value + + " is a Simple expression, which an endpoint option is not evaluated as: a property placeholder" + + " is written {{key}}, so " + name + ": \"{{" + key + "}}\""); + } + } + + /** + * uri: cron with only a schedule under parameters: the required path option name is neither in the uri nor among + * the parameters; camel run fails with "Option name is required when creating endpoint uri with syntax cron:name" + * (CAMEL-24858). Says both places it can go. + */ + static void checkRequiredPathOptions( + List errors, String fullUri, CamelCatalog catalog, int uriLineIdx, String eipName) { + int colon = fullUri.indexOf(':'); + if (colon < 0 || fullUri.contains("{{") || !"from".equals(eipName) && !"to".equals(eipName)) { + return; // only an endpoint that is created: an intercept pattern such as jms* names no destination + } + String scheme = fullUri.substring(0, colon); + int q = fullUri.indexOf('?'); + String path = q >= 0 ? fullUri.substring(colon + 1, q) : fullUri.substring(colon + 1); + if (path.startsWith("//") || !path.isEmpty()) { + // a path is given: which path option it fills is the component's business; an explicit empty authority + // (infinispan:// with a custom listener) is a choice, a bare scheme with the options under parameters is + // the slip this catches + return; + } + Set given = new HashSet<>(); + if (q >= 0) { + for (String pair : fullUri.substring(q + 1).split("&")) { + given.add(pair.contains("=") ? pair.substring(0, pair.indexOf('=')) : pair); + } + } + try { + var model = catalog.componentModel(scheme); + if (model == null) { + return; + } + for (var option : model.getEndpointOptions()) { + if (option.isRequired() && "path".equals(option.getKind()) && !given.contains(option.getName())) { + errors.add(linePrefix(uriLineIdx) + scheme + ": the required option '" + option.getName() + + "' is missing (the runtime says 'Option " + option.getName() + " is required'): write" + + " it in the uri, uri: " + scheme + ":<" + option.getName() + ">, or under parameters" + + " as " + option.getName() + ": "); + } + } + } catch (Exception e) { + // ignore: a component the catalog does not know is reported elsewhere + } + } + /** A wildcard such as *.txt as the regex .*\\.txt. */ static String toRegex(String wildcard) { StringBuilder sb = new StringBuilder(); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java new file mode 100644 index 0000000000000..5f5ba2b52a0a7 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java @@ -0,0 +1,144 @@ +/* + * 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.util.List; + +import org.apache.camel.util.StopWatch; +import org.apache.camel.util.json.JsonObject; + +/** + * What a dev-mode integration did with a file the agent wrote: the log records of the reload, read through + * {@link LogFileReader} (CAMEL-24859). A write tool answering "written" while the reload failed sends the agent on with + * a broken route; the outcome in the answer is what it acts on. + */ +public final class ReloadOutcome { + + static final String RELOADED = "Routes reloaded summary"; + static final String FAILED = "Error reloading routes from file"; + static final String PROPERTIES = "Reloading properties"; + static final String REPORT = "did not load"; + + private ReloadOutcome() { + } + + static boolean isReload(JsonObject r) { + String m = r.getStringOrDefault("message", ""); + return m.contains(RELOADED) || m.contains(FAILED) || m.contains(PROPERTIES); + } + + static String key(JsonObject r) { + return r.getStringOrDefault("time", "") + "|" + r.getStringOrDefault("message", ""); + } + + /** The key of the newest reload record, or null: what a later reload is compared against. */ + public static String latestReloadKey(List newestFirst) { + for (JsonObject r : newestFirst) { + if (isReload(r)) { + return key(r); + } + } + return null; + } + + /** + * The outcome in the records newer than the given key: status reloaded, failed or properties with the record's + * message (and, for a failure, its detail and the validator's report when the runtime printed one), or null when no + * reload has happened yet. + */ + public static JsonObject classify(List newestFirst, String sinceKey) { + for (int i = 0; i < newestFirst.size(); i++) { + JsonObject r = newestFirst.get(i); + if (!isReload(r)) { + continue; + } + if (sinceKey != null && sinceKey.equals(key(r))) { + return null; // the newest reload is the one from before the write + } + String m = r.getStringOrDefault("message", ""); + JsonObject out = new JsonObject(); + if (m.contains(FAILED)) { + out.put("status", "failed"); + StringBuilder sb = new StringBuilder(m); + String detail = r.getStringOrDefault("detail", ""); + if (!detail.isEmpty()) { + // the first lines of the cause, not the stack + for (String line : detail.split("\n")) { + if (line.startsWith("\tat ") || line.isBlank()) { + continue; + } + sb.append("\n").append(line); + if (sb.length() > 1500) { + break; + } + } + } + // the validator's report the runtime logs next to a load failure (CAMEL-24851) is older than the + // failure record in a newest-first list + for (int j = i + 1; j < Math.min(newestFirst.size(), i + 4); j++) { + String other = newestFirst.get(j).getStringOrDefault("message", ""); + if (other.contains(REPORT)) { + sb.append("\n").append(other); + String d = newestFirst.get(j).getStringOrDefault("detail", ""); + if (!d.isEmpty()) { + sb.append("\n").append(d); + } + break; + } + } + out.put("message", sb.toString()); + } else if (m.contains(RELOADED)) { + out.put("status", "reloaded"); + out.put("message", m); + } else { + out.put("status", "properties"); + out.put("message", m); + } + return out; + } + return null; + } + + /** Polls the integration's log for the reload of a just written file, up to the timeout. */ + public static JsonObject await(long pid, String name, String sinceKey, long timeoutMillis) { + StopWatch watch = new StopWatch(); + while (watch.taken() < timeoutMillis) { + JsonObject outcome = classify(records(pid, name), sinceKey); + if (outcome != null) { + return outcome; + } + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + JsonObject out = new JsonObject(); + out.put("status", "unknown"); + out.put("message", "no reload seen in the log within " + timeoutMillis / 1000 + "s: the integration may not run" + + " in dev mode, or the file is not one it watches; camel_get_log shows what it did"); + return out; + } + + @SuppressWarnings("unchecked") + static List records(long pid, String name) { + JsonObject log = LogFileReader.read(pid, name, 40, null, null); + Object lines = log.get("lines"); + return lines instanceof List ? (List) lines : List.of(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java index 0f245b07c3d7e..e572acbe2a2db 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java @@ -16,6 +16,8 @@ */ package org.apache.camel.dsl.jbang.core.commands.ai; +import java.util.Set; +import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.camel.util.StringHelper; @@ -111,6 +113,40 @@ private static String siblingValue(String line, int indent, String option) { return unquote(trimmed.substring(trimmed.indexOf(':') + 1).trim()); } + /** The roots of a Simple expression: ${body...}, ${header.x}, ${date:...}: a value evaluated as Simple is fine. */ + private static final Set SIMPLE_ROOTS = Set.of("body", "bodyAs", "mandatoryBodyAs", + "originalBody", "header", "headers", "headerAs", "exchangeProperty", "exchangeProperties", "exchangePropertyAs", + "variable", "variables", "variableAs", "exchange", "camelContext", "camelId", "routeId", "routeGroup", "stepId", + "id", "messageId", "exchangeId", "messageTimestamp", "messageHistory", "threadName", "threadId", "hostname", + "null", "date", "bean", "random", "file", "env", "sys", "sysenv", "ref", "type", "uuid", "empty", "collate", + "exception", "in", "out", + "skip", "jsonpath", "xpath", "jq", "iif", "join", "replace", "substring", "pretty", "hash", "messageAs", + "properties", "propertiesExist"); + + private static final Pattern DOTTED_KEY = Pattern.compile("^\\$\\{([A-Za-z_][\\w-]*)(?:[.-][\\w-]+)+\\}$"); + + /** + * Whether a ${...} value is a property key wearing Simple's syntax (${welcome.period}, ${properties:x}) rather than + * a Simple expression (${body.id}, ${date:now:yyyy}): the first segment is not a Simple root (CAMEL-24857). + */ + static boolean isPropertyKeyInSimpleSyntax(String value) { + if (value == null) { + return false; + } + if (value.startsWith("${properties:") && value.endsWith("}")) { + return true; + } + Matcher m = DOTTED_KEY.matcher(value); + return m.find() && !SIMPLE_ROOTS.contains(m.group(1)); + } + + /** The key of such a value: welcome.period for ${welcome.period} or ${properties:welcome.period}. */ + static String propertyKeyOf(String value) { + return value.startsWith("${properties:") + ? value.substring("${properties:".length(), value.length() - 1) + : value.substring(2, value.length() - 1); + } + static String extractEipFromLine(String trimmed) { if (trimmed.startsWith("- ")) { trimmed = trimmed.substring(2).trim(); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java new file mode 100644 index 0000000000000..8fc48abb0f910 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java @@ -0,0 +1,97 @@ +/* + * 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.util.List; + +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** CAMEL-24859: the reload of a written file, read from the log records newer than the ones before the write. */ +class ReloadOutcomeTest { + + private static final List BEFORE = List.of( + "2026-09-21 10:00:00.001 INFO 42 --- [ main] org.apache.camel.main.MainSupport : Apache Camel 4.23.0 is starting", + "2026-09-21 10:00:05.000 INFO 42 --- [rReloadStrategy] org.apache.camel.support.RouteWatcherReloadStrategy : Routes reloaded summary (total:1 started:1)", + "2026-09-21 10:00:06.000 INFO 42 --- [ timer://tick] route1 : Hello Camel"); + + private static final List FAILED = List.of( + "2026-09-21 10:00:20.000 ERROR 42 --- [rReloadStrategy] org.apache.camel.dsl.jbang.core.commands.ai.YamlLoadFailureReport : The route file did not load. camel validate yaml says what to write:", + " a.camel.yaml:", + " cron: the required option 'name' is missing", + "2026-09-21 10:00:20.001 WARN 42 --- [rReloadStrategy] org.apache.camel.support.FileWatcherResourceReloadStrategy : Error reloading routes from file: a.camel.yaml due to: Error constructing YAML node id: org.apache.camel.model.FromDefinition. This exception is ignored.", + "org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException: Error constructing YAML node id: org.apache.camel.model.FromDefinition", + "\tat org.apache.camel.dsl.yaml.YamlRoutesBuilderLoader.doConfigure(YamlRoutesBuilderLoader.java:190)", + "Caused by: java.lang.IllegalArgumentException: Option name is required when creating endpoint uri with syntax cron:name", + "\tat org.apache.camel.support.component.AbstractApiEndpoint.x(Foo.java:1)"); + + private static final List RELOADED = List.of( + "2026-09-21 10:00:40.000 INFO 42 --- [rReloadStrategy] org.apache.camel.support.RouteWatcherReloadStrategy : Routes reloaded summary (total:1 started:1)", + "2026-09-21 10:00:41.000 INFO 42 --- [ timer://tick] route1 : Hello again"); + + @SuppressWarnings("unchecked") + private static List records(List... parts) { + List all = new java.util.ArrayList<>(); + for (List p : parts) { + all.addAll(p); + } + return (List) (List) List + .copyOf(LogFileReader.build(all, 40, null, null, new JsonObject()).getCollection("lines")); + } + + @Test + void theNewestReloadBeforeTheWriteIsTheBaseline() { + String since = ReloadOutcome.latestReloadKey(records(BEFORE)); + assertEquals("10:00:05.000|Routes reloaded summary (total:1 started:1)", since); + assertNull(ReloadOutcome.classify(records(BEFORE), since), "no reload since the write yet"); + } + + @Test + void aFailedReloadCarriesTheCauseAndTheValidatorsReport() { + String since = ReloadOutcome.latestReloadKey(records(BEFORE)); + JsonObject out = ReloadOutcome.classify(records(BEFORE, FAILED), since); + assertEquals("failed", out.getString("status")); + String m = out.getString("message"); + assertTrue(m.startsWith("Error reloading routes from file: a.camel.yaml"), m); + assertTrue(m.contains("Caused by: java.lang.IllegalArgumentException: Option name is required"), m); + assertTrue(m.contains("camel validate yaml says what to write"), "the runtime's report is carried along: " + m); + assertTrue(m.contains("the required option 'name' is missing"), m); + assertTrue(!m.contains("\tat "), "no stack frames: " + m); + } + + @Test + void aPropertiesReloadIsReportedAsSuch() { + List props = List.of( + "2026-09-21 10:00:30.000 INFO 42 --- [rReloadStrategy] org.apache.camel.main.DefaultConfigurationConfigurer : Reloading properties: file:application.properties"); + String since = ReloadOutcome.latestReloadKey(records(BEFORE)); + JsonObject out = ReloadOutcome.classify(records(BEFORE, props), since); + assertEquals("properties", out.getString("status")); + assertEquals("Reloading properties: file:application.properties", out.getString("message")); + } + + @Test + void aReloadAfterTheFailureIsReloaded() { + String since = ReloadOutcome.latestReloadKey(records(BEFORE)); + JsonObject out = ReloadOutcome.classify(records(BEFORE, FAILED, RELOADED), since); + assertEquals("reloaded", out.getString("status")); + assertEquals("Routes reloaded summary (total:1 started:1)", out.getString("message")); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java new file mode 100644 index 0000000000000..36822c18c4fdc --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.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.util.List; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * CAMEL-24857 and CAMEL-24858: a ${...} placeholder where {{...}} is meant (an endpoint option, a bean property), and a + * required endpoint path option that is missing. + */ +class SourceValidatorPlaceholderTest { + + private static final CamelCatalog catalog = new DefaultCamelCatalog(); + + @Test + void aSimplePlaceholderInAnEndpointOptionSaysToWriteAPropertyPlaceholder() { + String yaml = """ + - route: + from: + uri: timer + parameters: + timerName: welcome + period: "${welcome.period}" + steps: + - to: + uri: "log:done?level=${properties:log.level}" + """; + List errors = SourceValidator.validateYamlEndpoints(yaml, catalog); + assertThat(errors).anyMatch(e -> e.startsWith("Line 6: timer: period=${welcome.period} is a Simple expression") + && e.endsWith("so period: \"{{welcome.period}}\"")); + assertThat(errors).anyMatch(e -> e.startsWith("Line 9: log: level=${properties:log.level} is a Simple expression") + && e.endsWith("so level: \"{{log.level}}\"")); + } + + @Test + void aDynamicEipAndAPropertyPlaceholderAreFine() { + String yaml = """ + - route: + from: + uri: timer + parameters: + timerName: welcome + period: "{{welcome.period}}" + steps: + - toD: + uri: "log:${header.target}?level=INFO" + """; + assertThat(SourceValidator.validateYamlEndpoints(yaml, catalog)).noneMatch(e -> e.contains("Simple expression")); + } + + @Test + void aMissingRequiredPathOptionIsReportedWithBothPlaces() { + String yaml = """ + - route: + from: + uri: cron + parameters: + schedule: 0/10 * * * * ? + steps: + - to: + uri: log:done + """; + List errors = SourceValidator.validateYamlEndpoints(yaml, catalog); + assertThat(errors).anyMatch(e -> e.startsWith("Line 3: cron: the required option 'name' is missing") + && e.contains("uri: cron:") && e.contains("name: ")); + // given in the uri, or as a parameter: fine + assertThat(SourceValidator.validateYamlEndpoints(yaml.replace("uri: cron", "uri: cron:report"), catalog)) + .noneMatch(e -> e.contains("required option")); + assertThat(SourceValidator.validateYamlEndpoints(yaml.replace("schedule: 0/10", "name: report\n schedule: 0/10"), + catalog)) + .noneMatch(e -> e.contains("required option")); + } + + @Test + void aSimplePlaceholderInABeanPropertySaysToWriteAPropertyPlaceholder() { + String yaml = """ + - beans: + - name: orderNumber + type: "camel.example.OrderNumber" + properties: + start: ${order.first-number} + label: "{{order.label}}" + """; + List msgs = BeanRefChecks.validateBeanPropertyPlaceholders(yaml); + assertThat(msgs).hasSize(1); + assertThat(msgs.get(0)).startsWith("Line 5: start: ${order.first-number} is a Simple expression") + .endsWith("so start: \"{{order.first-number}}\""); + } +}