This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new a60992b3efaa chore: camel-jbang - About has the operations of an
OpenAPI contract the REST DSL serves, an AI tool icon, and tui_send_message
returns the answer of a direct route (#29596)
a60992b3efaa is described below
commit a60992b3efaa592615dd57e30e304c8115cf9c37
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 11 08:02:27 2026 +0100
chore: camel-jbang - About has the operations of an OpenAPI contract the
REST DSL serves, an AI tool icon, and tui_send_message returns the answer of a
direct route (#29596)
* chore: camel-jbang - About has the operations of an OpenAPI contract the
REST DSL serves, an AI tool icon, and tui_send_message returns the answer of a
direct route
* chore: camel-jbang - In of About has the icon of the kind of system a
consumer the icons do not know is, as Out has it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../ROOT/pages/camel-jbang-tui-observe.adoc | 2 +-
.../jbang/core/commands/ai/JavaRouteReader.java | 5 +
.../jbang/core/commands/ai/ProjectOverview.java | 4 +-
.../dsl/jbang/core/commands/ai/ProjectRoutes.java | 9 ++
.../dsl/jbang/core/commands/ai/RestContracts.java | 151 +++++++++++++++++++++
.../jbang/core/commands/ai/RouteComponents.java | 4 +
.../camel/dsl/jbang/core/common/RuntimeHelper.java | 16 +++
.../jbang/core/commands/ai/RestContractsTest.java | 129 ++++++++++++++++++
.../dsl/jbang/core/common/RuntimeHelperTest.java | 24 ++++
.../dsl/jbang/core/commands/tui/AboutView.java | 34 ++++-
.../dsl/jbang/core/commands/tui/McpFacade.java | 11 +-
.../core/commands/tui/TuiToolDefinitions.java | 3 +-
.../jbang/core/commands/tui/TuiToolRegistry.java | 3 +-
.../src/main/resources/tui/help/overview.md | 3 +-
.../dsl/jbang/core/commands/tui/AboutViewTest.java | 55 ++++++++
15 files changed, 443 insertions(+), 10 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index 4f1c663294c4..b4f00f5c1ec4 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -29,7 +29,7 @@ route file (a YAML `#` comment, a Javadoc or `//` comment in
Java, an XML commen
README, else the descriptions of the routes, else the overview an AI wrote in
`camel-summary.md`. *r* opens the README,
and *s* the summary of the project.
* *In*, *Routes* and *Out* say what starts it (a timer or a cron with the
period or schedule of its properties, its
-profile applied), its routes (a route called with `direct` sits under its
caller with `โณ`,
+profile applied; the operations of an OpenAPI contract the REST DSL serves,
from the contract), its routes (a route called with `direct` sits under its
caller with `โณ`,
one handed off with `seda` or a `wireTap` with `โข`) and what it talks to, with
the exchanges since it started and a sparkline of the
last 5 minutes. *Up* and *Down* select a line, and *Enter* opens a route
drilled down on the Diagram, or an endpoint with
its details on the Endpoint tab.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
index 6e0fac5ef8a6..9cbe963ebed2 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
@@ -310,6 +310,11 @@ final class JavaRouteReader {
}
private void rest(RestDefinition rest) {
+ if (rest.getOpenApi() != null && rest.getOpenApi().getSpecification()
!= null) {
+ // contract first: the operations are in the contract, which the
overview reads
+
routes.add(RestContracts.contract(rest.getOpenApi().getSpecification(),
rest.getPath(), file,
+ Math.max(1, rest.getLineNumber()), "java"));
+ }
for (VerbDefinition v : rest.getVerbs()) {
List<Endpoint> produces = new ArrayList<>();
if (v.getTo() != null) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java
index a75c786b7553..61286b446972 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java
@@ -178,6 +178,8 @@ public final class ProjectOverview {
for (Map.Entry<String, String> e : sources.entrySet()) {
routes.addAll(ProjectRoutes.parse(e.getKey(), e.getValue(),
catalog, constants));
}
+ // the operations of a contract the REST DSL serves, from the contract
+ routes = new ArrayList<>(RestContracts.expand(dir, routes, catalog));
if (routes.size() > MAX_ROUTES) {
routes = new ArrayList<>(routes.subList(0, MAX_ROUTES));
}
@@ -198,7 +200,7 @@ public final class ProjectOverview {
* (SaaS, chat, documents), http, cloud, schedule, internal (plumbing
inside Camel such as direct, seda, log, bean)
* or other.
*/
- static String category(String scheme, CamelCatalog catalog) {
+ public static String category(String scheme, CamelCatalog catalog) {
String s = scheme.toLowerCase(Locale.ROOT);
if ("direct".equals(s) || IN_MEMORY.contains(s) || "kamelet".equals(s)
|| s.startsWith("dynamic")) {
return "internal";
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
index 16575063aa77..48fd5aa64754 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
@@ -697,6 +697,10 @@ public final class ProjectRoutes {
private void rest(MappingNode m, int line) {
String base = str(value(m, "path"));
+ if (child(m, "openApi") instanceof MappingNode oa && str(value(oa,
"specification")) != null) {
+ // contract first: the operations are in the contract, which
the overview reads
+ routes.add(RestContracts.contract(str(value(oa,
"specification")), base, file, line, "yaml"));
+ }
for (NodeTuple t : m.getValue()) {
String verb = scalar(t.getKeyNode());
if (verb == null || !REST_VERBS.contains(verb) ||
!(t.getValueNode() instanceof SequenceNode ops)) {
@@ -1129,6 +1133,11 @@ public final class ProjectRoutes {
private void rest(Element rest) {
String base = attr(rest, "path");
+ Element openApi = firstChild(rest, "openApi");
+ if (openApi != null && attr(openApi, "specification") != null) {
+ routes.add(RestContracts.contract(attr(openApi,
"specification"), base, file, lineOf("rest", null),
+ "xml"));
+ }
NodeList children = rest.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
if (!(children.item(i) instanceof Element op) ||
!REST_VERBS.contains(localName(op))) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RestContracts.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RestContracts.java
new file mode 100644
index 000000000000..830962454978
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RestContracts.java
@@ -0,0 +1,151 @@
+/*
+ * 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.io.IOException;
+import java.io.Reader;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectRoutes.Endpoint;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectRoutes.Route;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+/**
+ * The operations of a REST DSL that serves an OpenAPI contract (contract
first: {@code rest: openApi: specification:
+ * stock-api.json}). The routes do not name them: each operation is served by
the route {@code direct:<operationId>},
+ * and the paths and methods are in the contract. The readers of the sources
record the contract as one route of kind
+ * {@link #KIND}; the overview, which knows the project directory, reads the
contract and puts one REST operation per
+ * operation of it in its place, as the REST DSL written out would have.
+ */
+public final class RestContracts {
+
+ /** The kind of the route a reader records for a REST DSL that serves a
contract. */
+ static final String KIND = "openApiContract";
+
+ private static final Set<String> METHODS = Set.of("get", "post", "put",
"patch", "delete", "head", "options");
+
+ private RestContracts() {
+ }
+
+ /**
+ * The route a reader records for a contract: its specification as the
description, the path of the REST DSL as its
+ * path.
+ */
+ static Route contract(String specification, String basePath, String file,
int line, String format) {
+ return new Route(
+ null, KIND, specification, null, file, line, format, false,
null, List.of(), List.of(), 1, -1, 0,
+ null, basePath, false, null);
+ }
+
+ /** The routes with each contract in the place of its operations; a
contract that cannot be read has none. */
+ static List<Route> expand(Path dir, List<Route> routes, CamelCatalog
catalog) {
+ if (routes.stream().noneMatch(r -> KIND.equals(r.kind()))) {
+ return routes;
+ }
+ List<Route> answer = new ArrayList<>();
+ for (Route r : routes) {
+ if (KIND.equals(r.kind())) {
+ answer.addAll(operations(dir, r, catalog));
+ } else {
+ answer.add(r);
+ }
+ }
+ return answer;
+ }
+
+ private static List<Route> operations(Path dir, Route contract,
CamelCatalog catalog) {
+ Map<?, ?> spec = read(dir, contract.file(), contract.description());
+ if (spec == null || !(spec.get("paths") instanceof Map<?, ?> paths)) {
+ return List.of();
+ }
+ // the base path of the contract (servers: url:
http://localhost:8080/api), under the path of the REST DSL
+ String base = ProjectRoutes.joinPath(contract.restPath(),
serverPath(spec));
+ List<Route> answer = new ArrayList<>();
+ for (Map.Entry<?, ?> p : paths.entrySet()) {
+ if (!(p.getValue() instanceof Map<?, ?> ops)) {
+ continue;
+ }
+ for (Map.Entry<?, ?> o : ops.entrySet()) {
+ String method =
String.valueOf(o.getKey()).toLowerCase(Locale.ROOT);
+ if (!METHODS.contains(method) || !(o.getValue() instanceof
Map<?, ?> op)
+ || !(op.get("operationId") instanceof String
operationId)) {
+ continue;
+ }
+ List<Endpoint> produces = new ArrayList<>();
+ produces.add(ProjectRoutes.endpoint("direct:" + operationId,
null, false, catalog));
+ Object summary = op.get("summary");
+ answer.add(new Route(
+ null, "rest", summary instanceof String s ? s : null,
null, contract.file(), contract.line(),
+ contract.format(), false, null, List.of(), produces,
1, -1, 0, method.toUpperCase(Locale.ROOT),
+ ProjectRoutes.joinPath(base,
String.valueOf(p.getKey())), false, null));
+ }
+ }
+ return answer;
+ }
+
+ /** The path of the first server of the contract (/api of
http://localhost:8080/api), or none. */
+ static String serverPath(Map<?, ?> spec) {
+ if (spec.get("servers") instanceof List<?> servers &&
!servers.isEmpty()
+ && servers.get(0) instanceof Map<?, ?> server &&
server.get("url") instanceof String url) {
+ try {
+ String path = URI.create(url).getPath();
+ return path == null || "/".equals(path) ? null : path;
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+ // Swagger 2
+ return spec.get("basePath") instanceof String b ? b : null;
+ }
+
+ /** The contract, JSON or YAML, by its path in the project or next to the
route file; null when not found. */
+ private static Map<?, ?> read(Path dir, String routeFile, String
specification) {
+ if (dir == null || specification == null) {
+ return null;
+ }
+ String name = specification.replaceFirst("^(classpath|file):", "");
+ List<Path> candidates = new ArrayList<>();
+ candidates.add(dir.resolve(name));
+ Path routeDir = routeFile != null ? dir.resolve(routeFile).getParent()
: null;
+ if (routeDir != null) {
+ candidates.add(routeDir.resolve(name));
+ }
+ for (Path f : candidates) {
+ if (f.normalize().startsWith(dir.normalize()) &&
Files.isRegularFile(f)) {
+ try (Reader r = Files.newBufferedReader(f,
StandardCharsets.UTF_8)) {
+ // JSON is YAML too
+ Object doc = new Yaml(new SafeConstructor(new
LoaderOptions())).load(r);
+ return doc instanceof Map<?, ?> m ? m : null;
+ } catch (IOException | RuntimeException e) {
+ return null;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteComponents.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteComponents.java
index 1be4b9b3c4ab..829081bf71c6 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteComponents.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteComponents.java
@@ -64,6 +64,10 @@ public final class RouteComponents {
if ("rest".equals(r.kind())) {
// the rest DSL runs on the rest component
add(answer, "rest", catalog);
+ } else if (RestContracts.KIND.equals(r.kind())) {
+ // and serves a contract with rest-openapi
+ add(answer, "rest", catalog);
+ add(answer, "rest-openapi", catalog);
}
add(answer, r.from(), catalog);
r.consumes().forEach(e -> add(answer, e, catalog));
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
index 97056c9d448e..f6411741f8bb 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
@@ -228,6 +228,14 @@ public final class RuntimeHelper {
return sendMessage(pid, endpoint, body, headers, 0, null);
}
+ /**
+ * Sends a message, and with reply, waits for the answer of the route
(InOut) and has it under message in the
+ * result. A request-reply over a broker (jms:, kafka:) waits for a reply
that may not come: a reply suits direct:.
+ */
+ public static JsonObject sendMessage(long pid, String endpoint, String
body, String headers, boolean reply) {
+ return sendMessage(pid, endpoint, body, headers, 0, null, reply);
+ }
+
/**
* Sends a message, or with a repeat or a body of {@code file:<folder>}
several messages, with a pause between them;
* the answer is then a summary (sent, failed and the errors) rather than
the reply.
@@ -239,10 +247,18 @@ public final class RuntimeHelper {
*/
public static JsonObject sendMessage(
long pid, String endpoint, String body, String headers, int
repeat, String interval) {
+ return sendMessage(pid, endpoint, body, headers, repeat, interval,
false);
+ }
+
+ private static JsonObject sendMessage(
+ long pid, String endpoint, String body, String headers, int
repeat, String interval, boolean reply) {
JsonArray headerList = headers != null ? parseHeaders(headers) : null;
long[] pause = RepeatedSend.interval(interval);
String result = executeAction(pid, "send", root -> {
root.put("endpoint", endpoint);
+ if (reply) {
+ root.put("exchangePattern", "InOut");
+ }
if (body != null) {
root.put("body", body);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RestContractsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RestContractsTest.java
new file mode 100644
index 000000000000..ac5a8af3b639
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RestContractsTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+/**
+ * contracts/openapi-server: the REST DSL serves the contract stock-api.json,
and each operation is the route
+ * direct:operationId. The routes name no operation; the overview reads them
from the contract.
+ */
+class RestContractsTest {
+
+ private static final DefaultCamelCatalog CATALOG = new
DefaultCamelCatalog();
+
+ private static final String CONTRACT = """
+ {
+ "openapi": "3.0.3",
+ "servers": [ { "url": "http://localhost:8080/api" } ],
+ "paths": {
+ "/stock": { "get": { "operationId": "listStock", "summary":
"All stock levels" } },
+ "/stock/{sku}": { "get": { "operationId": "getStock",
"summary": "Stock level of one SKU" } },
+ "/stock/{sku}/reserve": { "post": { "operationId":
"reserveStock" } }
+ }
+ }
+ """;
+
+ private static final String OPERATIONS = """
+ - route:
+ id: listStock
+ from:
+ uri: direct:listStock
+ steps:
+ - log: listed
+ - route:
+ id: getStock
+ from:
+ uri: direct:getStock
+ steps:
+ - log: one
+ - route:
+ id: reserveStock
+ from:
+ uri: direct:reserveStock
+ steps:
+ - log: reserved
+ """;
+
+ @TempDir
+ Path dir;
+
+ @Test
+ void theOperationsOfTheContractAreTheEntryPointsInEveryDsl() throws
Exception {
+ Files.writeString(dir.resolve("stock-api.json"), CONTRACT);
+ Map<String, String> rests = Map.of(
+ "rest.camel.yaml", """
+ - rest:
+ openApi:
+ specification: stock-api.json
+ """,
+ "rest.xml", """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <rest><openApi
specification="stock-api.json"/></rest>
+ </routes>
+ """,
+ "Rest.java", """
+ public class Rest extends RouteBuilder {
+ public void configure() {
+
rest().openApi().specification("stock-api.json");
+ }
+ }
+ """);
+ for (Map.Entry<String, String> rest : rests.entrySet()) {
+ ProjectOverview.Overview o = ProjectOverview.analyze(dir,
+ Map.of(rest.getKey(), rest.getValue(),
"operations.camel.yaml", OPERATIONS), CATALOG);
+
+ assertThat(o.entryPoints()).as(rest.getKey())
+ .extracting(ProjectOverview.EntryPoint::label,
ProjectOverview.EntryPoint::route)
+ .contains(tuple("GET /api/stock", "listStock"), tuple("GET
/api/stock/{sku}", "getStock"),
+ tuple("POST /api/stock/{sku}/reserve",
"reserveStock"));
+ // About has them as what comes in from outside
+ assertThat(About.of(dir,
o).in()).as(rest.getKey()).extracting(About.In::route)
+ .containsExactlyInAnyOrder("listStock", "getStock",
"reserveStock");
+ }
+ }
+
+ @Test
+ void aContractThatIsNotThereHasNoOperations() {
+ ProjectOverview.Overview o = ProjectOverview.analyze(dir,
Map.of("rest.camel.yaml", """
+ - rest:
+ openApi:
+ specification: missing.json
+ """, "operations.camel.yaml", OPERATIONS), CATALOG);
+
+ assertThat(o.routes()).noneMatch(r ->
RestContracts.KIND.equals(r.kind()));
+ assertThat(o.entryPoints()).isEmpty();
+ }
+
+ @Test
+ void theRestComponentsOfAContract() {
+ assertThat(RouteComponents.of("""
+ - rest:
+ openApi:
+ specification: stock-api.json
+ """, "yaml", CATALOG)).contains("rest", "rest-openapi");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
index 02c9481b5b1f..c6605a0696a6 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
@@ -114,6 +114,30 @@ class RuntimeHelperTest {
assertNoRequestFiles();
}
+ @Test
+ void aSendWithReplyAsksForTheAnswerOfTheRoute() throws Exception {
+ // showcase/tui-hello-world: direct:greet answers Hello and the name
+ var result = executor.submit(() -> RuntimeHelper.sendMessage(123,
"direct:greet", "Claus", null, true));
+ Path action = requestFile();
+ JsonObject request = (JsonObject)
Jsoner.deserialize(Files.readString(action));
+ assertEquals("send", request.get("action"));
+ assertEquals("InOut", request.get("exchangePattern"));
+ Files.writeString(outputFile(action),
"{\"status\":\"success\",\"message\":{\"body\":{\"value\":\"Hello Claus!\"}}}");
+ JsonObject message = (JsonObject) result.get(5,
TimeUnit.SECONDS).get("message");
+ assertEquals("Hello Claus!", ((JsonObject)
message.get("body")).get("value"));
+ }
+
+ @Test
+ void aSendWithoutReplyDoesNotWaitForAnAnswer() throws Exception {
+ var result = executor.submit(() -> RuntimeHelper.sendMessage(123,
"kafka:orders", "x", null));
+ Path action = requestFile();
+ JsonObject request = (JsonObject)
Jsoner.deserialize(Files.readString(action));
+ // over a broker InOut is request-reply, waiting for a reply that may
not come
+ assertNull(request.get("exchangePattern"));
+ Files.writeString(outputFile(action), "{\"status\":\"success\"}");
+ assertEquals("success", result.get(5,
TimeUnit.SECONDS).getString("status"));
+ }
+
@Test
void jsonApiPropagatesPublicationFailureWhileTextApiKeepsItsDiagnostic() {
CommandLineHelper.useHomeDir(home.resolve("missing").toString());
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
index fc122d35962e..05bb6aba74dd 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
@@ -1012,7 +1012,7 @@ final class AboutView {
}
Long total = e.route() != null ? totals.get(e.route()) : null;
// with the values of its properties: the period or the schedule
it runs with, its profile applied
- in.add(Line.from(Span.raw(" " + icon(e.kind()) + " "),
+ in.add(Line.from(Span.raw(" " + inIcon(e.kind()) + " "),
Span.styled(Placeholders.resolve(e.label(), info),
Style.EMPTY.bold()),
Span.styled(total != null ? " " + total : "",
Theme.success())));
inTargets.add(e.route() != null ? new Target(e.route(), null) :
null);
@@ -1146,7 +1146,19 @@ final class AboutView {
return AboutReport.baseUri(uri);
}
- private static String icon(String kind) {
+ /**
+ * The icon of a consumer of In, by its scheme: one the icons do not know
(aws2-sqs) has the icon of its kind of
+ * system in the catalog (messaging), as Out has it.
+ */
+ static String inIcon(String scheme) {
+ String icon = icon(scheme);
+ if ("โข".equals(icon) && scheme != null && !scheme.isBlank()) {
+ icon = icon(ProjectOverview.category(scheme,
ArchitectureView.catalog()));
+ }
+ return icon;
+ }
+
+ static String icon(String kind) {
String k = kind != null ? kind.toLowerCase(Locale.ROOT) : "";
if (k.contains("file") || k.contains("storage") || k.equals("ftp") ||
k.equals("sftp") || k.equals("s3")) {
return "๐";
@@ -1154,6 +1166,10 @@ final class AboutView {
if (k.contains("kafka") || k.contains("jms") || k.contains("amqp") ||
k.contains("mqtt") || k.equals("seda")) {
return "๐จ";
}
+ if (k.equals("ai") || k.startsWith("ai-") ||
k.startsWith("langchain4j") || k.equals("openai")) {
+ // a route an AI agent calls as a tool (ai-tool), or a model it
calls
+ return TuiIcons.MCP;
+ }
return switch (k) {
case "schedule" -> "โฑ";
case "http" -> "๐";
@@ -1257,6 +1273,15 @@ final class AboutView {
return dot >= 0 ? type.substring(dot + 1) : type;
}
+ /**
+ * Whether the route is the one Camel adds to serve an OpenAPI contract
(from rest-openapi:, a route the sources do
+ * not have), which hands each request to the route direct:operationId.
+ */
+ private static boolean servesAContract(IntegrationInfo info, String
routeId) {
+ return info.routes.stream().anyMatch(r -> routeId.equals(r.routeId) &&
r.from != null
+ && r.from.startsWith("rest-openapi:"));
+ }
+
/** How each route that another one sends to is reached: call (direct) or
a hand-off (seda); none for the others. */
private Map<String, String> howCalled() {
Map<String, String> how = new LinkedHashMap<>();
@@ -1311,8 +1336,9 @@ final class AboutView {
Map<String, String> how = howCalled();
for (Timeline.RouteSeries r : series) {
String called = how.get(r.route());
- if ("call".equals(called)) {
- // a route called with direct is part of the exchange of its
caller, which gets its failure too
+ if ("call".equals(called) || servesAContract(info, r.route())) {
+ // a route called with direct is part of the exchange of its
caller, which gets its failure too; the
+ // route that serves a contract hands each request on to the
route of its operation, which counts it
continue;
}
if (called == null) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index de0649a95274..486c1750122e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -1636,6 +1636,14 @@ class McpFacade {
// ---- Integration messaging / control ----
JsonObject sendMessage(String endpoint, String body, String headers) {
+ return sendMessage(endpoint, body, headers, null);
+ }
+
+ /**
+ * @param reply whether to wait for the answer of the route and give it
back; null for the default: the answer of a
+ * direct: route, which the send waits for anyway, but no
request-reply over a broker
+ */
+ JsonObject sendMessage(String endpoint, String body, String headers,
Boolean reply) {
if (ctx.selectedPid == null) {
return null;
}
@@ -1650,7 +1658,8 @@ class McpFacade {
String fileOutside = ToolConfirms.fileOutsideProject(
body, info != null ? info.directory : null,
getSelectedSourceDirectory());
JsonObject rejected = confirm(ToolConfirms.sendMessage(endpoint,
selectedEndpointUris(), fileOutside));
- return rejected != null ? rejected : RuntimeHelper.sendMessage(pid,
endpoint, body, headers);
+ boolean inOut = reply != null ? reply : endpoint.startsWith("direct:");
+ return rejected != null ? rejected : RuntimeHelper.sendMessage(pid,
endpoint, body, headers, inOut);
}
JsonObject executeSql(String sql, String datasource, int maxRows, int
queryTimeout) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 129b830242c2..965dc9246727 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -397,7 +397,8 @@ final class TuiToolDefinitions {
Map.of("endpoint", propDef("string",
"Endpoint URI, e.g. 'direct:myRoute',
'paho-mqtt5:temperature?brokerUrl=tcp://localhost:1883'"),
"body", propDef("string", "Message body to send"),
- "headers", propDef("string", "Message headers as
key=value pairs separated by newlines")),
+ "headers", propDef("string", "Message headers as
key=value pairs separated by newlines"),
+ "reply", propDef("boolean", "Return the route's
answer; default true for direct:")),
List.of("endpoint")), r::callSendMessage, CORE));
// CAMEL-25307: the http tool group; the shared get_http_endpoints and
http_request for the selected integration
tools.add(tool(toolDef(
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index c71ff3e96107..2761f41ef244 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -1287,7 +1287,8 @@ class TuiToolRegistry {
}
String body = args.get("body") instanceof String s ? s : null;
String headers = args.get("headers") instanceof String s ? s : null;
- JsonObject response = facade.sendMessage(endpoint, body, headers);
+ Boolean reply = args.get("reply") instanceof Boolean b ? b : null;
+ JsonObject response = facade.sendMessage(endpoint, body, headers,
reply);
if (response == null) {
return "Error: no integration selected or PID unavailable";
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
index 6c2570b9add7..7ca5cef11bf8 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
@@ -22,7 +22,8 @@ happened to it, with no AI:
else the overview an AI wrote in its `camel-summary.md`. A timer or a
cron of In shows the period or schedule it runs with, from its
properties with its profile applied.
-- **In, Routes, Out** โ what starts it (timers, HTTP, consumers), its
+- **In, Routes, Out** โ what starts it (timers, HTTP, consumers, the
+ operations of an OpenAPI contract the REST DSL serves), its
routes (a route called with `direct:` sits under its caller with `โณ`,
one handed off with `seda:` or a `wireTap` with `โข`) and what it talks to,
from its
sources. Each line has the exchanges since it started (for an
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
index 1f8c6b769ee4..1be80a314444 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java
@@ -561,6 +561,61 @@ class AboutViewTest {
.contains("10 exchanges ยท 1 failed").doesNotContain("30
exchanges").doesNotContain("20 exchanges"));
}
+ @Test
+ void theOperationsOfAContractComeInAndAreCountedOnce() throws Exception {
+ // contracts/openapi-server: the REST DSL serves stock-api.json; Camel
adds route2 to serve it, which hands each
+ // request to the route of its operation
+ Files.writeString(dir.resolve("stock-api.json"), """
+ {"servers": [{"url": "http://localhost:8080/api"}],
+ "paths": {"/stock/{sku}/reserve": {"post": {"operationId":
"reserveStock"}}}}
+ """);
+ Files.writeString(dir.resolve("openapi-server.camel.yaml"), """
+ - rest:
+ openApi:
+ specification: stock-api.json
+ - route:
+ id: reserveStock
+ from:
+ uri: direct:reserveStock
+ steps:
+ - log: reserved
+ """);
+ IntegrationInfo info = new IntegrationInfo();
+ info.pid = "1234";
+ info.name = "openapi-server";
+ info.directory = dir.toString();
+ for (String[] r : List.of(new String[] { "reserveStock",
"direct://reserveStock" },
+ new String[] { "route2",
"rest-openapi://stock-api.json?clientRequestValidation=true" })) {
+ RouteInfo route = new RouteInfo();
+ route.routeId = r[0];
+ route.from = r[1];
+ route.state = "Started";
+ info.routes.add(route);
+ }
+ TimelineJson t = new TimelineJson(T0 / 1000 + 59, 60);
+ for (int i = 50; i < 56; i++) {
+ t.route("reserveStock", "completed", i, 1).route("route2",
"completed", i, 1);
+ }
+ MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+ ctx.timeline.merge("1234", t.json(), true);
+ AboutView about = new AboutView(ctx);
+
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(render(about, info, 200, 34))
+ .contains("POST
/api/stock/{sku}/reserve").doesNotContain("nothing from outside")
+ // 6 reservations, not 12 with the route that served them
+ .contains("6 exchanges").doesNotContain("12 exchanges"));
+ }
+
+ @Test
+ void aRouteAnAiAgentCallsAsAToolHasTheIconOfAi() {
+ // ai/mcp-server: its routes start from ai-tool
+ assertThat(AboutView.icon("ai-tool")).isEqualTo(TuiIcons.MCP);
+ assertThat(AboutView.icon("ai")).isEqualTo(TuiIcons.MCP);
+ assertThat(AboutView.icon("http")).isEqualTo("๐");
+ // cloud/aws-sqs: the queue the courier route consumes has the icon
Out gives it, from the catalog
+ assertThat(AboutView.inIcon("aws2-sqs")).isEqualTo("๐จ");
+ }
+
@Test
void aboutAnIntegrationWithWhatHasHappened() throws Exception {
Files.writeString(dir.resolve("shop.camel.yaml"), """