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 fbe5ebf121e2 CAMEL-25379: camel-jbang - AI tools: Kamelets in
camel_catalog_doc and camel_catalog_find, and kamelet: endpoints checked by the
validator (#27447)
fbe5ebf121e2 is described below
commit fbe5ebf121e2d3107a2f937563f6443009b4b5b3
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 6 20:37:51 2026 +0200
CAMEL-25379: camel-jbang - AI tools: Kamelets in camel_catalog_doc and
camel_catalog_find, and kamelet: endpoints checked by the validator (#27447)
* CAMEL-25379: camel-jbang - AI tools: Kamelets in camel_catalog_doc and
camel_catalog_find, and kamelet: endpoints checked by the validator
camel_catalog_doc answers a Kamelet with its properties, and
camel_catalog_find
lists Kamelets. The validator checks kamelet: endpoints against the Kamelet
catalog and the project's own Kamelet files: an unknown Kamelet, an unknown
property, a required property left out. A Kamelet file gets a hint where a
property is written as a Simple function instead of its placeholder. The
Kamelet
catalog helper no longer prints defaults as AnyType(value=...).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25379: camel-jbang - Kamelet checks: no crash on a repeated word,
the kamelet component's options, and properties set in every valid form
Review fixes: an unknown name such as aws-s3-to-aws-sqs crashed in Set.of;
the
options of the kamelet: endpoint itself (routeId, location, timeout,
noErrorHandler...) were reported as unknown properties, now taken from the
catalog's kamelet component; a required property set with key: value, or as
camel.component.kamelet.template-properties[name].prop or route-properties,
was
reported as missing.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25379: camel-jbang - help a model write its own Kamelet: the shape
of a Kamelet file, a Kamelet file sample, and the guide it can find
A Kamelet the model writes itself failed in the benchmark on what the
validator
did not check: properties and required directly under spec, required: true
on a
property, do: or steps: where spec.template.from goes, a template that
starts
from the Kamelet itself (a StackOverflowError at runtime), and camel:simple
as a
dependency (an artifact that does not exist, downloaded on every reload).
validateKameletFile checks the shape of a .kamelet.yaml file and its camel:
dependencies against the catalog.
The model asked camel_catalog_sample kamelet first and got only routes that
call
a Kamelet: it now also gets a Kamelet file from the guide, and a name such
as
kamelet-custom, or kind kamelet, gets the guide's Kamelet files.
camel_catalog_doc
with kind=kamelet reaches the guide (docPage=custom), "Kamelet not found"
and
names with kamelet point to it, and the doc pages are named in the hint. The
guide's samples are in canonical YAML DSL, the action first, with the
mistakes
to avoid.
With these, the custom Kamelet step of the side check passed 3 of 3 runs
(from
1 of 3), with no errors.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25379: kamelet-custom.adoc - write ${tag} as a passthrough, not an
attribute reference
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../apache/camel/catalog/docs/kamelet-custom.adoc | 90 +--
.../src/main/docs/kamelet-custom.adoc | 90 +--
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 8 +-
.../dsl/jbang/core/commands/ai/CatalogDocs.java | 138 ++++-
.../dsl/jbang/core/commands/ai/CatalogSamples.java | 62 +++
.../dsl/jbang/core/commands/ai/KameletChecks.java | 546 ++++++++++++++++++
.../jbang/core/commands/ai/KameletDefinitions.java | 313 +++++++++++
.../jbang/core/commands/ai/SourceValidator.java | 14 +-
.../commands/catalog/KameletCatalogHelper.java | 21 +-
.../jbang/core/commands/ai/KameletChecksTest.java | 612 +++++++++++++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 8 +-
11 files changed, 1812 insertions(+), 90 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc
index 122e53d64257..6b966009b2d9 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc
@@ -50,6 +50,55 @@ optional `default`.
In the template a parameter is written as a property placeholder:
`+{{customer}}+`. A parameter that may be left out
is written `+{{?customer}}+`, which removes the option it sets when no value
is given, instead of failing.
+== Mistakes to avoid
+
+* A parameter in the template is the placeholder `+{{tag}}+`, also inside an
expression: `+simple: {expression: "${body} [{{tag}}]"}+`.
+ It is not `+${tag}+`, `+${properties.tag}+` or `+${header.tag}+`.
+* `properties` and `required` go under `spec.definition`, not directly under
`spec`. `required` is a list of names
+ (`required: [tag]`), not `required: true` on a property.
+* The route is `spec.template` with a `from` and its `steps` under the `from`:
no `steps`, `do` or `route` directly
+ under `spec` or `template`.
+* An action or a sink starts `from: kamelet:source`, never from the Kamelet
itself: `kamelet:<its own name>` would
+ create the Kamelet again and again.
+* The file is one YAML document, without a `---`.
+
+== An action Kamelet
+
+This action keeps only the fields of a JSON object that are named in its
`allowlist` parameter, so personal data
+such as the customer and email does not travel further.
+
+.content-filter-action.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: content-filter-action
+ labels:
+ camel.apache.org/kamelet.type: action
+spec:
+ definition:
+ title: Content Filter Action
+ description: Keeps only the listed fields of a JSON object
+ required:
+ - allowlist
+ properties:
+ allowlist:
+ title: Allowlist
+ description: Comma separated names of the fields to keep
+ type: string
+ dependencies:
+ - "camel:jq"
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - setBody:
+ expression:
+ jq:
+ expression: 'with_entries(select(.key as $k | "{{allowlist}}"
| split(",") | index($k)))'
+----
+
== A source Kamelet
This source produces a sample order at a fixed interval. The route that uses
it chooses the customer, and may change
@@ -89,48 +138,13 @@ spec:
period: "{{period}}"
steps:
- setBody:
- simple:
- expression: '{"id": "${exchangeId}", "customer": "{{customer}}",
"email": "[email protected]", "country": "DK"}'
+ expression:
+ simple:
+ expression: '{"id": "${exchangeId}", "customer":
"{{customer}}", "email": "[email protected]", "country": "DK"}'
- to:
uri: kamelet:sink
----
-== An action Kamelet
-
-This action keeps only the fields of a JSON object that are named in its
`allowlist` parameter, so personal data
-such as the customer and email does not travel further.
-
-.content-filter-action.kamelet.yaml
-[source,yaml]
-----
-apiVersion: camel.apache.org/v1
-kind: Kamelet
-metadata:
- name: content-filter-action
- labels:
- camel.apache.org/kamelet.type: action
-spec:
- definition:
- title: Content Filter Action
- description: Keeps only the listed fields of a JSON object
- required:
- - allowlist
- properties:
- allowlist:
- title: Allowlist
- description: Comma separated names of the fields to keep
- type: string
- dependencies:
- - "camel:jq"
- template:
- from:
- uri: kamelet:source
- steps:
- - setBody:
- jq:
- expression: 'with_entries(select(.key as $k | "{{allowlist}}" |
split(",") | index($k)))'
-----
-
== A sink Kamelet
This sink writes each message to a file in a directory.
diff --git a/components/camel-kamelet/src/main/docs/kamelet-custom.adoc
b/components/camel-kamelet/src/main/docs/kamelet-custom.adoc
index 122e53d64257..6b966009b2d9 100644
--- a/components/camel-kamelet/src/main/docs/kamelet-custom.adoc
+++ b/components/camel-kamelet/src/main/docs/kamelet-custom.adoc
@@ -50,6 +50,55 @@ optional `default`.
In the template a parameter is written as a property placeholder:
`+{{customer}}+`. A parameter that may be left out
is written `+{{?customer}}+`, which removes the option it sets when no value
is given, instead of failing.
+== Mistakes to avoid
+
+* A parameter in the template is the placeholder `+{{tag}}+`, also inside an
expression: `+simple: {expression: "${body} [{{tag}}]"}+`.
+ It is not `+${tag}+`, `+${properties.tag}+` or `+${header.tag}+`.
+* `properties` and `required` go under `spec.definition`, not directly under
`spec`. `required` is a list of names
+ (`required: [tag]`), not `required: true` on a property.
+* The route is `spec.template` with a `from` and its `steps` under the `from`:
no `steps`, `do` or `route` directly
+ under `spec` or `template`.
+* An action or a sink starts `from: kamelet:source`, never from the Kamelet
itself: `kamelet:<its own name>` would
+ create the Kamelet again and again.
+* The file is one YAML document, without a `---`.
+
+== An action Kamelet
+
+This action keeps only the fields of a JSON object that are named in its
`allowlist` parameter, so personal data
+such as the customer and email does not travel further.
+
+.content-filter-action.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: content-filter-action
+ labels:
+ camel.apache.org/kamelet.type: action
+spec:
+ definition:
+ title: Content Filter Action
+ description: Keeps only the listed fields of a JSON object
+ required:
+ - allowlist
+ properties:
+ allowlist:
+ title: Allowlist
+ description: Comma separated names of the fields to keep
+ type: string
+ dependencies:
+ - "camel:jq"
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - setBody:
+ expression:
+ jq:
+ expression: 'with_entries(select(.key as $k | "{{allowlist}}"
| split(",") | index($k)))'
+----
+
== A source Kamelet
This source produces a sample order at a fixed interval. The route that uses
it chooses the customer, and may change
@@ -89,48 +138,13 @@ spec:
period: "{{period}}"
steps:
- setBody:
- simple:
- expression: '{"id": "${exchangeId}", "customer": "{{customer}}",
"email": "[email protected]", "country": "DK"}'
+ expression:
+ simple:
+ expression: '{"id": "${exchangeId}", "customer":
"{{customer}}", "email": "[email protected]", "country": "DK"}'
- to:
uri: kamelet:sink
----
-== An action Kamelet
-
-This action keeps only the fields of a JSON object that are named in its
`allowlist` parameter, so personal data
-such as the customer and email does not travel further.
-
-.content-filter-action.kamelet.yaml
-[source,yaml]
-----
-apiVersion: camel.apache.org/v1
-kind: Kamelet
-metadata:
- name: content-filter-action
- labels:
- camel.apache.org/kamelet.type: action
-spec:
- definition:
- title: Content Filter Action
- description: Keeps only the listed fields of a JSON object
- required:
- - allowlist
- properties:
- allowlist:
- title: Allowlist
- description: Comma separated names of the fields to keep
- type: string
- dependencies:
- - "camel:jq"
- template:
- from:
- uri: kamelet:source
- steps:
- - setBody:
- jq:
- expression: 'with_entries(select(.key as $k | "{{allowlist}}" |
split(",") | index($k)))'
-----
-
== A sink Kamelet
This sink writes each message to a file in a directory.
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 4bce96d071f9..6f65a77f9ba2 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
@@ -83,13 +83,13 @@ public final class AuthoringTools {
/** Registers the authoring tools; called once by the {@link
ToolRegistry}. */
static void register(Consumer<ToolDescriptor> registry) {
registry.accept(tool("camel_catalog_doc",
- "Catalog documentation of a component, data format, language,
EIP, built-in bean or the Java API: description, options, Maven coordinates,
the URI rules of a component; for simple its functions and operators
(optionsFilter narrows them). endpoint validates a URI.")
+ "Catalog documentation of a component, data format, language,
EIP, Kamelet, built-in bean or the Java API: description, options, Maven
coordinates, the URI rules of a component; for simple its functions and
operators (optionsFilter narrows them). endpoint validates a URI.")
.param("name", "string",
"Name, e.g. kafka, json (a data format by its YAML
name or artifact), simple, timer, choice, split, Exchange",
false)
.param("endpoint", "string", "Endpoint URI to check, e.g.
kafka:orders?brokers=host:9092", false)
.param("kind", "string",
- "component, dataformat, language, eip, bean or api
(auto-detected; a bean is a built-in class such as StringAggregationStrategy,
with how to declare and use it; api is the Java API to call from a bean or
script before writing it: Exchange, Message, CamelContext, Registry,
ProducerTemplate, Processor, AggregationStrategy, Predicate, Expression,
TypeConverter, or the variables of groovy, js, python, java scripts)",
+ "component, dataformat, language, eip, kamelet, bean
or api (auto-detected; a bean is a built-in class such as
StringAggregationStrategy, with how to declare and use it; api is the Java API
to call from a bean or script before writing it: Exchange, Message,
CamelContext, Registry, ProducerTemplate, Processor, AggregationStrategy,
Predicate, Expression, TypeConverter, or the variables of groovy, js, python,
java scripts)",
false)
.param("includeOptions", "string",
"common (default: no deprecated or advanced),
required, all or false", false)
@@ -112,13 +112,13 @@ public final class AuthoringTools {
}));
registry.accept(tool("camel_catalog_find",
- "Finds Camel components, data formats, languages and EIPs by a
protocol, product, alias or other term "
+ "Finds Camel components, data formats, languages, EIPs and
Kamelets by a protocol, product, alias or other term "
+ "that is not the exact
name (mqtt, s3, snowflake, csv, fan-out, dedup): best "
+ "match first with title
and description. camel_catalog_doc then gives the "
+ "options of one.")
.param("term", "string", "What to look for, e.g. mqtt, s3,
database, csv, fan-out", true)
.param("kind", "string",
- "component, dataformat, language, eip or bean
(default: all); bean with an interface name such as AggregationStrategy lists
the built-in implementations",
+ "component, dataformat, language, eip, kamelet or bean
(default: all); bean with an interface name such as AggregationStrategy lists
the built-in implementations",
false)
.param("limit", "integer", "Maximum matches per kind (default
10)", false)
.param("camelVersion", "string", VERSION_DESC, false)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
index 7a5f8351cb91..8cabb8f9740a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
@@ -190,6 +190,13 @@ public final class CatalogDocs {
return error("'name' or 'endpoint' parameter is required");
}
String page = docPage != null ?
docPage.trim().toLowerCase(Locale.ROOT) : null;
+ if ("kamelet".equals(kind) &&
!KameletDefinitions.catalog().containsKey(name)
+ && ("kamelet".equals(name) || page != null)) {
+ // the kamelet component's own docs, such as how to write a
Kamelet (docPage=custom): a model asks for
+ // them with kind=kamelet, which is the kind of the catalog's
Kamelets
+ kind = null;
+ name = "kamelet";
+ }
String lowerFilter = optionsFilter != null && !optionsFilter.isBlank()
? optionsFilter.toLowerCase() : null;
OptionScope scope = OptionScope.parse(includeOptions);
if (scope == null) {
@@ -309,6 +316,19 @@ public final class CatalogDocs {
return notFound("Bean", name, findBeans(catalog,
name).stream().map(PojoBeanModel::getName).limit(5).toList());
}
}
+ if (kind == null || "kamelet".equals(kind)) {
+ KameletDefinitions.Definition def =
KameletDefinitions.catalog().get(name);
+ if (def != null) {
+ return kameletDoc(def);
+ }
+ if (kind != null) {
+ JsonObject err = notFound("Kamelet", name,
+ KameletDefinitions.suggest(name,
KameletDefinitions.catalog().keySet(), 5));
+ // a Kamelet of the project is not in the catalog: how to
write one
+ err.put("guide", AuthoringTools.KAMELET_GUIDE);
+ return err;
+ }
+ }
JsonObject group = mainOptionsGroup(catalog, name);
if (group != null) {
return group;
@@ -323,7 +343,81 @@ public final class CatalogDocs {
suggestions.addAll(catalog.suggestLanguageNames(name, 3));
suggestions.addAll(catalog.suggestEipNames(name, 3));
suggestions.addAll(findBeans(catalog,
name).stream().map(PojoBeanModel::getName).limit(3).toList());
- return notFound("Artifact", name, suggestions);
+ suggestions.addAll(KameletDefinitions.suggest(name,
KameletDefinitions.catalog().keySet(), 3));
+ JsonObject answer = notFound("Artifact", name, suggestions);
+ if (name.toLowerCase(Locale.ROOT).contains("kamelet")) {
+ // kamelet-custom, custom-kamelet: how to write one
+ answer.put("guide", AuthoringTools.KAMELET_GUIDE);
+ }
+ return answer;
+ }
+
+ /** The Kamelets of the catalog whose name has the term, then those whose
description has it. */
+ private static List<KameletDefinitions.Definition> kameletsMatching(String
lowerTerm) {
+ List<KameletDefinitions.Definition> byName = new ArrayList<>();
+ List<KameletDefinitions.Definition> byDescription = new ArrayList<>();
+ String dashed = lowerTerm.trim().replace(' ', '-');
+ for (KameletDefinitions.Definition def :
KameletDefinitions.catalog().values()) {
+ if (def.name().contains(dashed)) {
+ byName.add(def);
+ } else if (def.description() != null &&
def.description().toLowerCase(Locale.ROOT).contains(lowerTerm)) {
+ byDescription.add(def);
+ }
+ }
+ byName.sort((a, b) -> Integer.compare(a.name().length(),
b.name().length()));
+ byName.addAll(byDescription);
+ return byName;
+ }
+
+ /**
+ * A Kamelet of the catalog: its properties, which go under parameters of
the kamelet: endpoint, and how to write
+ * it. A model writes the options of the component a Kamelet wraps instead
(brokers on kafka-sink, which has
+ * bootstrapServers), as it knows those from years of examples.
+ */
+ public static JsonObject kameletDoc(KameletDefinitions.Definition def) {
+ JsonObject result = new JsonObject();
+ result.put("kind", "kamelet");
+ result.put("name", def.name());
+ if (def.type() != null) {
+ result.put("type", def.type());
+ }
+ if (def.description() != null) {
+ result.put("description", def.description());
+ }
+ result.put("from", def.source());
+ StringBuilder params = new StringBuilder();
+ for (KameletDefinitions.Property p : def.properties()) {
+ if (p.required() && p.defaultValue() == null) {
+ params.append(params.isEmpty() ? "" : ",
").append(p.name()).append(": <").append(p.name()).append(">");
+ }
+ }
+ String endpoint = "{uri: kamelet:" + def.name() + (params.isEmpty() ?
"" : ", parameters: {" + params + "}") + "}";
+ result.put("yaml", ("source".equals(def.type()) ? "from: " : "- to: ")
+ endpoint
+ + " (its properties go under parameters: they are
the Kamelet's, not the options of the"
+ + " component it uses)");
+ JsonArray props = new JsonArray();
+ for (KameletDefinitions.Property p : def.properties()) {
+ JsonObject o = new JsonObject();
+ o.put("name", p.name());
+ if (p.required() && p.defaultValue() == null) {
+ o.put("required", true);
+ }
+ if (p.type() != null) {
+ o.put("type", p.type());
+ }
+ if (p.defaultValue() != null) {
+ o.put("defaultValue", p.defaultValue());
+ }
+ if (!p.enumValues().isEmpty()) {
+ o.put("enum", new JsonArray(p.enumValues()));
+ }
+ if (p.description() != null) {
+ o.put("description", p.description());
+ }
+ props.add(o);
+ }
+ result.put("properties", props);
+ return result;
}
/**
@@ -534,6 +628,24 @@ public final class CatalogDocs {
matches.add(o);
}
}
+ if (kind == null || "kamelet".equals(kind)) {
+ // the Kamelets whose name or description has the term: a few next
to the components, all when asked for
+ int n = 0;
+ int maxKamelets = kind == null ? Math.min(max, 5) : max;
+ String lower = term.toLowerCase(Locale.ROOT);
+ for (KameletDefinitions.Definition def : kameletsMatching(lower)) {
+ if (n++ >= maxKamelets) {
+ break;
+ }
+ JsonObject o = summary("kamelet", def.name(), def.title() !=
null ? def.title() : def.name(),
+ def.description(), null);
+ if (def.type() != null) {
+ o.put("type", def.type());
+ }
+ o.put("uri", "kamelet:" + def.name());
+ matches.add(o);
+ }
+ }
result.put("matches", matches);
result.put("count", matches.size());
if (matches.isEmpty()) {
@@ -1019,6 +1131,28 @@ public final class CatalogDocs {
}
}
+ /**
+ * The hint naming each doc page with the call that returns it: a model
given docPage=<page> asked for the page by
+ * another name instead (kamelet-custom for how to write a Kamelet).
+ */
+ private static String pagesHint(List<JsonObject> docPages, String key) {
+ // key is the field of a page with its name
+ StringBuilder sb = new StringBuilder();
+ for (JsonObject p : docPages) {
+ String page = p.getString(key);
+ if (page == null) {
+ continue;
+ }
+ sb.append(sb.isEmpty() ? "" : ";
").append("docPage=").append(page);
+ if (p.getString("title") != null) {
+ sb.append(": ").append(p.getString("title"));
+ }
+ }
+ return sb.isEmpty()
+ ? "docPage=<" + key + "> returns that documentation page as
text"
+ : sb + " (each returned as text)";
+ }
+
private static JsonObject error(String message) {
JsonObject err = new JsonObject();
err.put("error", message);
@@ -1190,7 +1324,7 @@ public final class CatalogDocs {
if (!docPages.isEmpty()) {
// named with their titles, so a model can tell which one answers
its question
result.put("docPages", new JsonArray(docPages));
- result.put("docPagesHint", "docPage=<page> returns that
documentation page as text");
+ result.put("docPagesHint", pagesHint(docPages, "page"));
}
return result;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
index 7fe490c21b03..af249d73d95d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
@@ -426,6 +426,68 @@ public final class CatalogSamples {
* @param limit the maximum number of samples, at most {@link #MAX_LIMIT}
*/
public static JsonObject sample(CamelCatalog catalog, String kind, String
name, int limit) {
+ String lower = name != null ? name.trim().toLowerCase(Locale.ROOT) :
"";
+ boolean kameletKind = "kamelet".equalsIgnoreCase(kind) || "kamelet
file".equalsIgnoreCase(kind);
+ if (kameletKind || lower.contains("kamelet") &&
!lower.equals("kamelet") && kind == null) {
+ // kamelet-custom, custom-kamelet, kamelet-action: how to write a
Kamelet file
+ List<Map<String, String>> files = kameletFileSamples(catalog);
+ if (!files.isEmpty()) {
+ JsonObject answer = new JsonObject();
+ answer.put("name", name.trim());
+ answer.put("kind", "kamelet file");
+ answer.put("placement", KAMELET_FILE_PLACEMENT);
+ putSamples(answer, files, Math.max(1, Math.min(MAX_LIMIT,
limit <= 0 ? DEFAULT_LIMIT : limit)));
+ answer.put("guide", AuthoringTools.KAMELET_GUIDE);
+ return answer;
+ }
+ }
+ JsonObject answer = doSample(catalog, kind, name, limit);
+ if (lower.equals("kamelet") && !answer.containsKey("error")) {
+ // the samples show a route that uses a Kamelet; a model asking
for kamelet often wants to write one
+ List<Map<String, String>> files = kameletFileSamples(catalog);
+ if (!files.isEmpty()) {
+ JsonObject file = new JsonObject();
+ file.put("source", files.get(0).get("source"));
+ file.put("placement", KAMELET_FILE_PLACEMENT);
+ file.put("yaml", files.get(0).get("yaml"));
+ answer.put("kameletFile", file);
+ }
+ answer.put("guide", AuthoringTools.KAMELET_GUIDE);
+ }
+ return answer;
+ }
+
+ static final String KAMELET_FILE_PLACEMENT = "a file of its own,
<name>.kamelet.yaml, beside the routes; a route"
+ + " uses it as
kamelet:<name>";
+
+ private static final Pattern KAMELET_FILE_BLOCK = Pattern.compile(
+
"(?m)^\\.([\\w.-]+\\.kamelet\\.yaml)\\s*\\n\\[source,yaml\\]\\s*\\n----\\n(.*?)\\n----",
Pattern.DOTALL);
+
+ /** The Kamelet files of the guide on writing a Kamelet
(kamelet-custom.adoc), the action first. */
+ static List<Map<String, String>> kameletFileSamples(CamelCatalog catalog) {
+ String adoc = null;
+ try {
+ adoc = catalog != null ? catalog.asciiDoc("kamelet-custom") : null;
+ } catch (Exception e) {
+ // no guide in this catalog
+ }
+ List<Map<String, String>> answer = new ArrayList<>();
+ if (adoc != null) {
+ Matcher m = KAMELET_FILE_BLOCK.matcher(adoc);
+ while (m.find()) {
+ Map<String, String> sample = Map.of("source",
"kamelet-custom.adoc: " + m.group(1), "yaml", m.group(2) + "\n");
+ // the action first: the building block a project writes most
+ if (m.group(2).contains("kamelet.type: action")) {
+ answer.add(0, sample);
+ } else {
+ answer.add(sample);
+ }
+ }
+ }
+ return answer;
+ }
+
+ private static JsonObject doSample(CamelCatalog catalog, String kind,
String name, int limit) {
JsonObject answer = new JsonObject();
String given = name != null ? name.trim() : "";
if (given.isEmpty()) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
new file mode 100644
index 000000000000..0a6f0267545a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
@@ -0,0 +1,546 @@
+/*
+ * 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.io.StringReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.tooling.model.ArtifactModel;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+import org.yaml.snakeyaml.nodes.MappingNode;
+import org.yaml.snakeyaml.nodes.Node;
+import org.yaml.snakeyaml.nodes.NodeTuple;
+import org.yaml.snakeyaml.nodes.ScalarNode;
+import org.yaml.snakeyaml.nodes.SequenceNode;
+
+/**
+ * The kamelet: endpoints of a YAML route against the Kamelets they name
({@link KameletDefinitions}): a Kamelet that
+ * does not exist, a property it does not have, a required property left out.
The catalog validates the options of a
+ * component's endpoint; the options of a kamelet: endpoint are the properties
of a template it does not know, so these
+ * went unchecked until the route failed to start ("mandatory parameters must
be provided: message") or, for a
+ * misspelled optional one, never failed and did nothing.
+ */
+public final class KameletChecks {
+
+ /** The keys whose value is an endpoint uri, written as uri: under them or
as their own value. */
+ private static final Set<String> ENDPOINT_KEYS = Set.of("from", "to",
"toD", "to-d", "wireTap", "wire-tap", "enrich",
+ "pollEnrich", "poll-enrich");
+
+ private KameletChecks() {
+ }
+
+ /** The errors of the kamelet: endpoints of a YAML route, each with its
line; empty when it has none. */
+ public static List<String> validateYaml(String content, Path directory) {
+ return validateYaml(content, directory, true);
+ }
+
+ /**
+ * As {@link #validateYaml(String, Path)}; unknownNames false for a write,
where a Kamelet no catalog or project has
+ * may be the project's own one not written yet: then only a name close to
a known one is an error (the route and
+ * its Kamelet could otherwise only be written in one order, as for
direct: consumers in CAMEL-24955).
+ */
+ public static List<String> validateYaml(String content, Path directory,
boolean unknownNames) {
+ List<String> errors = new ArrayList<>();
+ if (content == null || !content.contains("kamelet:")) {
+ return errors;
+ }
+ List<Node> roots = new ArrayList<>();
+ try {
+ for (Node n : new Yaml(new SafeConstructor(new
LoaderOptions())).composeAll(new StringReader(content))) {
+ roots.add(n);
+ }
+ } catch (Exception e) {
+ // not YAML: the schema check says so
+ return errors;
+ }
+ Map<String, String> properties = directory != null ?
projectProperties(directory) : Map.of();
+ for (Node root : roots) {
+ walk(root, null, errors, directory, properties, unknownNames);
+ }
+ return errors;
+ }
+
+ private static void walk(
+ Node node, String parentKey, List<String> errors, Path directory,
Map<String, String> properties,
+ boolean unknownNames) {
+ if (node instanceof SequenceNode seq) {
+ for (Node n : seq.getValue()) {
+ walk(n, parentKey, errors, directory, properties,
unknownNames);
+ }
+ } else if (node instanceof MappingNode map) {
+ ScalarNode uri = null;
+ MappingNode parameters = null;
+ for (NodeTuple t : map.getValue()) {
+ String key = t.getKeyNode() instanceof ScalarNode k ?
k.getValue() : null;
+ if ("uri".equals(key) && t.getValueNode() instanceof
ScalarNode s) {
+ uri = s;
+ } else if ("parameters".equals(key) && t.getValueNode()
instanceof MappingNode p) {
+ parameters = p;
+ } else if (ENDPOINT_KEYS.contains(key) && t.getValueNode()
instanceof ScalarNode s) {
+ // to: kamelet:log-sink, the short form
+ check(s, null, errors, directory, properties,
unknownNames);
+ }
+ }
+ if (uri != null && (parentKey == null ||
ENDPOINT_KEYS.contains(parentKey) || "from".equals(parentKey))) {
+ check(uri, parameters, errors, directory, properties,
unknownNames);
+ }
+ for (NodeTuple t : map.getValue()) {
+ String key = t.getKeyNode() instanceof ScalarNode k ?
k.getValue() : null;
+ if (!"parameters".equals(key)) {
+ walk(t.getValueNode(), key, errors, directory, properties,
unknownNames);
+ }
+ }
+ }
+ }
+
+ private static void check(
+ ScalarNode uriNode, MappingNode parameters, List<String> errors,
Path directory,
+ Map<String, String> properties, boolean unknownNames) {
+ String uri = uriNode.getValue();
+ if (uri == null || !uri.startsWith("kamelet:")) {
+ return;
+ }
+ String rest = uri.substring("kamelet:".length());
+ String query = null;
+ int q = rest.indexOf('?');
+ if (q >= 0) {
+ query = rest.substring(q + 1);
+ rest = rest.substring(0, q);
+ }
+ String name = rest.contains("/") ? rest.substring(0,
rest.indexOf('/')) : rest;
+ if (name.isEmpty() || "source".equals(name) || "sink".equals(name) ||
name.contains("{{")
+ || name.contains("${")) {
+ // kamelet:source and kamelet:sink are the ends of a Kamelet's own
template
+ return;
+ }
+ int line = uriNode.getStartMark().getLine();
+ KameletDefinitions.Definition def = KameletDefinitions.find(name,
directory);
+ if (def == null) {
+ Map<String, KameletDefinitions.Definition> catalog =
KameletDefinitions.catalog();
+ if (catalog.isEmpty()) {
+ // the catalog cannot be read: nothing to check against
+ return;
+ }
+ List<String> names = new ArrayList<>(catalog.keySet());
+ if (directory != null) {
+
names.addAll(KameletDefinitions.projectKamelets(directory).keySet());
+ }
+ List<String> suggestions = KameletDefinitions.suggest(name, names,
3);
+ if (!unknownNames && suggestions.isEmpty()) {
+ return;
+ }
+ StringBuilder sb = new
StringBuilder(EndpointChecks.linePrefix(line)).append("kamelet:").append(name)
+ .append(": no Kamelet named ").append(name).append(" in
the Kamelet catalog or the project");
+ if (!suggestions.isEmpty()) {
+ sb.append(". Did you mean: ").append(String.join(", ",
suggestions));
+ }
+ sb.append("; camel_catalog_doc name=<kamelet> has the properties
of one (a Kamelet of the project is")
+ .append(" a ").append(name).append(".kamelet.yaml file
beside the route)");
+ errors.add(sb.toString());
+ return;
+ }
+ Map<String, Integer> given = new LinkedHashMap<>();
+ if (query != null) {
+ for (String part : query.split("&")) {
+ String key = part.contains("=") ? part.substring(0,
part.indexOf('=')) : part;
+ if (!key.isBlank()) {
+ given.put(key, line);
+ }
+ }
+ }
+ if (parameters != null) {
+ for (NodeTuple t : parameters.getValue()) {
+ if (t.getKeyNode() instanceof ScalarNode k) {
+ given.put(k.getValue(), k.getStartMark().getLine());
+ }
+ }
+ }
+ String where = def.source().startsWith("the project") ? " (" +
def.source() + ")" : "";
+ Set<String> componentOptions = kameletComponentOptions();
+ for (Map.Entry<String, Integer> e : given.entrySet()) {
+ if (def.property(e.getKey()) == null &&
!componentOptions.contains(e.getKey())) {
+ List<String> close =
KameletDefinitions.suggestProperty(e.getKey(), def);
+ StringBuilder sb = new
StringBuilder(EndpointChecks.linePrefix(e.getValue())).append("kamelet:")
+ .append(name).append(": unknown property
'").append(e.getKey()).append("'");
+ if (!close.isEmpty()) {
+ sb.append(". Did you mean: ").append(String.join(", ",
close));
+ }
+ sb.append(". The properties of
").append(name).append(where).append(": ")
+ .append(def.properties().isEmpty() ? "none" :
KameletDefinitions.propertyList(def));
+ if (!where.isEmpty()) {
+ // the project's own Kamelet: the property may be what the
Kamelet lacks, not the route
+ sb.append("; a property of a Kamelet is declared in its
file under spec.definition.properties")
+ .append(" (and listed under required: when it must
be given)");
+ }
+ errors.add(sb.toString());
+ }
+ }
+ Set<String> missing = new TreeSet<>();
+ for (KameletDefinitions.Property p : def.properties()) {
+ if (p.required() && p.defaultValue() == null &&
!given.containsKey(p.name())
+ && !inProperties(properties, name, p.name())) {
+ missing.add(p.name());
+ }
+ }
+ if (!missing.isEmpty()) {
+ errors.add(EndpointChecks.linePrefix(line) + "kamelet:" + name +
": the required "
+ + (missing.size() == 1 ? "property " : "properties ") +
String.join(", ", missing)
+ + (missing.size() == 1 ? " is" : " are")
+ + " missing (the runtime says 'mandatory parameters
must be provided: "
+ + String.join(",", missing) + "'): add "
+ + (missing.size() == 1 ? "it" : "them") + " under
parameters. The properties of " + name
+ + where + ": " + KameletDefinitions.propertyList(def));
+ }
+ }
+
+ private static volatile Set<String> componentOptions;
+
+ /**
+ * The options of the kamelet component's own endpoint (routeId, location,
timeout, noErrorHandler...): they go on a
+ * kamelet: endpoint beside the Kamelet's properties.
+ */
+ static Set<String> kameletComponentOptions() {
+ Set<String> answer = componentOptions;
+ if (answer == null) {
+ Set<String> names = new HashSet<>();
+ try {
+ ComponentModel model = new
DefaultCamelCatalog().componentModel("kamelet");
+ if (model != null) {
+ model.getEndpointOptions().forEach(o ->
names.add(o.getName()));
+ }
+ } catch (Exception e) {
+ // the list below
+ }
+ if (names.isEmpty()) {
+ names.addAll(Set.of("templateId", "routeId", "location",
"uuid", "block", "timeout",
+ "failIfNoConsumers", "noErrorHandler",
"bridgeErrorHandler", "lazyStartProducer",
+ "exchangePattern", "exceptionHandler"));
+ }
+ componentOptions = answer = names;
+ }
+ return answer;
+ }
+
+ /** The keys of spec of a Kamelet; anything else is not read. */
+ private static final Set<String> SPEC_KEYS = Set.of("definition",
"template", "dependencies", "types", "dataTypes",
+ "sources", "flow");
+
+ /**
+ * The shape of a Kamelet file: what a model gets wrong writing one, and
the runtime only reports as a Kamelet that
+ * cannot be loaded or used. spec.template with from: is the route; the
properties are under spec.definition, with
+ * the required ones listed in spec.definition.required; an action or a
sink starts from kamelet:source.
+ */
+ public static List<String> validateKameletFile(String content) {
+ List<String> errors = new ArrayList<>();
+ Node root;
+ try {
+ root = new Yaml(new SafeConstructor(new
LoaderOptions())).compose(new StringReader(content));
+ } catch (Exception e) {
+ // not YAML: the schema check says so
+ return errors;
+ }
+ if (!(root instanceof MappingNode doc) ||
!"Kamelet".equals(scalar(value(doc, "kind")))) {
+ return errors;
+ }
+ Node specNode = value(doc, "spec");
+ if (!(specNode instanceof MappingNode spec)) {
+ errors.add(EndpointChecks.linePrefix(line(doc)) + "a Kamelet has a
spec: with definition: (its properties)"
+ + " and template: (its route); " +
AuthoringTools.KAMELET_GUIDE);
+ return errors;
+ }
+ for (NodeTuple t : spec.getValue()) {
+ String key = scalar(t.getKeyNode());
+ if (key == null || SPEC_KEYS.contains(key)) {
+ continue;
+ }
+ String hint = switch (key) {
+ case "properties", "required" -> "it goes under
spec.definition (definition: {required: [...],"
+ + " properties: {name: {type:
string, ...}}})";
+ case "from", "route", "steps", "do", "flow", "routeTemplate"
-> "the route of a Kamelet is spec.template:"
+
+ " template: {from: {uri: kamelet:source, steps: [...]}}";
+ default -> "the keys of spec are definition, template,
dependencies and types";
+ };
+
errors.add(EndpointChecks.linePrefix(t.getKeyNode().getStartMark().getLine()) +
"spec." + key
+ + " is not a key of a Kamelet: " + hint);
+ }
+ Node definition = value(spec, "definition");
+ Set<String> declared = new java.util.LinkedHashSet<>();
+ if (definition instanceof MappingNode def) {
+ if (value(def, "properties") instanceof MappingNode props) {
+ for (NodeTuple p : props.getValue()) {
+ String pname = scalar(p.getKeyNode());
+ declared.add(pname);
+ if (p.getValueNode() instanceof MappingNode pm &&
value(pm, "required") != null) {
+ errors.add(EndpointChecks.linePrefix(value(pm,
"required").getStartMark().getLine())
+ + "spec.definition.properties." + pname +
".required: a required property is listed"
+ + " in spec.definition.required (required:
[" + pname + "]), not marked on the"
+ + " property");
+ }
+ }
+ }
+ if (value(def, "required") instanceof SequenceNode req) {
+ for (Node r : req.getValue()) {
+ String rname = scalar(r);
+ if (rname != null && !declared.contains(rname)) {
+
errors.add(EndpointChecks.linePrefix(r.getStartMark().getLine()) +
"spec.definition.required"
+ + " lists " + rname + ", which is not under
spec.definition.properties");
+ }
+ }
+ }
+ }
+ if (value(spec, "dependencies") instanceof SequenceNode deps) {
+ for (Node d : deps.getValue()) {
+ String dep = scalar(d);
+ String problem = dep != null && dep.startsWith("camel:") ?
camelDependency(dep.substring(6)) : null;
+ if (problem != null) {
+
errors.add(EndpointChecks.linePrefix(d.getStartMark().getLine()) +
"spec.dependencies: " + dep + " "
+ + problem);
+ }
+ }
+ }
+ Node template = value(spec, "template");
+ if (template == null) {
+
errors.add(EndpointChecks.linePrefix(spec.getStartMark().getLine()) + "the
Kamelet has no spec.template:"
+ + " its route, template: {from: {uri: kamelet:source,
steps: [...]}} for an action or a sink, or"
+ + " from: the component for a source; " +
AuthoringTools.KAMELET_GUIDE);
+ } else if (template instanceof MappingNode tm) {
+ Node from = value(tm, "from");
+ if (from == null && value(tm, "route") instanceof MappingNode
route) {
+ from = value(route, "from");
+ }
+ String type = null;
+ if (value(doc, "metadata") instanceof MappingNode meta &&
value(meta, "labels") instanceof MappingNode labels) {
+ type = scalar(value(labels, "camel.apache.org/kamelet.type"));
+ }
+ if (value(tm, "steps") != null) {
+
errors.add(EndpointChecks.linePrefix(tm.getStartMark().getLine()) +
"spec.template.steps: the steps go"
+ + " under from:, template: {from: {uri:
kamelet:source, steps: [...]}}");
+ }
+ if (from instanceof MappingNode fm0 && scalar(value(fm0, "uri"))
!= null
+ && scalar(value(fm0, "uri")).startsWith("kamelet:")
+ && !scalar(value(fm0,
"uri")).startsWith("kamelet:source")) {
+ // from: kamelet:<itself> instantiates the Kamelet from within
itself, over and over
+ errors.add(EndpointChecks.linePrefix(value(fm0,
"uri").getStartMark().getLine()) + "the template starts"
+ + " from " + scalar(value(fm0, "uri")) + ": a
Kamelet's template is entered from kamelet:source,"
+ + " the message the route sends to the Kamelet");
+ } else if (from == null) {
+
errors.add(EndpointChecks.linePrefix(tm.getStartMark().getLine()) +
"spec.template has no from: the"
+ + " route starts with from: {uri: kamelet:source,
steps: [...]} for an action or a sink");
+ } else if (("action".equals(type) || "sink".equals(type)) && from
instanceof MappingNode fm) {
+ String uri = scalar(value(fm, "uri"));
+ if (uri != null && !uri.startsWith("kamelet:")) {
+ errors.add(EndpointChecks.linePrefix(value(fm,
"uri").getStartMark().getLine()) + "an " + type
+ + " Kamelet starts from: {uri: kamelet:source},
the message it is sent; " + uri
+ + " is the from: of a source");
+ }
+ }
+ }
+ return errors;
+ }
+
+ private static volatile CamelCatalog dependencyCatalog;
+
+ /**
+ * What is wrong with camel:name as a dependency, or null: it names the
artifact without camel- (camel:jq for
+ * camel-jq), which the runtime downloads. A language of camel core such
as simple has no artifact of its own, so
+ * camel:simple failed to download on every reload.
+ */
+ static String camelDependency(String name) {
+ if (name.isBlank() || "core".equals(name) || "kamelet".equals(name)) {
+ return null;
+ }
+ CamelCatalog catalog = dependencyCatalog;
+ if (catalog == null) {
+ catalog = new DefaultCamelCatalog();
+ dependencyCatalog = catalog;
+ }
+ try {
+ String artifact = artifactOf(catalog, name);
+ if (artifact == null) {
+ return isArtifact(catalog, "camel-" + name)
+ ? null
+ : "is not a Camel artifact: a dependency is
camel:<artifact without camel->, such as camel:jq";
+ }
+ if (artifact.equals("camel-" + name)) {
+ return null;
+ }
+ if (artifact.startsWith("camel-core") ||
artifact.equals("camel-base") || artifact.equals("camel-support")) {
+ return "is part of camel core (" + artifact + "): leave it
out, as the runtime has it; camel:" + name
+ + " is downloaded as an artifact camel-" + name + ",
which does not exist";
+ }
+ return isArtifact(catalog, "camel-" + name)
+ ? null
+ : "is in " + artifact + ": write camel:" +
artifact.substring("camel-".length());
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /** The artifact of the component, language, data format or other of the
given name, or null. */
+ private static String artifactOf(CamelCatalog catalog, String name) {
+ ArtifactModel<?> model = catalog.componentModel(name);
+ if (model == null) {
+ model = catalog.languageModel(name);
+ }
+ if (model == null) {
+ model = catalog.dataFormatModel(name);
+ }
+ if (model == null) {
+ model = catalog.otherModel(name);
+ }
+ return model != null ? model.getArtifactId() : null;
+ }
+
+ /** Whether some component, data format, language or other of the catalog
is in the given artifact. */
+ private static boolean isArtifact(CamelCatalog catalog, String artifactId)
{
+ for (String n : catalog.findComponentNames()) {
+ if (artifactId.equals(artifactOf(catalog, n))) {
+ return true;
+ }
+ }
+ for (String n : catalog.findDataFormatNames()) {
+ ArtifactModel<?> m = catalog.dataFormatModel(n);
+ if (m != null && artifactId.equals(m.getArtifactId())) {
+ return true;
+ }
+ }
+ for (String n : catalog.findLanguageNames()) {
+ ArtifactModel<?> m = catalog.languageModel(n);
+ if (m != null && artifactId.equals(m.getArtifactId())) {
+ return true;
+ }
+ }
+ for (String n : catalog.findOtherNames()) {
+ ArtifactModel<?> m = catalog.otherModel(n);
+ if (m != null && artifactId.equals(m.getArtifactId())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Node value(MappingNode map, String key) {
+ for (NodeTuple t : map.getValue()) {
+ if (key.equals(scalar(t.getKeyNode()))) {
+ return t.getValueNode();
+ }
+ }
+ return null;
+ }
+
+ private static String scalar(Node node) {
+ return node instanceof ScalarNode s ? s.getValue() : null;
+ }
+
+ private static int line(Node node) {
+ return node.getStartMark().getLine();
+ }
+
+ private static final Pattern UNKNOWN_FUNCTION
+ = Pattern.compile(
+ "Unknown function:
(properties[.:]|property[.:]|header\\.|exchangeProperty\\.|variable\\.)?([\\w-]+)");
+
+ /**
+ * The messages of a Kamelet file with what a model gets wrong in its
template: a property of the Kamelet written as
+ * a Simple function (${properties.tag}, ${header.tag}) where the template
has it as the placeholder {{tag}}.
+ */
+ public static List<String> withTemplateHints(String fileName, String
content, List<String> msgs) {
+ if (msgs.isEmpty() || fileName == null ||
!fileName.endsWith(".kamelet.yaml")) {
+ return msgs;
+ }
+ KameletDefinitions.Definition def = KameletDefinitions.parse(content,
fileName, fileName);
+ List<String> answer = new ArrayList<>();
+ for (String m : msgs) {
+ Matcher matcher = UNKNOWN_FUNCTION.matcher(m);
+ // properties.tag is a property whether the file declares it yet
or not; a bare tag only when declared
+ if (matcher.find() && (matcher.group(1) != null
+ || def != null && def.property(matcher.group(2)) != null))
{
+ String p = matcher.group(2);
+ m = m + " (in a Kamelet's template its property " + p + " is
the placeholder {{" + p
+ + "}}, as in simple: \"${body} {{" + p + "}}\"; it is not
a header or an exchange property)";
+ }
+ answer.add(m);
+ }
+ return answer;
+ }
+
+ /**
+ * Whether application properties set the property of the Kamelet:
camel.kamelet.name.prop (or name.routeId.prop),
+ * or through the kamelet component,
camel.component.kamelet.template-properties[name].prop (or route-properties of
+ * a route of it).
+ */
+ static boolean inProperties(Map<String, String> properties, String
kamelet, String property) {
+ String prefix = "camel.kamelet." + kamelet + ".";
+ for (String key : properties.keySet()) {
+ if (key.startsWith(prefix) && (key.equals(prefix + property) ||
key.endsWith("." + property))) {
+ return true;
+ }
+ String k = key.replace("templateProperties",
"template-properties").replace("routeProperties",
+ "route-properties");
+ if (k.startsWith("camel.component.kamelet.template-properties[" +
kamelet + "].")
+ && k.endsWith("]." + property)) {
+ return true;
+ }
+ if (k.startsWith("camel.component.kamelet.route-properties[") &&
k.endsWith("]." + property)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Map<String, String> projectProperties(Path directory) {
+ Map<String, String> answer = new LinkedHashMap<>();
+ try (Stream<Path> files = Files.list(directory)) {
+ files.filter(p ->
p.getFileName().toString().endsWith(".properties")).forEach(p -> {
+ // as java.util.Properties reads them: key=value, key: value,
key value
+ Properties props = new Properties();
+ try (Reader r = Files.newBufferedReader(p)) {
+ props.load(r);
+ } catch (IOException e) {
+ // an unreadable file sets nothing
+ }
+ for (String key : props.stringPropertyNames()) {
+ if (key.startsWith("camel.kamelet.") ||
key.startsWith("camel.component.kamelet.")) {
+ answer.put(key, props.getProperty(key));
+ }
+ }
+ });
+ } catch (IOException e) {
+ // no directory listing: no properties
+ }
+ return answer;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletDefinitions.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletDefinitions.java
new file mode 100644
index 000000000000..a221d46924a5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletDefinitions.java
@@ -0,0 +1,313 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.commands.catalog.KameletCatalogHelper;
+import org.apache.camel.dsl.jbang.core.commands.catalog.KameletModel;
+import org.apache.camel.dsl.jbang.core.commands.catalog.KameletOptionModel;
+import org.apache.camel.dsl.jbang.core.common.VersionHelper;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+/**
+ * The Kamelets an agent can use: those of the Kamelet catalog and the
project's own {@code <name>.kamelet.yaml} files,
+ * with their properties. The catalog tools and the validator read them, so
that a Kamelet is looked up and checked as a
+ * component is: a model knows the components from years of examples, but
writes a Kamelet's properties from the
+ * component's options (brokers on kafka-sink) or uses one that is gone
(kafka-not-secured-source).
+ */
+public final class KameletDefinitions {
+
+ /** A Kamelet and its properties. */
+ public record Definition(String name, String type, String title, String
description, String source,
+ List<Property> properties) {
+
+ public Property property(String name) {
+ for (Property p : properties) {
+ if (p.name().equals(name)) {
+ return p;
+ }
+ }
+ return null;
+ }
+ }
+
+ /** A property of a Kamelet. */
+ public record Property(String name, boolean required, String type, String
defaultValue, String description,
+ List<String> enumValues) {
+ }
+
+ private static final Map<String, Map<String, Definition>> CATALOGS = new
ConcurrentHashMap<>();
+ private static volatile Map<String, Definition> testCatalog;
+
+ private KameletDefinitions() {
+ }
+
+ /** The Kamelet of the project directory, else of the catalog, or null. */
+ public static Definition find(String name, Path directory) {
+ if (name == null || name.isBlank()) {
+ return null;
+ }
+ Definition def = directory != null ?
projectKamelets(directory).get(name) : null;
+ return def != null ? def : catalog().get(name);
+ }
+
+ /** The Kamelets of the catalog by name; empty when the catalog cannot be
read (offline, say). */
+ public static Map<String, Definition> catalog() {
+ Map<String, Definition> fixed = testCatalog;
+ if (fixed != null) {
+ return fixed;
+ }
+ String version;
+ try {
+ version = VersionHelper.extractKameletsVersion();
+ } catch (Exception e) {
+ version = null;
+ }
+ if (version == null) {
+ return Map.of();
+ }
+ return CATALOGS.computeIfAbsent(version,
KameletDefinitions::loadCatalog);
+ }
+
+ /** The Kamelets the catalog has instead of the real catalog, for tests;
null for the real one. */
+ static void setTestCatalog(Map<String, Definition> kamelets) {
+ testCatalog = kamelets;
+ }
+
+ private static Map<String, Definition> loadCatalog(String version) {
+ Map<String, Definition> answer = new LinkedHashMap<>();
+ try {
+ for (Object o : KameletCatalogHelper.loadKamelets(version,
null).values()) {
+ KameletModel km = KameletCatalogHelper.createModel(o, true);
+ List<Property> props = new ArrayList<>();
+ if (km.properties != null) {
+ for (KameletOptionModel om : km.properties.values()) {
+ props.add(new Property(
+ om.name, om.required, om.type,
om.defaultValue, om.description,
+ om.enumValues != null ? om.enumValues :
List.of()));
+ }
+ }
+ answer.put(km.name, new Definition(
+ km.name, km.type, null, km.description,
+ "the Kamelet catalog " + version, props));
+ }
+ } catch (Exception e) {
+ // no catalog: the tools say nothing about Kamelets rather than
the wrong thing
+ }
+ return answer;
+ }
+
+ /** The project's own Kamelets: the *.kamelet.yaml files of the directory
and its subdirectories. */
+ public static Map<String, Definition> projectKamelets(Path directory) {
+ Map<String, Definition> answer = new LinkedHashMap<>();
+ if (directory == null || !Files.isDirectory(directory)) {
+ return answer;
+ }
+ try (Stream<Path> files = Files.walk(directory, 4)) {
+ files.filter(p ->
p.getFileName().toString().endsWith(".kamelet.yaml"))
+ .filter(p -> !hidden(directory.relativize(p)))
+ .sorted()
+ .forEach(p -> {
+ Definition def = parse(p);
+ if (def != null) {
+ answer.putIfAbsent(def.name(), def);
+ }
+ });
+ } catch (IOException e) {
+ // an unreadable directory has no Kamelets
+ }
+ return answer;
+ }
+
+ private static boolean hidden(Path relative) {
+ for (Path part : relative) {
+ String s = part.toString();
+ if (s.startsWith(".") || "target".equals(s) ||
"node_modules".equals(s)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** A Kamelet file as a definition, or null when it is not one (yet): a
half-written file has no Kamelet. */
+ static Definition parse(Path file) {
+ String fileName = file.getFileName().toString();
+ String fallback = fileName.substring(0, fileName.length() -
".kamelet.yaml".length());
+ try {
+ return parse(Files.readString(file), fallback, fileName);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ static Definition parse(String content, String fallbackName, String
fileName) {
+ Object root;
+ try {
+ root = new Yaml(new SafeConstructor(new
LoaderOptions())).load(content);
+ } catch (Exception e) {
+ return null;
+ }
+ if (!(root instanceof Map<?, ?> map) ||
!"Kamelet".equals(map.get("kind"))) {
+ return null;
+ }
+ String name = fallbackName;
+ String type = null;
+ if (map.get("metadata") instanceof Map<?, ?> meta) {
+ if (meta.get("name") instanceof String n && !n.isBlank()) {
+ name = n;
+ }
+ if (meta.get("labels") instanceof Map<?, ?> labels
+ && labels.get("camel.apache.org/kamelet.type") instanceof
String t) {
+ type = t;
+ }
+ }
+ String title = null;
+ String description = null;
+ List<Property> props = new ArrayList<>();
+ if (map.get("spec") instanceof Map<?, ?> spec &&
spec.get("definition") instanceof Map<?, ?> def) {
+ title = def.get("title") instanceof String t ? t : null;
+ description = def.get("description") instanceof String d ? d :
null;
+ Set<String> required = new HashSet<>();
+ if (def.get("required") instanceof List<?> req) {
+ req.forEach(r -> required.add(String.valueOf(r)));
+ }
+ if (def.get("properties") instanceof Map<?, ?> properties) {
+ for (Map.Entry<?, ?> e : properties.entrySet()) {
+ String pname = String.valueOf(e.getKey());
+ Map<String, Object> p = e.getValue() instanceof Map<?, ?>
m ? (Map<String, Object>) m : Map.of();
+ Object dv = p.get("default");
+ List<String> enums = new ArrayList<>();
+ if (p.get("enum") instanceof List<?> en) {
+ en.forEach(v -> enums.add(String.valueOf(v)));
+ }
+ props.add(new Property(
+ pname, required.contains(pname),
+ p.get("type") != null ?
String.valueOf(p.get("type")) : null,
+ dv != null ? String.valueOf(dv) : null,
+ p.get("description") != null ?
String.valueOf(p.get("description")) : null, enums));
+ }
+ }
+ }
+ return new Definition(name, type, title, description, "the project
file " + fileName, props);
+ }
+
+ /**
+ * The Kamelets whose name is close to the given one: those sharing its
words (kafka-not-secured-source has kafka
+ * and source, so kafka-source), then those a few edits away. Best first.
+ */
+ public static List<String> suggest(String name, Collection<String> names,
int limit) {
+ if (name == null || name.isBlank()) {
+ return List.of();
+ }
+ String lower = name.toLowerCase(Locale.ROOT);
+ // a name may repeat a word (aws-s3-to-aws-sqs): Set.of would refuse it
+ Set<String> words = new HashSet<>(Arrays.asList(lower.split("[-_.]")));
+ record Scored(String name, int score) {
+ }
+ List<Scored> scored = new ArrayList<>();
+ for (String candidate : names) {
+ String c = candidate.toLowerCase(Locale.ROOT);
+ String[] cw = c.split("-");
+ int shared = 0;
+ for (String w : cw) {
+ if (words.contains(w)) {
+ shared++;
+ }
+ }
+ int distance = distance(lower, c);
+ // all words of the candidate in the name, or one edit apart for a
short name
+ if (shared == cw.length && shared >= 2) {
+ scored.add(new Scored(candidate, 100 + shared * 10 -
distance));
+ } else if (distance <= Math.max(2, lower.length() / 4)) {
+ scored.add(new Scored(candidate, 50 - distance));
+ } else if (shared >= 2 && shared * 2 >= cw.length) {
+ scored.add(new Scored(candidate, shared * 10 - distance));
+ }
+ }
+ return scored.stream()
+
.sorted(Comparator.comparingInt(Scored::score).reversed().thenComparing(Scored::name))
+ .limit(limit).map(Scored::name).toList();
+ }
+
+ /** The property names close to the given one: same name in another case
or form, or a few edits away. */
+ public static List<String> suggestProperty(String name, Definition def) {
+ String lower = name.toLowerCase(Locale.ROOT).replace("-",
"").replace("_", "");
+ List<String> answer = new ArrayList<>();
+ for (Property p : def.properties()) {
+ String c = p.name().toLowerCase(Locale.ROOT);
+ if (c.equals(lower) || distance(lower, c) <= Math.max(2,
c.length() / 5) || c.contains(lower)
+ || (lower.length() >= 5 && lower.contains(c))) {
+ answer.add(p.name());
+ }
+ }
+ return answer;
+ }
+
+ static int distance(String a, String b) {
+ int[] prev = new int[b.length() + 1];
+ int[] cur = new int[b.length() + 1];
+ for (int j = 0; j <= b.length(); j++) {
+ prev[j] = j;
+ }
+ for (int i = 1; i <= a.length(); i++) {
+ cur[0] = i;
+ for (int j = 1; j <= b.length(); j++) {
+ int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
+ cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1),
prev[j - 1] + cost);
+ }
+ int[] t = prev;
+ prev = cur;
+ cur = t;
+ }
+ return prev[b.length()];
+ }
+
+ /** The properties in one line each: name (required), type, default; for a
message that lists them. */
+ public static String propertyList(Definition def) {
+ StringBuilder sb = new StringBuilder();
+ for (Property p : def.properties()) {
+ if (!sb.isEmpty()) {
+ sb.append(", ");
+ }
+ sb.append(p.name());
+ if (p.required() && p.defaultValue() == null) {
+ sb.append(" (required)");
+ } else if (p.defaultValue() != null) {
+ sb.append(" (default ").append(p.defaultValue()).append(")");
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 3d572d282043..807b5a18217a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -121,6 +121,18 @@ public final class SourceValidator {
// route: read it, so that a GET operation is known to carry no
body (CAMEL-24844)
List<String> msgs = validateCamelYaml(content, catalog,
schemaValidator,
directory != null ?
OpenApiVerbs.bodylessEndpoints(content, directory) : Set.of());
+ if (name.endsWith(".kamelet.yaml") ||
name.endsWith(".kamelet.yml")) {
+ // the shape of a Kamelet file, which the schema of a route
file does not know
+ List<String> shape =
KameletChecks.validateKameletFile(content);
+ if (!shape.isEmpty()) {
+ msgs = new ArrayList<>(msgs);
+ msgs.addAll(shape);
+ }
+ }
+ if (msgs.isEmpty()) {
+ // the kamelet: endpoints against the Kamelet catalog and the
project's own Kamelets
+ msgs = new ArrayList<>(KameletChecks.validateYaml(content,
directory, checkConsumers));
+ }
if (directory != null && msgs.isEmpty()) {
msgs = new ArrayList<>(msgs);
BeanDeclarations declarations =
BeanDeclarations.scan(directory, fileName);
@@ -132,7 +144,7 @@ public final class SourceValidator {
msgs.addAll(EndpointConsumerChecks.validateYamlConsumers(content, directory,
fileName, catalog));
}
}
- return msgs;
+ return KameletChecks.withTemplateHints(name, content, msgs);
}
if (name.endsWith(".properties")) {
return validateProperties(content, catalog, extraPropertyLine);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/KameletCatalogHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/KameletCatalogHelper.java
index dd7961ef8894..9096ed45a2f5 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/KameletCatalogHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/KameletCatalogHelper.java
@@ -101,7 +101,7 @@ public final class KameletCatalogHelper {
private static String getPropertyExample(Object prop) throws Exception {
Method m = prop.getClass().getMethod("getExample");
- Object en = ObjectHelper.invokeMethod(m, prop);
+ Object en = unwrap(ObjectHelper.invokeMethod(m, prop));
if (en != null) {
String t = en.toString();
return StringHelper.removeLeadingAndEndingQuotes(t);
@@ -111,7 +111,7 @@ public final class KameletCatalogHelper {
private static String getPropertyDefaultValue(Object prop) throws
Exception {
Method m = prop.getClass().getMethod("get_default");
- Object dn = ObjectHelper.invokeMethod(m, prop);
+ Object dn = unwrap(ObjectHelper.invokeMethod(m, prop));
if (dn != null) {
String t = dn.toString();
return StringHelper.removeLeadingAndEndingQuotes(t);
@@ -119,13 +119,28 @@ public final class KameletCatalogHelper {
return null;
}
+ /**
+ * The value of a default, example or enum value of the Kamelet model,
which wraps it in an AnyType whose toString
+ * is AnyType(value=1000): the docs showed that instead of 1000.
+ */
+ private static Object unwrap(Object value) {
+ if (value != null &&
value.getClass().getSimpleName().equals("AnyType")) {
+ try {
+ return
ObjectHelper.invokeMethod(value.getClass().getMethod("getValue"), value);
+ } catch (Exception e) {
+ // not the AnyType we know: its text
+ }
+ }
+ return value;
+ }
+
private static List<String> getPropertyEnum(Object prop) throws Exception {
List<String> answer = new ArrayList<>();
Method m = prop.getClass().getMethod("get_enum");
List<Object> list = (List<Object>) ObjectHelper.invokeMethod(m, prop);
if (list != null && !list.isEmpty()) {
for (var en : list) {
- String t = en.toString();
+ String t = String.valueOf(unwrap(en));
t = StringHelper.removeLeadingAndEndingQuotes(t);
answer.add(t);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
new file mode 100644
index 000000000000..066e207ef06a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
@@ -0,0 +1,612 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+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.assertThatCode;
+
+/**
+ * The kamelet: endpoints of a route against the Kamelets they name, and the
Kamelets in the catalog tools: what a model
+ * got wrong writing Kamelets in the Kamelet side check of the local model
benchmark.
+ */
+class KameletChecksTest {
+
+ private static CamelCatalog catalog;
+
+ @BeforeAll
+ static void setUp() {
+ catalog = new DefaultCamelCatalog();
+ Map<String, KameletDefinitions.Definition> kamelets = new
LinkedHashMap<>();
+ kamelets.put("timer-source", new KameletDefinitions.Definition(
+ "timer-source", "source", null,
+ "Produces periodic messages with a custom payload.", "the
Kamelet catalog test", List.of(
+ prop("period", false, "integer", "1000"),
+ prop("message", true, "string", null),
+ prop("contentType", false, "string", "text/plain"),
+ prop("repeatCount", false, "integer", null))));
+ kamelets.put("kafka-source", new KameletDefinitions.Definition(
+ "kafka-source", "source", null,
+ "Receive data from Kafka topics.", "the Kamelet catalog test",
List.of(
+ prop("topic", true, "string", null),
+ prop("bootstrapServers", true, "string", null),
+ prop("saslAuthType", false, "string", "NONE"))));
+ kamelets.put("kafka-sink", new KameletDefinitions.Definition(
+ "kafka-sink", "sink", null,
+ "Send data to Kafka topics.", "the Kamelet catalog test",
List.of(
+ prop("topic", true, "string", null),
+ prop("bootstrapServers", true, "string", null))));
+ kamelets.put("log-sink", new KameletDefinitions.Definition(
+ "log-sink", "sink", null,
+ "Log data to the console.", "the Kamelet catalog test",
List.of(
+ prop("showHeaders", false, "boolean", "false"),
+ prop("showStreams", false, "boolean", "false"))));
+ KameletDefinitions.setTestCatalog(kamelets);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ KameletDefinitions.setTestCatalog(null);
+ }
+
+ private static KameletDefinitions.Property prop(String name, boolean
required, String type, String defaultValue) {
+ return new KameletDefinitions.Property(name, required, type,
defaultValue, null, List.of());
+ }
+
+ @Test
+ void rightKameletsHaveNoErrors() {
+ String yaml = """
+ - route:
+ from:
+ uri: kamelet:timer-source
+ parameters:
+ period: 2000
+ message: hello
+ steps:
+ - to:
+ uri: kamelet:kafka-sink
+ parameters:
+ topic: orders
+ bootstrapServers: localhost:9092
+ - to: kamelet:log-sink
+ """;
+ assertThat(KameletChecks.validateYaml(yaml, null)).isEmpty();
+ }
+
+ @Test
+ void aKameletThatIsGone() {
+ // kafka-not-secured-source was folded into kafka-source (saslAuthType
NONE); a model still writes it
+ String yaml = """
+ - route:
+ from:
+ uri: kamelet:kafka-not-secured-source
+ parameters:
+ topic: orders
+ bootstrapServers: localhost:9092
+ steps:
+ - to: kamelet:log-sink
+ """;
+ List<String> errors = KameletChecks.validateYaml(yaml, null);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).startsWith("Line 3: ")
+ .contains("no Kamelet named kafka-not-secured-source")
+ .contains("Did you mean: kafka-source");
+ }
+
+ @Test
+ void anUnknownNameOnAWriteOnlyWhenCloseToAKnownOne() {
+ // the project's own Kamelet may be the next file the model writes
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to: kamelet:tag-order-action
+ - to: kamelet:kafka-not-secured-sink
+ """;
+ List<String> errors = KameletChecks.validateYaml(yaml, null, false);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).startsWith("Line 6: ").contains("Did you
mean: kafka-sink");
+ assertThat(KameletChecks.validateYaml(yaml, null, true)).hasSize(2);
+ }
+
+ @Test
+ void theOptionsOfTheComponentInsteadOfTheKamelet() {
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: kamelet:kafka-sink
+ parameters:
+ topic: orders
+ brokers: localhost:9092
+ """;
+ List<String> errors = KameletChecks.validateYaml(yaml, null);
+ assertThat(errors).hasSize(2);
+ assertThat(errors.get(0)).startsWith("Line 9: ")
+ .contains("kamelet:kafka-sink: unknown property 'brokers'")
+ .contains("The properties of kafka-sink: topic (required),
bootstrapServers (required)");
+ assertThat(errors.get(1)).startsWith("Line 6: ")
+ .contains("the required property bootstrapServers is missing")
+ .contains("mandatory parameters must be provided:
bootstrapServers");
+ }
+
+ @Test
+ void aMisspelledOptionalProperty() {
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to: kamelet:log-sink?showHeader=true
+ """;
+ List<String> errors = KameletChecks.validateYaml(yaml, null);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).contains("unknown property
'showHeader'").contains("Did you mean: showHeaders");
+ }
+
+ @Test
+ void aRequiredPropertyLeftOut() {
+ String yaml = """
+ - route:
+ from:
+ uri: kamelet:timer-source
+ parameters:
+ period: 2000
+ jsonBody: '{"a": 1}'
+ steps:
+ - to: kamelet:log-sink
+ """;
+ List<String> errors = KameletChecks.validateYaml(yaml, null);
+ assertThat(errors).hasSize(2);
+ assertThat(errors.get(0)).contains("unknown property 'jsonBody'");
+ assertThat(errors.get(1)).startsWith("Line 3: ").contains("the
required property message is missing");
+ }
+
+ @Test
+ void aRequiredPropertyFromApplicationProperties(@TempDir Path dir) throws
Exception {
+ Files.writeString(dir.resolve("application.properties"),
"camel.kamelet.timer-source.message=hello\n");
+ String yaml = """
+ - route:
+ from:
+ uri: kamelet:timer-source
+ steps:
+ - to: kamelet:log-sink
+ """;
+ assertThat(KameletChecks.validateYaml(yaml, dir)).isEmpty();
+ }
+
+ @Test
+ void theProjectsOwnKamelet(@TempDir Path dir) throws Exception {
+ Files.writeString(dir.resolve("tag-order-action.kamelet.yaml"), """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: tag-order-action
+ labels:
+ camel.apache.org/kamelet.type: action
+ spec:
+ definition:
+ title: Tag Order
+ required:
+ - tag
+ properties:
+ tag:
+ type: string
+ prefix:
+ type: string
+ default: "#"
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - setBody:
+ simple: "${body} [{{prefix}}{{tag}}]"
+ """);
+ String ok = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: kamelet:tag-order-action
+ parameters:
+ tag: priority
+ """;
+ assertThat(KameletChecks.validateYaml(ok, dir)).isEmpty();
+ String wrong = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: kamelet:tag-order-action
+ parameters:
+ label: priority
+ """;
+ List<String> errors = KameletChecks.validateYaml(wrong, dir);
+ assertThat(errors).hasSize(2);
+ assertThat(errors.get(0)).contains("unknown property 'label'")
+ .contains("The properties of tag-order-action (the project
file tag-order-action.kamelet.yaml): "
+ + "tag (required), prefix (default #)");
+ assertThat(errors.get(1)).contains("the required property tag is
missing");
+ }
+
+ private static final String TAG_KAMELET = """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: tag-order-action
+ labels:
+ camel.apache.org/kamelet.type: action
+ spec:
+ definition:
+ title: Tag Order
+ required:
+ - tag
+ properties:
+ tag:
+ title: Tag
+ type: string
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression: "%s"
+ """;
+
+ @Test
+ void aRightKameletFileIsValid(@TempDir Path dir) {
+ String content = TAG_KAMELET.formatted("${body} [{{tag}}]");
+ assertThat(SourceValidator.validate("tag-order-action.kamelet.yaml",
content, catalog, null, dir)).isEmpty();
+ }
+
+ @Test
+ void aKameletPropertyWrittenAsAFunction(@TempDir Path dir) {
+ String content = TAG_KAMELET.formatted("${body} [${properties.tag}]");
+ List<String> errors =
SourceValidator.validate("tag-order-action.kamelet.yaml", content, catalog,
null, dir);
+ assertThat(errors).anySatisfy(e -> assertThat(e).contains("Unknown
function: properties.tag")
+ .contains("its property tag is the placeholder {{tag}}"));
+ }
+
+ @Test
+ void theShapeOfAKameletFile(@TempDir Path dir) {
+ // what a local model wrote in the benchmark: properties under spec,
required on the property, do: as the route
+ String content = """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: tag-order-action
+ labels:
+ camel.apache.org/kamelet.type: action
+ spec:
+ definition:
+ title: Tag Order Action
+ properties:
+ tag:
+ title: Tag
+ type: string
+ required: true
+ do:
+ - setBody:
+ simple: "${body} [${properties.tag}]"
+ """;
+ List<String> errors =
SourceValidator.validate("tag-order-action.kamelet.yaml", content, catalog,
null, dir);
+ assertThat(errors).anySatisfy(e -> assertThat(e).startsWith("Line 10:
").contains("spec.properties is not a key")
+ .contains("spec.definition"));
+ assertThat(errors).anySatisfy(e -> assertThat(e).startsWith("Line 15:
").contains("spec.do is not a key")
+ .contains("template: {from: {uri: kamelet:source"));
+ assertThat(errors).anySatisfy(e -> assertThat(e).contains("has no
spec.template"));
+ assertThat(errors).anySatisfy(e -> assertThat(e).contains("Unknown
function: properties.tag")
+ .contains("the placeholder {{tag}}"));
+ }
+
+ @Test
+ void aRequiredMarkOnAPropertyAndAnActionFromAComponent() {
+ String content = """
+ kind: Kamelet
+ metadata:
+ name: my-action
+ labels:
+ camel.apache.org/kamelet.type: action
+ spec:
+ definition:
+ required:
+ - tag
+ - other
+ properties:
+ tag:
+ type: string
+ required: true
+ template:
+ from:
+ uri: timer:tick
+ steps:
+ - to: kamelet:sink
+ """;
+ List<String> errors = KameletChecks.validateKameletFile(content);
+ assertThat(errors).anySatisfy(e ->
assertThat(e).contains("properties.tag.required"));
+ assertThat(errors).anySatisfy(e -> assertThat(e).contains("lists
other, which is not under"));
+ assertThat(errors).anySatisfy(e -> assertThat(e).contains("an action
Kamelet starts from: {uri: kamelet:source}"));
+ }
+
+ @Test
+ void aTemplateThatStartsFromItself() {
+ // the benchmark: from: kamelet:tag-order-action/route in
tag-order-action recursed to a StackOverflowError
+ String content = """
+ kind: Kamelet
+ metadata:
+ name: tag-order-action
+ spec:
+ template:
+ from:
+ uri: kamelet:tag-order-action/route
+ steps:
+ - setBody:
+ simple: "${body} [{{tag}}]"
+ """;
+ assertThat(KameletChecks.validateKameletFile(content)).anySatisfy(
+ e -> assertThat(e).startsWith("Line 7: ").contains("is entered
from kamelet:source"));
+ }
+
+ @Test
+ void stepsUnderTheTemplate() {
+ String content = """
+ kind: Kamelet
+ metadata:
+ name: my-action
+ spec:
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - to: kamelet:sink
+ """;
+ assertThat(KameletChecks.validateKameletFile(content)).anySatisfy(
+ e -> assertThat(e).contains("spec.template.steps: the steps go
under from:"));
+ }
+
+ @Test
+ void theDependenciesOfAKamelet() {
+ // camel:simple failed to download on every reload: simple is in camel
core
+ assertThat(KameletChecks.camelDependency("simple")).contains("part of
camel core").contains("leave it out");
+ assertThat(KameletChecks.camelDependency("timer")).isNull();
+ assertThat(KameletChecks.camelDependency("jackson")).isNull();
+ assertThat(KameletChecks.camelDependency("jq")).isNull();
+ assertThat(KameletChecks.camelDependency("core")).isNull();
+
assertThat(KameletChecks.camelDependency("no-such-thing")).contains("is not a
Camel artifact");
+ }
+
+ @Test
+ void aGoodKameletFileHasNoShapeErrors() {
+
assertThat(KameletChecks.validateKameletFile(TAG_KAMELET.formatted("${body}
[{{tag}}]"))).isEmpty();
+ }
+
+ @Test
+ void anUnknownDocNameWithKameletPointsToTheGuide() {
+ JsonObject doc = CatalogDocs.catalogDoc(catalog, "kamelet-custom",
null, null, null, null, false, false, null);
+ assertThat(doc.toJson()).contains("camel_catalog_doc name=kamelet
docPage=custom");
+ }
+
+ @Test
+ void aPropertyTheProjectsKameletLacks(@TempDir Path dir) throws Exception {
+ Files.writeString(dir.resolve("tag-order-action.kamelet.yaml"), """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: tag-order-action
+ spec:
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - to: kamelet:sink
+ """);
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: kamelet:tag-order-action
+ parameters:
+ tag: priority
+ """;
+ List<String> errors = KameletChecks.validateYaml(yaml, dir);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).contains("unknown property
'tag'").contains(": none")
+ .contains("declared in its file under
spec.definition.properties");
+ }
+
+ @Test
+ void anUnknownNameThatRepeatsAWordDoesNotCrash() {
+ // a reproducer of the review: Set.of refused the duplicate aws
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to: kamelet:aws-s3-to-aws-sqs
+ """;
+ assertThatCode(() -> KameletChecks.validateYaml(yaml,
null)).doesNotThrowAnyException();
+ assertThatCode(() -> CatalogDocs.catalogDoc(catalog, "kafka-to-kafka",
null, null, null, null, false, false,
+ null)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void theOptionsOfTheKameletComponentAreNotUnknownProperties() {
+ // routeId and noErrorHandler in the uri, location and timeout under
parameters: options of the kamelet: endpoint
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
kamelet:log-sink?routeId=myLog&noErrorHandler=false
+ - to:
+ uri: kamelet:log-sink
+ parameters:
+ location: file:/k/log-sink.kamelet.yaml
+ timeout: 5000
+ """;
+ assertThat(KameletChecks.validateYaml(yaml, null)).isEmpty();
+ }
+
+ @Test
+ void aRequiredPropertyWithAColonSeparator(@TempDir Path dir) throws
Exception {
+ Files.writeString(dir.resolve("application.properties"),
"camel.kamelet.timer-source.message: hello\n");
+ assertThat(KameletChecks.validateYaml(TIMER_TO_LOG, dir)).isEmpty();
+ }
+
+ @Test
+ void aRequiredPropertyFromTheTemplatePropertiesOfTheComponent(@TempDir
Path dir) throws Exception {
+ Files.writeString(dir.resolve("application.properties"),
+
"camel.component.kamelet.template-properties[timer-source].message=hello\n");
+ assertThat(KameletChecks.validateYaml(TIMER_TO_LOG, dir)).isEmpty();
+ }
+
+ private static final String TIMER_TO_LOG = """
+ - route:
+ from:
+ uri: kamelet:timer-source
+ steps:
+ - to: kamelet:log-sink
+ """;
+
+ @Test
+ void theEndsOfAKameletTemplateAreNotKamelets() {
+ String yaml = """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: my-action
+ spec:
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - to: kamelet:sink
+ """;
+ assertThat(KameletChecks.validateYaml(yaml, null)).isEmpty();
+ }
+
+ @Test
+ void theSourceValidatorChecksKamelets(@TempDir Path dir) {
+ String yaml = """
+ - route:
+ from:
+ uri: kamelet:timer-source
+ parameters:
+ period: 2000
+ steps:
+ - to: kamelet:log-sink
+ """;
+ List<String> errors = SourceValidator.validate("orders.camel.yaml",
yaml, catalog, null, dir);
+ assertThat(errors).anySatisfy(e -> assertThat(e).contains("the
required property message is missing"));
+ }
+
+ @Test
+ void theCatalogDocOfAKamelet() {
+ JsonObject doc = CatalogDocs.catalogDoc(catalog, "kafka-source", null,
null, null, null, false, false, null);
+ assertThat(doc.getString("kind")).isEqualTo("kamelet");
+ assertThat(doc.getString("yaml"))
+ .startsWith("from: {uri: kamelet:kafka-source, parameters:
{topic: <topic>, bootstrapServers:");
+
assertThat(doc.toJson()).contains("\"name\":\"saslAuthType\"").contains("\"defaultValue\":\"NONE\"");
+ }
+
+ @Test
+ void theCatalogDocSuggestsAKamelet() {
+ JsonObject doc = CatalogDocs.catalogDoc(catalog,
"kafka-not-secured-source", null, null, null, null, false,
+ false, null);
+ assertThat(doc.toJson()).contains("kafka-source");
+ }
+
+ @Test
+ void howToWriteAKameletWithKindKamelet() {
+ // a model asked name=kamelet kind=kamelet docPage=custom 14 times and
got "Kamelet not found"
+ JsonObject doc = CatalogDocs.catalogDoc(catalog, "kamelet", null,
"kamelet", null, null, false, false, "custom");
+ assertThat(doc.getString("kind")).isEqualTo("component");
+ assertThat(doc.getString("name")).isEqualTo("kamelet");
+ JsonObject other = CatalogDocs.catalogDoc(catalog, "kamelet-custom",
null, "kamelet", null, null, false, false,
+ "custom");
+ assertThat(other.getString("name")).isEqualTo("kamelet");
+ }
+
+ @Test
+ void aKameletNotInTheCatalogPointsToHowToWriteOne() {
+ JsonObject doc = CatalogDocs.catalogDoc(catalog, "tag-order-action",
null, "kamelet", null, null, false, false,
+ null);
+ assertThat(doc.toJson()).contains("Kamelet not found:
tag-order-action")
+ .contains("camel_catalog_doc name=kamelet docPage=custom");
+ }
+
+ @Test
+ void theSampleOfKameletShowsAKameletFile() {
+ // every model asked camel_catalog_sample kamelet first, and got only
routes that call a Kamelet
+ JsonObject sample = CatalogSamples.sample(catalog, "kamelet", 3);
+ JsonObject file = (JsonObject) sample.get("kameletFile");
+ assertThat(file).isNotNull();
+ assertThat(file.getString("yaml")).contains("kind:
Kamelet").contains("uri: kamelet:source");
+
assertThat(file.getString("placement")).contains("<name>.kamelet.yaml");
+ assertThat(sample.getString("guide")).contains("docPage=custom");
+ }
+
+ @Test
+ void aSampleWithKindKamelet() {
+ assertThat(CatalogSamples.sample(catalog, "kamelet",
"tag-order-action", 3).getString("kind"))
+ .isEqualTo("kamelet file");
+ }
+
+ @Test
+ void aSampleOfAKameletByAnotherName() {
+ // kamelet-custom, as a model named it after its project
+ JsonObject sample = CatalogSamples.sample(catalog, "kamelet-custom",
3);
+ assertThat(sample.getString("kind")).isEqualTo("kamelet file");
+ assertThat(sample.toJson()).contains("kind:
Kamelet").contains("kamelet.type: action");
+ }
+
+ @Test
+ void theDocPagesAreNamedWithTheirCall() {
+ JsonObject doc = CatalogDocs.catalogDoc(catalog, "kamelet", null,
null, null, null, false, false, null);
+ assertThat(doc.getString("docPagesHint")).contains("docPage=custom:
Writing a custom Kamelet");
+ }
+
+ @Test
+ void theCatalogFindsKamelets() {
+ JsonObject found = CatalogDocs.find(catalog, "kafka", "kamelet", 10);
+
assertThat(found.toJson()).contains("\"kamelet:kafka-sink\"").contains("\"kamelet:kafka-source\"");
+ JsonObject all = CatalogDocs.find(catalog, "timer source", null, 10);
+ assertThat(all.toJson()).contains("\"kamelet:timer-source\"");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index a4b20b6be28a..80327185eb75 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -53,7 +53,7 @@ public class AuthoringTools {
RepeatedCallSessions repeatedCalls;
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
- description = "Camel catalog documentation of a component, data
format, language, EIP, built-in bean or the "
+ description = "Camel catalog documentation of a component, data
format, language, EIP, Kamelet, built-in bean or the "
+ "Java API (description, options, Maven coordinates),
with the URI rules of a component. For "
+ "simple also its syntax rules, functions and
operators: count and names by group, or with "
+ "optionsFilter the matching ones with parameters and
examples. kind=api is the Java API to "
@@ -69,7 +69,7 @@ public class AuthoringTools {
required = false) String name,
@ToolArg(description = "Endpoint URI to check, e.g.
kafka:orders?brokers=host:9092",
required = false) String endpoint,
- @ToolArg(description = "component, dataformat, language, eip, bean
or api (auto-detected; a bean is a built-in class such as
StringAggregationStrategy, with how to declare and use it; api is the Java API
to call from a bean or script before writing it: Exchange, Message,
CamelContext, Registry, ProducerTemplate, Processor, AggregationStrategy,
Predicate, Expression, TypeConverter, or the variables of groovy, js, python,
java scripts)",
+ @ToolArg(description = "component, dataformat, language, eip,
kamelet, bean or api (auto-detected; a bean is a built-in class such as
StringAggregationStrategy, with how to declare and use it; api is the Java API
to call from a bean or script before writing it: Exchange, Message,
CamelContext, Registry, ProducerTemplate, Processor, AggregationStrategy,
Predicate, Expression, TypeConverter, or the variables of groovy, js, python,
java scripts)",
required = false) String kind,
@ToolArg(description = "common (default: no deprecated or
advanced), required, all or false",
required = false) String includeOptions,
@@ -88,14 +88,14 @@ public class AuthoringTools {
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
- description = "Finds Camel components, data formats, languages and
EIPs by a protocol, product, alias or "
+ description = "Finds Camel components, data formats, languages, EIPs
and Kamelets by a protocol, product, alias or "
+ "other term that is not the exact name (mqtt, s3,
snowflake, csv, fan-out, dedup): best "
+ "match first with title and description.
camel_catalog_doc then gives the options of one.")
@MetaField(prefix = "camel.apache.org/", name = "deterministic", type =
MetaField.Type.BOOLEAN, value = "true")
public JsonObject camel_catalog_find(
McpConnection connection,
@ToolArg(description = "What to look for, e.g. mqtt, s3, database,
csv, fan-out", required = true) String term,
- @ToolArg(description = "component, dataformat, language, eip or
bean (default: all); bean with an interface name such as AggregationStrategy
lists the built-in implementations",
+ @ToolArg(description = "component, dataformat, language, eip,
kamelet or bean (default: all); bean with an interface name such as
AggregationStrategy lists the built-in implementations",
required = false) String kind,
@ToolArg(description = "Maximum matches per kind (default 10)",
required = false) Integer limit,
@ToolArg(description = VERSION_DESC, required = false) String
camelVersion) {