This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25283 in repository https://gitbox.apache.org/repos/asf/camel.git
commit dd098a3a6bdf916dca467d635f35f012aaa95ead Author: Claus Ibsen <[email protected]> AuthorDate: Fri Oct 2 20:45:03 2026 +0200 CAMEL-25283: camel-kamelet - document how to write a custom Kamelet, and let camel_catalog_doc return a component's sub-pages The kamelet page gets a short section on writing a custom Kamelet and its three kinds (source, sink, action), and a new sub-page kamelet-custom.adoc shows the file, the parameters ({{name}}, {{?name}}, defaults), one example of each kind, how a route uses them, and where Camel finds a Kamelet. The examples were run with camel run. camel_catalog_doc now answers docPage for components too: the sub-pages a component page links to under others are listed as docPages with their titles, and docPage=<page> returns one. A model is pointed at the Kamelet page from the kamelet answer, from its samples, and when a .kamelet.yaml it validates or writes is invalid. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/docs.properties | 1 + .../camel/catalog/docs/kamelet-component.adoc | 12 ++ .../apache/camel/catalog/docs/kamelet-custom.adoc | 228 +++++++++++++++++++++ .../src/main/docs/kamelet-component.adoc | 12 ++ .../src/main/docs/kamelet-custom.adoc | 228 +++++++++++++++++++++ docs/components/modules/others/nav.adoc | 1 + .../modules/others/pages/kamelet-custom.adoc | 1 + .../dsl/jbang/core/commands/ai/AuthoringTools.java | 13 ++ .../dsl/jbang/core/commands/ai/CatalogDocs.java | 48 ++++- .../dsl/jbang/core/commands/ai/CatalogSamples.java | 4 + .../commands/ai/CatalogDocComponentPagesTest.java | 147 +++++++++++++ 11 files changed, 693 insertions(+), 2 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties index 2f4b8a857b91..3971d7d6053c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties @@ -330,6 +330,7 @@ jta jte-component kafka-component kamelet-component +kamelet-custom kamelet-eip kamelet-main kamelet-main-support diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc index 0ae87469eadf..dbf297fb88f6 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc @@ -39,6 +39,18 @@ The *kamelet* endpoint is *lenient*, which means that the endpoint accepts addit If a xref:manual::route-template.adoc[Route Template] is not found, the *kamelet* endpoint tries to load the related *kamelet* definition from the file system (by default `classpath:kamelets`). The default resolution mechanism expects _Kamelets_ files to have the extension `.kamelet.yaml`. +=== Writing a custom Kamelet + +You can write your own Kamelets, for a piece of an integration that several routes need. A Kamelet is a +`.kamelet.yaml` file with a route template and a description of its parameters, and it is one of three kinds, +set by the `camel.apache.org/kamelet.type` label: + +* `source`: produces messages, and is used as the start of a route (`from: kamelet:<name>`). +* `sink`: receives the message of a route and sends it somewhere (`to: kamelet:<name>`). +* `action`: changes the message as a step in the middle of a route (`to: kamelet:<name>`). + +See xref:others:kamelet-custom.adoc[Writing a custom Kamelet] for the file, with an example of each kind. + === Error Handling The error handling when using kamelets are using the same error handling 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 new file mode 100644 index 000000000000..3447a0499ffc --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc @@ -0,0 +1,228 @@ += Kamelet - Writing a custom Kamelet +:tabs-sync-option: + +xref:ROOT:kamelet-component.adoc[Back to Kamelet Component] + +A Kamelet is a route template in a `.kamelet.yaml` file, together with a description of the parameters it takes. +You write one when the same piece of an integration is needed in several routes: a policy such as removing +personal data, a connection to a system with your company's settings, or a destination every route writes to. +The routes then use it by name, with their own parameter values, like any other endpoint. + +== The three kinds of Kamelet + +Every Kamelet is one of three kinds, set by the `camel.apache.org/kamelet.type` label. The kind decides where its +template starts and ends, and where a route uses it. + +[cols="1,3,2",options="header"] +|=== +| Kind | Template | Used in a route as + +| `source` +| Starts at a component that produces messages (a timer, a queue, an API), and ends with `to: kamelet:sink`, which + hands each message to the route. +| `from: kamelet:<name>` + +| `sink` +| Starts with `from: kamelet:source`, which receives the message of the route, and ends at a component that sends + it somewhere (a file, a topic, an API). +| `to: kamelet:<name>`, usually as the last step + +| `action` +| Starts with `from: kamelet:source` and changes the message (filter fields, convert, enrich). The changed message + goes back to the route. +| `to: kamelet:<name>`, as a step in the middle +|=== + +== The file + +A Kamelet file is a YAML object (not a list of routes like a `.camel.yaml` file) with these parts: + +`apiVersion` and `kind`:: always `camel.apache.org/v1` and `Kamelet`. +`metadata.name`:: the name routes use in `kamelet:<name>`. Name the file after it: `<name>.kamelet.yaml`. +`metadata.labels`:: `camel.apache.org/kamelet.type` with `source`, `sink` or `action`. +`spec.definition`:: the parameters as a JSON schema: a `title` and `description` for the Kamelet, the names of the +`required` parameters, and under `properties` each parameter with its `title`, `description`, `type`, and an +optional `default`. +`spec.dependencies`:: the Camel components and libraries the template uses, as `camel:<name>` or +`mvn:<groupId>:<artifactId>:<version>`. +`spec.template`:: the route itself, written as in the YAML DSL: a `from` with its `steps`, and optionally `beans`. + +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. + +== A source Kamelet + +This source produces a sample order at a fixed interval. The route that uses it chooses the customer, and may change +the interval, which has a default. + +.order-source.kamelet.yaml +[source,yaml] +---- +apiVersion: camel.apache.org/v1 +kind: Kamelet +metadata: + name: order-source + labels: + camel.apache.org/kamelet.type: source +spec: + definition: + title: Order Source + description: Produces a sample order at a fixed interval + required: + - customer + properties: + customer: + title: Customer + description: The customer name to put in the order + type: string + period: + title: Period + description: Milliseconds between orders + type: integer + default: 5000 + dependencies: + - "camel:timer" + template: + from: + uri: timer:orders + parameters: + period: "{{period}}" + steps: + - setBody: + 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. + +.archive-sink.kamelet.yaml +[source,yaml] +---- +apiVersion: camel.apache.org/v1 +kind: Kamelet +metadata: + name: archive-sink + labels: + camel.apache.org/kamelet.type: sink +spec: + definition: + title: Archive Sink + description: Writes each message to a file in a directory + required: + - directory + properties: + directory: + title: Directory + description: The directory to write the files to + type: string + fileExtension: + title: File Extension + description: The extension of the file names + type: string + default: json + dependencies: + - "camel:file" + template: + from: + uri: kamelet:source + steps: + - to: + uri: "file:{{directory}}" + parameters: + fileName: "${exchangeId}.{{fileExtension}}" +---- + +== Using the Kamelets in a route + +A route uses each Kamelet by name, passing the parameters as endpoint parameters. Parameters with a default can be +left out, or given to override the default: + +.orders.camel.yaml +[source,yaml] +---- +- route: + id: export-orders + from: + uri: kamelet:order-source + parameters: + customer: Donald + period: 2000 + steps: + - to: + uri: kamelet:content-filter-action + parameters: + allowlist: id,country + - log: + message: "Filtered: ${body}" + - to: + uri: kamelet:archive-sink + parameters: + directory: target/orders +---- + +Each file written then holds only the fields that are allowed, such as `{"id":"...","country":"DK"}`. + +== Where Camel finds a Kamelet + +When a route uses `kamelet:<name>`, Camel loads `<name>.kamelet.yaml` from the `location` of the Kamelet component, +which is `classpath:kamelets` by default. Set `camel.component.kamelet.location` to look elsewhere; several locations +can be given, separated by comma. + +With the Camel CLI, the Kamelet files can simply be run with the route: + +[source,bash] +---- +camel run orders.camel.yaml order-source.kamelet.yaml content-filter-action.kamelet.yaml archive-sink.kamelet.yaml +---- + +A Kamelet can also call another Kamelet in its template, as any route can. + +== Checking a Kamelet + +`camel validate yaml` checks a Kamelet file: the template against the YAML DSL, with the errors reported at their +line in the template. + +[source,bash] +---- +camel validate yaml content-filter-action.kamelet.yaml +---- diff --git a/components/camel-kamelet/src/main/docs/kamelet-component.adoc b/components/camel-kamelet/src/main/docs/kamelet-component.adoc index 0ae87469eadf..dbf297fb88f6 100644 --- a/components/camel-kamelet/src/main/docs/kamelet-component.adoc +++ b/components/camel-kamelet/src/main/docs/kamelet-component.adoc @@ -39,6 +39,18 @@ The *kamelet* endpoint is *lenient*, which means that the endpoint accepts addit If a xref:manual::route-template.adoc[Route Template] is not found, the *kamelet* endpoint tries to load the related *kamelet* definition from the file system (by default `classpath:kamelets`). The default resolution mechanism expects _Kamelets_ files to have the extension `.kamelet.yaml`. +=== Writing a custom Kamelet + +You can write your own Kamelets, for a piece of an integration that several routes need. A Kamelet is a +`.kamelet.yaml` file with a route template and a description of its parameters, and it is one of three kinds, +set by the `camel.apache.org/kamelet.type` label: + +* `source`: produces messages, and is used as the start of a route (`from: kamelet:<name>`). +* `sink`: receives the message of a route and sends it somewhere (`to: kamelet:<name>`). +* `action`: changes the message as a step in the middle of a route (`to: kamelet:<name>`). + +See xref:others:kamelet-custom.adoc[Writing a custom Kamelet] for the file, with an example of each kind. + === Error Handling The error handling when using kamelets are using the same error handling diff --git a/components/camel-kamelet/src/main/docs/kamelet-custom.adoc b/components/camel-kamelet/src/main/docs/kamelet-custom.adoc new file mode 100644 index 000000000000..3447a0499ffc --- /dev/null +++ b/components/camel-kamelet/src/main/docs/kamelet-custom.adoc @@ -0,0 +1,228 @@ += Kamelet - Writing a custom Kamelet +:tabs-sync-option: + +xref:ROOT:kamelet-component.adoc[Back to Kamelet Component] + +A Kamelet is a route template in a `.kamelet.yaml` file, together with a description of the parameters it takes. +You write one when the same piece of an integration is needed in several routes: a policy such as removing +personal data, a connection to a system with your company's settings, or a destination every route writes to. +The routes then use it by name, with their own parameter values, like any other endpoint. + +== The three kinds of Kamelet + +Every Kamelet is one of three kinds, set by the `camel.apache.org/kamelet.type` label. The kind decides where its +template starts and ends, and where a route uses it. + +[cols="1,3,2",options="header"] +|=== +| Kind | Template | Used in a route as + +| `source` +| Starts at a component that produces messages (a timer, a queue, an API), and ends with `to: kamelet:sink`, which + hands each message to the route. +| `from: kamelet:<name>` + +| `sink` +| Starts with `from: kamelet:source`, which receives the message of the route, and ends at a component that sends + it somewhere (a file, a topic, an API). +| `to: kamelet:<name>`, usually as the last step + +| `action` +| Starts with `from: kamelet:source` and changes the message (filter fields, convert, enrich). The changed message + goes back to the route. +| `to: kamelet:<name>`, as a step in the middle +|=== + +== The file + +A Kamelet file is a YAML object (not a list of routes like a `.camel.yaml` file) with these parts: + +`apiVersion` and `kind`:: always `camel.apache.org/v1` and `Kamelet`. +`metadata.name`:: the name routes use in `kamelet:<name>`. Name the file after it: `<name>.kamelet.yaml`. +`metadata.labels`:: `camel.apache.org/kamelet.type` with `source`, `sink` or `action`. +`spec.definition`:: the parameters as a JSON schema: a `title` and `description` for the Kamelet, the names of the +`required` parameters, and under `properties` each parameter with its `title`, `description`, `type`, and an +optional `default`. +`spec.dependencies`:: the Camel components and libraries the template uses, as `camel:<name>` or +`mvn:<groupId>:<artifactId>:<version>`. +`spec.template`:: the route itself, written as in the YAML DSL: a `from` with its `steps`, and optionally `beans`. + +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. + +== A source Kamelet + +This source produces a sample order at a fixed interval. The route that uses it chooses the customer, and may change +the interval, which has a default. + +.order-source.kamelet.yaml +[source,yaml] +---- +apiVersion: camel.apache.org/v1 +kind: Kamelet +metadata: + name: order-source + labels: + camel.apache.org/kamelet.type: source +spec: + definition: + title: Order Source + description: Produces a sample order at a fixed interval + required: + - customer + properties: + customer: + title: Customer + description: The customer name to put in the order + type: string + period: + title: Period + description: Milliseconds between orders + type: integer + default: 5000 + dependencies: + - "camel:timer" + template: + from: + uri: timer:orders + parameters: + period: "{{period}}" + steps: + - setBody: + 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. + +.archive-sink.kamelet.yaml +[source,yaml] +---- +apiVersion: camel.apache.org/v1 +kind: Kamelet +metadata: + name: archive-sink + labels: + camel.apache.org/kamelet.type: sink +spec: + definition: + title: Archive Sink + description: Writes each message to a file in a directory + required: + - directory + properties: + directory: + title: Directory + description: The directory to write the files to + type: string + fileExtension: + title: File Extension + description: The extension of the file names + type: string + default: json + dependencies: + - "camel:file" + template: + from: + uri: kamelet:source + steps: + - to: + uri: "file:{{directory}}" + parameters: + fileName: "${exchangeId}.{{fileExtension}}" +---- + +== Using the Kamelets in a route + +A route uses each Kamelet by name, passing the parameters as endpoint parameters. Parameters with a default can be +left out, or given to override the default: + +.orders.camel.yaml +[source,yaml] +---- +- route: + id: export-orders + from: + uri: kamelet:order-source + parameters: + customer: Donald + period: 2000 + steps: + - to: + uri: kamelet:content-filter-action + parameters: + allowlist: id,country + - log: + message: "Filtered: ${body}" + - to: + uri: kamelet:archive-sink + parameters: + directory: target/orders +---- + +Each file written then holds only the fields that are allowed, such as `{"id":"...","country":"DK"}`. + +== Where Camel finds a Kamelet + +When a route uses `kamelet:<name>`, Camel loads `<name>.kamelet.yaml` from the `location` of the Kamelet component, +which is `classpath:kamelets` by default. Set `camel.component.kamelet.location` to look elsewhere; several locations +can be given, separated by comma. + +With the Camel CLI, the Kamelet files can simply be run with the route: + +[source,bash] +---- +camel run orders.camel.yaml order-source.kamelet.yaml content-filter-action.kamelet.yaml archive-sink.kamelet.yaml +---- + +A Kamelet can also call another Kamelet in its template, as any route can. + +== Checking a Kamelet + +`camel validate yaml` checks a Kamelet file: the template against the YAML DSL, with the errors reported at their +line in the template. + +[source,bash] +---- +camel validate yaml content-filter-action.kamelet.yaml +---- diff --git a/docs/components/modules/others/nav.adoc b/docs/components/modules/others/nav.adoc index ebccf4f508eb..4a991e597a07 100644 --- a/docs/components/modules/others/nav.adoc +++ b/docs/components/modules/others/nav.adoc @@ -38,6 +38,7 @@ ** xref:jfr.adoc[JFR] ** xref:jsoup.adoc[Jsoup] ** xref:jta.adoc[JTA] +** xref:kamelet-custom.adoc[Kamelet - Writing a custom Kamelet] ** xref:keycloak-consumer.adoc[Keycloak Consumer Operations] ** xref:keycloak-producer.adoc[Keycloak Producer Operations] ** xref:keycloak-security.adoc[Keycloak Security Policies] diff --git a/docs/components/modules/others/pages/kamelet-custom.adoc b/docs/components/modules/others/pages/kamelet-custom.adoc new file mode 120000 index 000000000000..be528337c081 --- /dev/null +++ b/docs/components/modules/others/pages/kamelet-custom.adoc @@ -0,0 +1 @@ +../../../../../components/camel-kamelet/src/main/docs/kamelet-custom.adoc \ No newline at end of file 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 2d1ff2dd2d84..f2bc35379490 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 @@ -473,6 +473,17 @@ public final class AuthoringTools { return sb.toString(); } + /** Where the shape of a Kamelet file is explained, said where an agent gets one wrong (CAMEL-25283). */ + static final String KAMELET_GUIDE = "How to write a Kamelet (the file, and the source, sink and action kinds): " + + "camel_catalog_doc name=kamelet docPage=custom"; + + private static void putKameletGuide(JsonObject result, String file, List<String> errors) { + String name = file != null ? file.toLowerCase(Locale.ROOT) : ""; + if (!errors.isEmpty() && (name.endsWith(".kamelet.yaml") || name.endsWith(".kamelet.yml"))) { + result.put("guide", KAMELET_GUIDE); + } + } + private static String commaLines(String list) { return list == null ? null : list.replace(',', '\n'); } @@ -499,6 +510,7 @@ public final class AuthoringTools { result.put("valid", errors.isEmpty()); result.put("file", file); result.put("errors", new JsonArray(errors)); + putKameletGuide(result, file, errors); // the problems whose fix is certain, as edits an agent can apply (camel_edit_file find/replace) JsonArray fixes = new JsonArray(); String[] lines = content.split("\n", -1); @@ -919,6 +931,7 @@ public final class AuthoringTools { result.put("errors", new JsonArray(errors)); result.put("message", "The file was not written: the content has validation errors. Fix them and" + " call camel_write_file again."); + putKameletGuide(result, file, errors); return result; } } 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 8472684617c3..dd4b522109f0 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 @@ -19,10 +19,13 @@ package org.apache.camel.dsl.jbang.core.commands.ai; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.TreeMap; +import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.camel.catalog.CamelCatalog; @@ -197,9 +200,21 @@ public final class CatalogDocs { ComponentModel cm = catalog.componentModel(name); if (cm != null) { String adoc = catalog.asciiDoc(name + "-component"); + List<JsonObject> pages = componentDocPages(cm.getScheme(), adoc); + if (page != null && !page.isEmpty()) { + // a sub-page the component page links to, such as how to write a custom Kamelet + String sub = pages.stream().anyMatch(p -> page.equals(p.getString("page"))) + ? catalog.asciiDoc(cm.getScheme() + "-" + page) : null; + if (sub == null) { + JsonObject err = error("No doc page '" + page + "' for component " + name); + err.put("docPages", new JsonArray(pages)); + return err; + } + return componentDoc(cm, lowerFilter, OptionScope.NONE, false, sub, null, pages); + } // the whole page when it was asked for, else its first section, which the options cannot say return componentDoc(cm, lowerFilter, scope, includeHeaders, includeDoc ? adoc : null, - includeDoc ? null : docExcerpt(adoc, DOC_EXCERPT_BUDGET)); + includeDoc ? null : docExcerpt(adoc, DOC_EXCERPT_BUDGET), pages); } JsonObject group = mainOptionsGroup(catalog, name); if (group != null) { @@ -1032,7 +1047,7 @@ public final class CatalogDocs { private static JsonObject componentDoc( ComponentModel model, String filter, OptionScope scope, boolean includeHeaders, String doc, - String docExcerpt) { + String docExcerpt, List<JsonObject> docPages) { JsonObject result = new JsonObject(); result.put("kind", "component"); result.put("name", model.getScheme()); @@ -1114,9 +1129,38 @@ public final class CatalogDocs { result.put("documentation", docExcerpt); result.put("documentationHint", "the start of the component's documentation page; includeDoc=true for all of it"); } + 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"); + } return result; } + /** + * The sub-pages of a component's documentation, found from the links of its page to them + * ({@code xref:others:kamelet-custom.adoc[Writing a custom Kamelet]}): the page name to ask for with + * {@code docPage}, and its title. + */ + static List<JsonObject> componentDocPages(String scheme, String adoc) { + List<JsonObject> pages = new ArrayList<>(); + if (adoc == null || scheme == null) { + return pages; + } + Matcher m = Pattern.compile("xref:others:" + Pattern.quote(scheme) + "-([a-z0-9][a-z0-9-]*)\\.adoc\\[([^\\]]+)]") + .matcher(adoc); + Set<String> seen = new HashSet<>(); + while (m.find()) { + if (seen.add(m.group(1))) { + JsonObject jo = new JsonObject(); + jo.put("page", m.group(1)); + jo.put("title", m.group(2)); + pages.add(jo); + } + } + return pages; + } + private static JsonObject dataFormatDoc( DataFormatModel model, String filter, OptionScope scope, String doc, String docExcerpt) { JsonObject result = new JsonObject(); 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 42f9856ca1e5..da2cdc36882f 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 @@ -533,6 +533,10 @@ public final class CatalogSamples { answer.put("note", "none of the examples of the documentation uses a " + name + " endpoint; they show the " + "component in another way (a properties function, a policy, a converter)"); } + if ("component".equals(kind) && "kamelet".equals(name)) { + // the samples show how a route uses a Kamelet; writing the .kamelet.yaml file is on its own page + answer.put("guide", AuthoringTools.KAMELET_GUIDE); + } return answer; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocComponentPagesTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocComponentPagesTest.java new file mode 100644 index 000000000000..321036374ef9 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocComponentPagesTest.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.ai; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * CAMEL-25283: the sub-pages of a component's documentation can be asked for with {@code docPage}, and a model is told + * where the page on writing a custom Kamelet is: in the kamelet answer, in its samples, and when a Kamelet file it + * wrote is invalid. + */ +class CatalogDocComponentPagesTest { + + private static JsonObject catalogDoc(Map<String, String> args) throws Exception { + String json = String.valueOf(ToolRegistry.execute("camel_catalog_doc", new ToolContext(), new HashMap<>(args))); + return (JsonObject) Jsoner.deserialize(json); + } + + private static JsonObject page(JsonArray pages, String name) { + for (Object o : pages) { + JsonObject p = (JsonObject) o; + if (name.equals(p.getString("page"))) { + return p; + } + } + return null; + } + + @Test + public void testTheKameletAnswerNamesTheCustomKameletPage() throws Exception { + JsonObject answer = catalogDoc(Map.of("name", "kamelet")); + JsonArray pages = (JsonArray) answer.get("docPages"); + assertNotNull(pages, "no docPages in: " + answer.toJson()); + JsonObject custom = page(pages, "custom"); + assertNotNull(custom, "custom is not a doc page: " + pages.toJson()); + assertEquals("Writing a custom Kamelet", custom.getString("title")); + assertNotNull(answer.getString("docPagesHint")); + // the pages of another artifact that share the prefix are not sub-pages of the component + assertNull(page(pages, "main")); + } + + @Test + public void testTheCustomKameletPageIsReturnedAsText() throws Exception { + JsonObject answer = catalogDoc(Map.of("name", "kamelet", "docPage", "custom")); + String doc = answer.getString("doc"); + assertNotNull(doc, "no doc in: " + answer.toJson()); + for (String kind : List.of("source", "sink", "action")) { + assertTrue(doc.contains("camel.apache.org/kamelet.type: " + kind), "no " + kind + " Kamelet in the page"); + } + assertTrue(doc.contains("{{?"), "the page does not say how an optional parameter is written"); + // the page is the answer: no option list around it + assertNull(answer.get("options")); + assertNull(answer.get("documentation")); + } + + @Test + public void testAnUnknownPageListsThePagesThereAre() throws Exception { + JsonObject answer = catalogDoc(Map.of("name", "kamelet", "docPage", "nosuchpage")); + assertNotNull(answer.getString("error")); + assertNotNull(page((JsonArray) answer.get("docPages"), "custom")); + } + + @Test + public void testTheSubPagesOfOtherComponentsAreFoundToo() throws Exception { + JsonArray pages = (JsonArray) catalogDoc(Map.of("name", "aws2-s3")).get("docPages"); + assertNotNull(pages); + assertNotNull(page(pages, "streaming"), pages.toJson()); + assertNotNull(page(pages, "consumer-examples"), pages.toJson()); + String doc = catalogDoc(Map.of("name", "aws2-s3", "docPage", "streaming")).getString("doc"); + assertTrue(doc.startsWith("= AWS S3 - Streaming Upload"), doc.substring(0, Math.min(80, doc.length()))); + } + + @Test + public void testAComponentWithoutSubPagesHasNoDocPages() throws Exception { + assertNull(catalogDoc(Map.of("name", "timer")).get("docPages")); + } + + @Test + public void testAnInvalidKameletFileSaysWhereTheGuideIs() { + String kamelet = """ + apiVersion: camel.apache.org/v1 + kind: Kamelet + metadata: + name: my-action + labels: + camel.apache.org/kamelet.type: action + spec: + definition: + title: My Action + template: + from: + uri: kamelet:source + steps: + - jq: "." + """; + JsonObject invalid = AuthoringTools.validate(new ToolContext(), "my-action.kamelet.yaml", kamelet); + assertFalse(invalid.getBoolean("valid")); + assertEquals(AuthoringTools.KAMELET_GUIDE, invalid.getString("guide")); + + String fixed + = kamelet.replace("- jq: \".\"", "- setBody:\n jq:\n expression: \".\""); + JsonObject valid = AuthoringTools.validate(new ToolContext(), "my-action.kamelet.yaml", fixed); + assertTrue(valid.getBoolean("valid"), valid.toJson()); + assertNull(valid.get("guide")); + + // a route file is not a Kamelet + JsonObject route = AuthoringTools.validate(new ToolContext(), "my.camel.yaml", + "- from:\n uri: timer:x\n steps:\n - jq: \".\"\n"); + assertFalse(route.getBoolean("valid")); + assertNull(route.get("guide")); + } + + @Test + public void testTheKameletSamplesSayWhereTheGuideIs() { + JsonObject answer = CatalogSamples.sample(new DefaultCamelCatalog(), "component", "kamelet", 2); + assertEquals(AuthoringTools.KAMELET_GUIDE, answer.getString("guide"), answer.toJson()); + assertNull(CatalogSamples.sample(new DefaultCamelCatalog(), "component", "timer", 2).get("guide")); + } +}
