This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-25379-kamelet-ai-tools in repository https://gitbox.apache.org/repos/asf/camel.git
commit fea5be60d1e4c1e550f8b9cb3bf475bd1140a03c Author: Claus Ibsen <[email protected]> AuthorDate: Tue Oct 6 17:13:31 2026 +0200 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 --- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 8 +- .../dsl/jbang/core/commands/ai/CatalogDocs.java | 97 ++++++ .../dsl/jbang/core/commands/ai/KameletChecks.java | 282 +++++++++++++++ .../jbang/core/commands/ai/KameletDefinitions.java | 311 +++++++++++++++++ .../jbang/core/commands/ai/SourceValidator.java | 6 +- .../commands/catalog/KameletCatalogHelper.java | 21 +- .../jbang/core/commands/ai/KameletChecksTest.java | 388 +++++++++++++++++++++ .../jbang/core/commands/mcp/AuthoringTools.java | 8 +- 8 files changed, 1109 insertions(+), 12 deletions(-) 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..723d49b6b020 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 @@ -309,6 +309,16 @@ 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) { + return notFound("Kamelet", name, + KameletDefinitions.suggest(name, KameletDefinitions.catalog().keySet(), 5)); + } + } JsonObject group = mainOptionsGroup(catalog, name); if (group != null) { return group; @@ -323,9 +333,78 @@ 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()); + suggestions.addAll(KameletDefinitions.suggest(name, KameletDefinitions.catalog().keySet(), 3)); return notFound("Artifact", name, suggestions); } + /** 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; + } + /** * The documentation of the EIP a term such as an alias (fan-out, dedup, rate-limit) or a word of the title names, * with the term the caller used; null when no EIP matches. @@ -534,6 +613,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()) { 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..cd9491798374 --- /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,282 @@ +/* + * 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.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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.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() + ")" : ""; + for (Map.Entry<String, Integer> e : given.entrySet()) { + if (def.property(e.getKey()) == null) { + 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 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); + if (def == null || def.properties().isEmpty()) { + return msgs; + } + List<String> answer = new ArrayList<>(); + for (String m : msgs) { + Matcher matcher = UNKNOWN_FUNCTION.matcher(m); + if (matcher.find() && def.property(matcher.group(1)) != null) { + String p = matcher.group(1); + 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. */ + 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; + } + } + 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 -> { + try { + for (String l : Files.readAllLines(p)) { + String s = l.trim(); + int eq = s.indexOf('='); + if (s.startsWith("camel.kamelet.") && eq > 0) { + answer.put(s.substring(0, eq).trim(), s.substring(eq + 1).trim()); + } + } + } catch (IOException e) { + // an unreadable file sets nothing + } + }); + } 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..38a48dbf396c --- /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,311 @@ +/* + * 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.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); + Set<String> words = Set.of(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..cab4549e326d 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,10 @@ 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 (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 +136,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..4b7de4993365 --- /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,388 @@ +/* + * 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; + +/** + * 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 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 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 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) {
