This is an automated email from the ASF dual-hosted git repository.
Croway 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 138d8c645eb8 CAMEL-25642: camel-jbang-mcp - camel_openapi_scaffold
follows the rules of camel-rest-openapi (#27960)
138d8c645eb8 is described below
commit 138d8c645eb8af8cca4eb45f19e36098747e7592
Author: Federico Mariani <[email protected]>
AuthorDate: Sat Oct 17 14:59:09 2026 +0200
CAMEL-25642: camel-jbang-mcp - camel_openapi_scaffold follows the rules of
camel-rest-openapi (#27960)
* CAMEL-25642: camel-jbang-mcp - camel_openapi_scaffold follows the rules
of camel-rest-openapi
- The id of an operation without operationId comes from the new
RestOpenApiHelper.generateOperationId, which
DefaultRestOpenapiProcessorStrategy
now uses too (GENOPID_GET.pets._petId_, not GENOPID_GET_pets_petId).
- With missingOperation=mock the scaffold explains the mode and lists one
camel-mock/ file per path, named after the request path (no method suffix,
path parameters matched by value), with what Camel answers without the
file.
camel_openapi_mock_guidance, whose walk duplicated the scaffold's, is
removed.
- camel_openapi_validate reports every security scheme as not enforced, and
OpenAPI 3.1 as information (swagger-parser parses it, the component serves
its paths like 3.0).
- Test binding the scaffolded petstore routes and mock file with the real
rest-openapi component.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-25642: the upgrade guide shows the path parameter literally, not as
an AsciiDoc attribute
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* CAMEL-25642: camel-jbang-mcp - a visible mock file name for the root
path, null-safe scheme text, and a unit test of the generated operation id
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* CAMEL-25642: camel-jbang-mcp - mock file names carry a sample path
parameter value, HTTP 204 for an operation without a '200' response, and media
type wording
- the mock file of a path with parameters is named with the sample value 1
(camel-mock/pets/1.json for /pets/{petId}), as camel-rest-openapi matches
the
request path; requestPath keeps the template
- an operation without a '200' response is listed as answering HTTP 204
with an
empty body (CAMEL-25663 fixed the error the text described)
- the note and the mock mode explanation say the '200' example is taken for
the
media type of the request's Content-Type or Accept header (Accept */*
takes
application/json, then application/xml), otherwise HTTP 204
- MockFileInfo javadoc, the camel-jbang-mcp doc page and the tests follow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-25642: camel-jbang-mcp - shorter per-path mock note, firstExample
javadoc, and the example of the media type that names the file
- The per-method note of a mock file points to modeExplanation instead of
repeating its media type sentence
- firstExample javadoc: the example is a preview of the file content; at
runtime camel-rest-openapi takes the
example of the media type matching the request's Content-Type or Accept
header
- The example comes from the media type that gives the file extension when
it has one, otherwise from the first
media type with an example, whose type then gives the extension, so
content and extension agree
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../DefaultRestOpenapiProcessorStrategy.java | 9 +-
.../component/rest/openapi/RestOpenApiHelper.java | 18 +
.../rest/openapi/RestOpenApiHelperTest.java | 18 +
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 12 +
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 28 +-
dsl/camel-jbang/camel-jbang-mcp/pom.xml | 11 +
.../dsl/jbang/core/commands/mcp/OpenApiTools.java | 465 ++++++++++-----------
.../mcp/OpenApiScaffoldRestOpenApiTest.java | 134 ++++++
.../jbang/core/commands/mcp/OpenApiToolsTest.java | 155 +++----
.../src/test/resources/petstore.yaml | 83 ++++
10 files changed, 599 insertions(+), 334 deletions(-)
diff --git
a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java
b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java
index bd27d14c96cd..2a3cda90b127 100644
---
a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java
+++
b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java
@@ -68,8 +68,6 @@ public class DefaultRestOpenapiProcessorStrategy extends
ServiceSupport
private static final String BODY_VERBS = "DELETE,PUT,POST,PATCH";
- private static final String GEN_OPID = "GENOPID_";
-
private CamelContext camelContext;
private ProducerCache producerCache;
private String component = "direct";
@@ -163,12 +161,11 @@ public class DefaultRestOpenapiProcessorStrategy extends
ServiceSupport
*
* @param path The path for this operation, such as /users.
* @param httpMethod The operation to perform
- * @return A generated operation id based on the path and the
operation. Slashes and braces in the path
- * are replaced with placeholder characters.
+ * @return A generated operation id based on the path and the
operation, see
+ * {@link RestOpenApiHelper#generateOperationId(String,
HttpMethod)}
*/
private String generateOperationId(String path, HttpMethod httpMethod) {
- final String sanitizedPath = path.replace('/', '.').replaceAll("[{}]",
"_");
- final String opId = GEN_OPID + httpMethod.name() + sanitizedPath;
+ final String opId = RestOpenApiHelper.generateOperationId(path,
httpMethod);
LOG.debug("Generated operationId {} for path {} and method {}", opId,
path, httpMethod.name());
return opId;
}
diff --git
a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiHelper.java
b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiHelper.java
index 0b692d1a36b7..b88e7e4042b6 100644
---
a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiHelper.java
+++
b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiHelper.java
@@ -26,6 +26,7 @@ import java.util.regex.Pattern;
import java.util.stream.Stream;
import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.PathItem.HttpMethod;
import io.swagger.v3.oas.models.servers.Server;
import org.apache.camel.CamelContext;
import org.apache.camel.spi.ContentTypeAware;
@@ -44,10 +45,27 @@ public final class RestOpenApiHelper {
private static final List<String> YAML_CONTENT_TYPES =
Arrays.asList("application/yaml", "application/yml",
"text/yaml", "text/yml", "text/x-yaml");
+ private static final String GENERATED_OPERATION_ID_PREFIX = "GENOPID_";
+
private RestOpenApiHelper() {
// utility class
}
+ /**
+ * The operation id that the default contract-first strategy ({@link
DefaultRestOpenapiProcessorStrategy}) gives an
+ * operation of the specification without an {@code operationId}: {@code
GENOPID_}, the HTTP method, then the path
+ * with {@code /} replaced by {@code .} and the braces of the path
parameters by {@code _}. For example {@code GET
+ * /pets/{petId}} becomes {@code GENOPID_GET.pets._petId_}, and the
operation is routed to
+ * {@code direct:GENOPID_GET.pets._petId_}.
+ *
+ * @param path the path of the operation in the specification, such
as {@code /pets/{petId}}
+ * @param httpMethod the HTTP method of the operation
+ * @return the generated operation id
+ */
+ public static String generateOperationId(String path, HttpMethod
httpMethod) {
+ return GENERATED_OPERATION_ID_PREFIX + httpMethod.name() +
path.replace('/', '.').replaceAll("[{}]", "_");
+ }
+
public static String isMediaRange(final String given, final String name) {
return notEmpty(given, name);
}
diff --git
a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiHelperTest.java
b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiHelperTest.java
index b653940c939a..fd04f57dc38a 100644
---
a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiHelperTest.java
+++
b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiHelperTest.java
@@ -19,6 +19,7 @@ package org.apache.camel.component.rest.openapi;
import java.io.InputStream;
import java.util.stream.Stream;
+import io.swagger.v3.oas.models.PathItem.HttpMethod;
import org.apache.camel.spi.ContentTypeAware;
import org.apache.camel.spi.Resource;
import org.apache.camel.support.ResourceSupport;
@@ -100,6 +101,23 @@ public class RestOpenApiHelperTest {
Arguments.of("classpath", null, false));
}
+ @ParameterizedTest
+ @MethodSource("generatedOperationIds")
+ void generateOperationIdFollowsTheContractFirstRule(String path,
HttpMethod method, String expected) {
+ assertThat(RestOpenApiHelper.generateOperationId(path,
method)).isEqualTo(expected);
+ }
+
+ static Stream<Arguments> generatedOperationIds() {
+ return Stream.of(
+ Arguments.of("/pets", HttpMethod.GET, "GENOPID_GET.pets"),
+ Arguments.of("/pets/{petId}", HttpMethod.GET,
"GENOPID_GET.pets._petId_"),
+ Arguments.of("/users/{userId}/orders/{orderId}",
HttpMethod.DELETE,
+ "GENOPID_DELETE.users._userId_.orders._orderId_"),
+ Arguments.of("/", HttpMethod.POST, "GENOPID_POST."),
+ Arguments.of("/pets/", HttpMethod.GET, "GENOPID_GET.pets."),
+ Arguments.of("/v1.0/pets-list", HttpMethod.PATCH,
"GENOPID_PATCH.v1.0.pets-list"));
+ }
+
static final class FakeContentTypeAwareResource extends ResourceSupport
implements ContentTypeAware {
private String contentType;
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 83252eab6082..e9117fe96d6d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -709,6 +709,18 @@ it audits the `camelVersion` argument, else lists every
advisory of the declared
`warnings`. Its `reachable` is null when no routes are given, advisories
without components are returned as
`uncheckedAdvisories`, and `clean` is true only when none of them apply.
+=== camel-jbang-mcp - camel_openapi_mock_guidance is removed
+
+The `camel_openapi_mock_guidance` MCP tool is removed. Call
`camel_openapi_scaffold` with
+`missingOperation=mock` instead: its answer explains the mode and lists the
`camel-mock/` files, one per path
+of the spec. The scaffold and `camel_openapi_validate` now follow the rules of
camel-rest-openapi: an operation
+without `operationId` is routed to `direct:GENOPID_<METHOD><path>` with `/`
replaced by `.` and the braces of
+path parameters by `_` (`direct:GENOPID_GET.pets._petId_` for `+GET
/pets/{petId}+`), and a mock file has the
+request path as its name, without the HTTP method (`camel-mock/pets/42.json`
for `GET /pets/42`). The
+security scheme diagnostics of `camel_openapi_validate` are now all warnings
with a `SECURITY_<type>` code
+(`SECURITY_APIKEY`, `SECURITY_HTTP`, ...), as camel-rest-openapi enforces none
of them, and `OPENAPI_31` is
+reported as information instead of a warning.
+
=== camel-jpa, camel-hazelcast - an exchange marked rollback only is not
consumed
When a route marks the exchange rollback only (for example with
`markRollbackOnly()` or `markRollbackOnlyLast()`)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 4be96356681e..765691486c2e 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -255,10 +255,9 @@ The assistant uses `camel_error_diagnose` to identify the
exception chain, extra
=== OpenAPI contract-first
* _"Validate this OpenAPI spec for Camel compatibility"_ — uses
`camel_openapi_validate`
-* _"Generate a Camel YAML scaffold with mock mode"_ — uses
`camel_openapi_scaffold`
-* _"Show me the mock directory structure"_ — uses `camel_openapi_mock_guidance`
+* _"Generate a Camel YAML scaffold with mock mode, and tell me where the mock
files go"_ — uses `camel_openapi_scaffold`
-Combine all three for a complete prototyping workflow: validate, scaffold,
then implement routes one at a time while Camel mocks the rest.
+Combine both for a complete prototyping workflow: validate, scaffold, then
implement routes one at a time while Camel mocks the rest.
=== Runtime introspection
@@ -711,27 +710,28 @@ The advisories are also browseable as MCP resources:
`camel://security/advisorie
Since Camel 4.6, the recommended approach for building REST APIs from OpenAPI
specifications is **contract-first**:
referencing the OpenAPI spec directly at runtime via `rest:openApi` rather
than generating REST DSL code. These tools
-help validate, scaffold, and provide mock guidance for that workflow.
+help validate and scaffold that workflow, following the rules of the
`rest-openapi` component.
[cols="1,3",options="header"]
|===
| Tool | Description
| `camel_openapi_validate`
-| Validates an OpenAPI specification for compatibility with Camel's
contract-first REST support. Checks for missing
- `operationId` fields, unsupported security schemes, OpenAPI 3.1 limitations,
webhooks usage, and empty paths.
+| Validates an OpenAPI specification for compatibility with Camel's
contract-first REST support. Reports operations
+ without `operationId` (with the `direct:GENOPID_...` id Camel routes them
to), security schemes (Camel enforces
+ none of them), webhooks (not served), OpenAPI 3.1 (parsed and served like
3.0) and empty paths.
Returns errors, warnings, and info-level diagnostics.
| `camel_openapi_scaffold`
| Generates a Camel YAML scaffold for contract-first OpenAPI integration.
Produces a `rest:openApi` configuration
- block referencing the spec file and a `direct:<operationId>` route stub for
each operation, with `Content-Type`
- and `CamelHttpResponseCode` headers pre-configured from the spec. Supports
configuring the `missingOperation`
- mode (`fail`, `ignore`, or `mock`).
-
-| `camel_openapi_mock_guidance`
-| Provides guidance on configuring Camel's `missingOperation` modes (`fail`,
`ignore`, `mock`). For `mock` mode,
- returns the `camel-mock/` directory structure, mock file paths derived from
the API paths, and example content
- from the spec. Explains the behavior of each mode.
+ block referencing the spec file and a `direct:<operationId>` route stub for
each operation (an operation without
+ `operationId` gets the id the `rest-openapi` component generates), with
`Content-Type` and `CamelHttpResponseCode`
+ headers pre-configured from the spec. Supports configuring the
`missingOperation` mode (`fail`, `ignore`, or
+ `mock`) and explains it. With `mock`, it also lists one `camel-mock/` file
per path of the spec, named after the
+ request path: a path parameter carries a sample value, such as
`camel-mock/pets/1.json` for `+/pets/{petId}+`,
+ which answers `/pets/1` (one file per value). It gives the example of the
spec and what Camel answers without the
+ file: the example of the `200` response for the media type of the request's
`Content-Type` or `Accept` header,
+ otherwise HTTP 204 with an empty body.
|===
=== Migration
diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml
b/dsl/camel-jbang/camel-jbang-mcp/pom.xml
index 107e15a92ed5..875b94462163 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml
@@ -112,6 +112,12 @@
<artifactId>camel-yaml-dsl</artifactId>
</dependency>
+ <!-- rest-openapi component: the OpenAPI tools follow its rules
(generated operation ids) -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-rest-openapi</artifactId>
+ </dependency>
+
<!-- Swagger/OpenAPI parser for contract-first OpenAPI tools -->
<dependency>
<groupId>io.swagger.core.v3</groupId>
@@ -143,6 +149,11 @@
</dependency>
<!-- test dependencies -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-direct</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-xml-io-dsl</artifactId>
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiTools.java
index 71505833a632..a6d5a2d5694f 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiTools.java
@@ -17,10 +17,12 @@
package org.apache.camel.dsl.jbang.core.commands.mcp;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
-import java.util.TreeSet;
import jakarta.enterprise.context.ApplicationScoped;
@@ -36,12 +38,16 @@ import io.swagger.v3.oas.models.responses.ApiResponses;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.parser.OpenAPIV3Parser;
import io.swagger.v3.parser.core.models.SwaggerParseResult;
+import org.apache.camel.component.rest.openapi.RestOpenApiHelper;
/**
* MCP Tools for contract-first OpenAPI support in Apache Camel.
*
* Since Camel 4.6, the recommended approach is contract-first: referencing
the OpenAPI spec directly at runtime via
- * rest:openApi. These tools help validate, scaffold, and provide mock
guidance for that workflow.
+ * rest:openApi. These tools help validate and scaffold that workflow,
following the rules of the camel-rest-openapi
+ * component (DefaultRestOpenapiProcessorStrategy): an operation without
operationId is routed to the id of
+ * {@link RestOpenApiHelper#generateOperationId(String, PathItem.HttpMethod)},
and in mock mode a mock file is served
+ * when its location without extension ends with the request path.
*/
@McpSecured
@ApplicationScoped
@@ -49,10 +55,23 @@ public class OpenApiTools {
private static final Set<String> VALID_MISSING_OPERATION_MODES =
Set.of("fail", "ignore", "mock");
+ /**
+ * The methods that, in mock mode and without a mock file, answer with the
request body.
+ */
+ private static final Set<String> BODY_VERBS = Set.of("DELETE", "PUT",
"POST", "PATCH");
+ // the value a path parameter takes in the name of a mock file
+ private static final String SAMPLE_PATH_PARAMETER_VALUE = "1";
+
+ private static final String NOT_ENFORCED
+ = " is defined, but camel-rest-openapi does not enforce security
schemes: requests are not authenticated"
+ + " against it. Secure the HTTP layer (for a Bearer token, the
rest-openapi oauthProfile option)"
+ + " or check the credential in the route.";
+
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
description = "Validate an OpenAPI specification for use with
Camel's contract-first REST support. "
- + "Checks for compatibility issues like missing
operationIds, unsupported security schemes, "
- + "and OpenAPI 3.1 features that Camel does not fully
support.")
+ + "Reports operations without operationId (with the id
Camel routes them to), "
+ + "security schemes (which Camel does not enforce),
webhooks (which Camel does not serve) "
+ + "and specs without paths.")
public ValidateResult camel_openapi_validate(
@ToolArg(description = "OpenAPI 3.x specification content (JSON or
YAML string)") String spec) {
@@ -62,12 +81,11 @@ public class OpenApiTools {
List<DiagnosticMessage> warnings = new ArrayList<>();
List<DiagnosticMessage> info = new ArrayList<>();
- // Check OpenAPI version for 3.1 limitations
if (openAPI.getOpenapi() != null &&
openAPI.getOpenapi().startsWith("3.1")) {
- warnings.add(new DiagnosticMessage(
+ info.add(new DiagnosticMessage(
"OPENAPI_31",
- "OpenAPI 3.1 detected. Camel supports 3.0.x fully; 3.1
features like "
- + "webhooks and advanced JSON Schema may not
be supported.",
+ "OpenAPI 3.1 detected. camel-rest-openapi parses 3.1
specifications and serves their paths "
+ + "like those of a 3.0 specification;
webhooks are not served.",
null));
}
@@ -92,14 +110,12 @@ public class OpenApiTools {
}
for (Map.Entry<PathItem.HttpMethod, Operation> opEntry :
pathItem.readOperationsMap().entrySet()) {
- Operation op = opEntry.getValue();
- String method = opEntry.getKey().name();
- if (op.getOperationId() == null ||
op.getOperationId().isBlank()) {
- String generated = "GENOPID_" + method +
path.replace("/", "_").replace("{", "").replace("}", "");
+ if (opEntry.getValue().getOperationId() == null) {
warnings.add(new DiagnosticMessage(
"MISSING_OPERATION_ID",
- "Operation " + method + " " + path + " has no
operationId. "
- + "Camel will
auto-generate: " + generated,
+ "Operation " + opEntry.getKey().name() + " " +
path + " has no operationId. "
+ + "Camel routes it to
direct:"
+ +
RestOpenApiHelper.generateOperationId(path, opEntry.getKey()),
path));
}
}
@@ -117,9 +133,7 @@ public class OpenApiTools {
// Check security schemes
if (openAPI.getComponents() != null &&
openAPI.getComponents().getSecuritySchemes() != null) {
for (Map.Entry<String, SecurityScheme> schemeEntry :
openAPI.getComponents().getSecuritySchemes().entrySet()) {
- String name = schemeEntry.getKey();
- SecurityScheme scheme = schemeEntry.getValue();
- checkSecurityScheme(name, scheme, warnings, info);
+ checkSecurityScheme(schemeEntry.getKey(),
schemeEntry.getValue(), warnings);
}
}
@@ -131,8 +145,11 @@ public class OpenApiTools {
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
description = "Generate Camel YAML scaffold for contract-first
OpenAPI integration. "
- + "Produces a rest:openApi configuration block and
route stubs for each operation "
- + "defined in the spec. This is the recommended
approach since Camel 4.6.")
+ + "Produces a rest:openApi configuration block and a
direct:<operationId> route stub for "
+ + "each operation defined in the spec (with the id
Camel generates when the spec has none), "
+ + "and explains the missingOperation mode. With
missingOperation=mock it also lists the "
+ + "camel-mock/ files Camel serves for the operations
without a route. "
+ + "This is the recommended approach since Camel 4.6.")
public ScaffoldResult camel_openapi_scaffold(
@ToolArg(description = "OpenAPI 3.x specification content (JSON or
YAML string)") String spec,
@ToolArg(description = "Filename of the OpenAPI spec file as it
will be referenced at runtime "
@@ -140,14 +157,14 @@ public class OpenApiTools {
required = false) String specFilename,
@ToolArg(description = "Behavior when a route is missing for an
operationId: "
+ "'fail' (default, throw error), 'ignore'
(skip silently), "
- + "or 'mock' (return mock responses)",
+ + "or 'mock' (return mock responses; the
answer then lists the mock files)",
required = false) String missingOperation) {
OpenAPI openAPI = parseSpec(spec);
String filename = (specFilename == null || specFilename.isBlank()) ?
"openapi.json" : specFilename.strip();
- String mode
- = (missingOperation == null || missingOperation.isBlank()) ?
"fail" : missingOperation.strip().toLowerCase();
+ String mode = (missingOperation == null || missingOperation.isBlank())
+ ? "fail" : missingOperation.strip().toLowerCase(Locale.ROOT);
if (!VALID_MISSING_OPERATION_MODES.contains(mode)) {
throw new ToolCallException(
@@ -199,127 +216,10 @@ public class OpenApiTools {
yaml.append(" expression: \"TODO: implement
").append(stub.operationId()).append("\"\n");
}
- return new ScaffoldResult(yaml.toString(), stubs.size(), filename,
mode, apiTitle);
- }
-
- @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
- description = "Get guidance on configuring Camel's contract-first
REST missingOperation modes "
- + "(fail, ignore, mock). For 'mock' mode, provides
directory structure, mock file paths, "
- + "and example content derived from the OpenAPI spec.")
- public MockGuidanceResult camel_openapi_mock_guidance(
- @ToolArg(description = "OpenAPI 3.x specification content (JSON or
YAML string)") String spec,
- @ToolArg(description = "The missingOperation mode to get guidance
for: "
- + "'mock' (default), 'fail', or 'ignore'",
- required = false) String mode) {
-
- OpenAPI openAPI = parseSpec(spec);
-
- String effectiveMode = (mode == null || mode.isBlank()) ? "mock" :
mode.strip().toLowerCase();
- if (!VALID_MISSING_OPERATION_MODES.contains(effectiveMode)) {
- throw new ToolCallException(
- "'mode' must be 'fail', 'ignore', or 'mock', got: " +
mode, null);
- }
-
- String modeExplanation = getModeExplanation(effectiveMode);
-
- // Configuration YAML snippet
- StringBuilder configYaml = new StringBuilder();
- configYaml.append("- rest:\n");
- configYaml.append(" openApi:\n");
- configYaml.append(" specification: openapi.json\n");
- configYaml.append(" missingOperation:
").append(effectiveMode).append("\n");
-
- List<MockFileInfo> mockFiles = new ArrayList<>();
- String directoryStructure = null;
-
- if ("mock".equals(effectiveMode)) {
- // Build mock file info from spec
- Set<String> directories = new TreeSet<>();
- directories.add("camel-mock/");
-
- if (openAPI.getPaths() != null) {
- for (Map.Entry<String, PathItem> pathEntry :
openAPI.getPaths().entrySet()) {
- String path = pathEntry.getKey();
- PathItem pathItem = pathEntry.getValue();
-
- if (pathItem.readOperationsMap() == null) {
- continue;
- }
-
- for (Map.Entry<PathItem.HttpMethod, Operation> opEntry :
pathItem.readOperationsMap().entrySet()) {
- String method = opEntry.getKey().name();
- Operation op = opEntry.getValue();
-
- // Determine response content type and example
- String contentType = null;
- String exampleContent = null;
- String responseCode = null;
-
- if (op.getResponses() != null) {
- Map.Entry<String, ApiResponse> successResponse =
findFirstSuccessResponse(op.getResponses());
- if (successResponse != null) {
- responseCode = successResponse.getKey();
- ApiResponse resp = successResponse.getValue();
- if (resp.getContent() != null &&
!resp.getContent().isEmpty()) {
- Map.Entry<String, MediaType> firstContent
- =
resp.getContent().entrySet().iterator().next();
- contentType = firstContent.getKey();
- MediaType mediaType =
firstContent.getValue();
- if (mediaType.getExample() != null) {
- exampleContent =
mediaType.getExample().toString();
- }
- }
- }
- }
-
- // Determine file extension from content type
- String ext = getFileExtension(contentType);
-
- // Build mock file path: camel-mock/<path>.<ext>
- String cleanPath = path.startsWith("/") ?
path.substring(1) : path;
- // Replace path parameters with placeholder directory
names
- cleanPath = cleanPath.replace("{", "_").replace("}",
"_");
- String filePath = "camel-mock/" + cleanPath + "." +
method.toLowerCase() + ext;
-
- // Track parent directories
- String[] parts = filePath.split("/");
- StringBuilder dirBuilder = new StringBuilder();
- for (int i = 0; i < parts.length - 1; i++) {
- dirBuilder.append(parts[i]).append("/");
- directories.add(dirBuilder.toString());
- }
-
- String operationId = op.getOperationId() != null ?
op.getOperationId() : method + " " + path;
+ List<MockFileInfo> mockFiles = "mock".equals(mode) ?
collectMockFiles(openAPI) : null;
- String note = null;
- if ("GET".equals(method) && exampleContent == null) {
- note = "GET without a mock file returns HTTP 204
(No Content)";
- } else if ("POST".equals(method) ||
"PUT".equals(method) || "DELETE".equals(method)) {
- if (exampleContent == null) {
- note = method + " without a mock file echoes
the request body back";
- }
- }
-
- mockFiles.add(new MockFileInfo(filePath, operationId,
contentType, exampleContent, note));
- }
- }
- }
-
- // Build directory structure string
- StringBuilder dirStructure = new StringBuilder();
- for (String dir : directories) {
- int depth = dir.split("/").length - 1;
- dirStructure.append("
".repeat(depth)).append(dir.substring(dir.lastIndexOf('/') == dir.length() - 1
- ? dir.substring(0, dir.length() - 1).lastIndexOf('/')
+ 1
- : dir.lastIndexOf('/') + 1));
- dirStructure.append("\n");
- }
- directoryStructure = dirStructure.toString().stripTrailing();
- }
-
- return new MockGuidanceResult(
- effectiveMode, modeExplanation, configYaml.toString(),
- directoryStructure, mockFiles.isEmpty() ? null : mockFiles);
+ return new ScaffoldResult(
+ yaml.toString(), stubs.size(), filename, mode, apiTitle,
getModeExplanation(mode), mockFiles);
}
// -- Shared helpers --
@@ -353,6 +253,13 @@ public class OpenApiTools {
return count;
}
+ /**
+ * The id camel-rest-openapi routes the operation to: its operationId, or
the generated one.
+ */
+ private static String operationId(String path, PathItem.HttpMethod method,
Operation op) {
+ return op.getOperationId() != null ? op.getOperationId() :
RestOpenApiHelper.generateOperationId(path, method);
+ }
+
private List<OperationStub> collectOperationStubs(OpenAPI openAPI) {
List<OperationStub> stubs = new ArrayList<>();
if (openAPI.getPaths() == null) {
@@ -368,14 +275,9 @@ public class OpenApiTools {
}
for (Map.Entry<PathItem.HttpMethod, Operation> opEntry :
pathItem.readOperationsMap().entrySet()) {
- String method = opEntry.getKey().name().toLowerCase();
+ String method =
opEntry.getKey().name().toLowerCase(Locale.ROOT);
Operation op = opEntry.getValue();
-
- String operationId = op.getOperationId();
- if (operationId == null || operationId.isBlank()) {
- operationId = "GENOPID_" + method.toUpperCase()
- + path.replace("/", "_").replace("{",
"").replace("}", "");
- }
+ String operationId = operationId(path, opEntry.getKey(), op);
String responseCode = null;
String contentType = null;
@@ -407,6 +309,134 @@ public class OpenApiTools {
return stubs;
}
+ /**
+ * One mock file per path of the spec. For an operation without a route,
camel-rest-openapi serves the first
+ * resource of mockIncludePattern (default classpath:camel-mock/**) whose
location without extension ends with the
+ * request path, whatever the method: a path parameter is matched by its
value, so {@code /pets/{petId}} takes one
+ * file per value, and the listed file carries the sample value {@code 1},
such as {@code camel-mock/pets/1.json}.
+ */
+ private List<MockFileInfo> collectMockFiles(OpenAPI openAPI) {
+ List<MockFileInfo> files = new ArrayList<>();
+ if (openAPI.getPaths() == null) {
+ return files;
+ }
+ for (Map.Entry<String, PathItem> pathEntry :
openAPI.getPaths().entrySet()) {
+ String path = pathEntry.getKey();
+ Map<PathItem.HttpMethod, Operation> operations =
pathEntry.getValue().readOperationsMap();
+ if (operations == null || operations.isEmpty()) {
+ continue;
+ }
+
+ List<String> operationIds = new ArrayList<>();
+ Map<String, String> withoutFile = new LinkedHashMap<>();
+ String responseType = null;
+ String example = null;
+ String exampleType = null;
+ for (Map.Entry<PathItem.HttpMethod, Operation> opEntry :
operations.entrySet()) {
+ String method = opEntry.getKey().name();
+ Operation op = opEntry.getValue();
+ operationIds.add(operationId(path, opEntry.getKey(), op));
+
+ if (op.getResponses() != null) {
+ Map.Entry<String, ApiResponse> success =
findFirstSuccessResponse(op.getResponses());
+ if (responseType == null && success != null &&
success.getValue().getContent() != null
+ && !success.getValue().getContent().isEmpty()) {
+ responseType =
success.getValue().getContent().keySet().iterator().next();
+ }
+ }
+ if (BODY_VERBS.contains(method)) {
+ withoutFile.put(method, "echoes the request body with HTTP
200");
+ continue;
+ }
+ ApiResponse ok = op.getResponses() != null ?
op.getResponses().get("200") : null;
+ Map.Entry<String, String> opExample = firstExample(ok,
responseType);
+ if (opExample != null) {
+ withoutFile.put(method, "answers the example of its '200'
response with HTTP 200 for a matching"
+ + " Content-Type or Accept (see
modeExplanation), otherwise HTTP 204");
+ if (example == null) {
+ exampleType = opExample.getKey();
+ example = opExample.getValue();
+ }
+ } else {
+ // no example in the '200' response, or no '200' response
at all (CAMEL-25663): an empty answer
+ withoutFile.put(method, "answers HTTP 204 with an empty
body");
+ }
+ }
+
+ // the file content is the example, so the extension follows the
media type the example comes from
+ String fileType = exampleType != null ? exampleType : responseType;
+ String ext = fileType != null && fileType.contains("xml") ? ".xml"
: ".json";
+ StringBuilder note = new StringBuilder("Served with HTTP 200 for
any method of ").append(path)
+ .append(" without a route.");
+ // the component matches the request path, not the template: a
path parameter takes a sample value
+ String sample = path.replaceAll("\\{[^}]+}",
SAMPLE_PATH_PARAMETER_VALUE);
+ if (path.contains("{")) {
+ note.append(" Camel matches the request path, so the file name
carries the value the request uses:"
+ + " camel-mock")
+ .append(sample).append(ext).append(" answers
").append(sample)
+ .append(", add one file per value.");
+ }
+ note.append(" Without the file, ");
+ withoutFile.forEach((method, answer) ->
note.append(method).append(' ').append(answer).append("; "));
+ note.setLength(note.length() - 2);
+ note.append('.');
+
+ // the component matches a resource whose location without
extension ends with the request path without its
+ // leading slash; for the root path that is the empty string, so
any mock file matches
+ String fileName = "/".equals(path) ? "camel-mock/index" + ext :
"camel-mock" + sample + ext;
+ if ("/".equals(path)) {
+ note.append(" Camel matches the root path to any mock file
(the first one found), so with more than"
+ + " one file the one that answers / is not
predictable.");
+ }
+ files.add(new MockFileInfo(fileName, path, operationIds, example,
note.toString()));
+ }
+ return files;
+ }
+
+ /**
+ * An example of the "200" response, used as a preview of the mock file
content, with the media type it comes from:
+ * the example of the preferred media type when it has one, otherwise the
first media type with an example. At
+ * runtime camel-rest-openapi takes the example of the media type matching
the request's Content-Type or Accept
+ * header.
+ */
+ private static Map.Entry<String, String> firstExample(ApiResponse ok,
String preferredType) {
+ if (ok == null || ok.getContent() == null) {
+ return null;
+ }
+ if (preferredType != null) {
+ String preferred = exampleOf(ok.getContent().get(preferredType));
+ if (preferred != null) {
+ return Map.entry(preferredType, preferred);
+ }
+ }
+ for (Map.Entry<String, MediaType> mt : ok.getContent().entrySet()) {
+ String example = exampleOf(mt.getValue());
+ if (example != null) {
+ return Map.entry(mt.getKey(), example);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The example, or the first of the examples, of a media type.
+ */
+ private static String exampleOf(MediaType mt) {
+ if (mt == null) {
+ return null;
+ }
+ if (mt.getExample() != null) {
+ return mt.getExample().toString();
+ }
+ if (mt.getExamples() != null && !mt.getExamples().isEmpty()) {
+ Object value =
mt.getExamples().values().iterator().next().getValue();
+ if (value != null) {
+ return value.toString();
+ }
+ }
+ return null;
+ }
+
private Map.Entry<String, ApiResponse>
findFirstSuccessResponse(ApiResponses responses) {
// Try 2xx codes in order
for (Map.Entry<String, ApiResponse> entry : responses.entrySet()) {
@@ -422,99 +452,46 @@ public class OpenApiTools {
return null;
}
- private void checkSecurityScheme(
- String name, SecurityScheme scheme,
- List<DiagnosticMessage> warnings, List<DiagnosticMessage> info) {
-
+ private void checkSecurityScheme(String name, SecurityScheme scheme,
List<DiagnosticMessage> warnings) {
if (scheme.getType() == null) {
return;
}
-
- switch (scheme.getType()) {
- case APIKEY:
- if (scheme.getIn() == SecurityScheme.In.QUERY) {
- info.add(new DiagnosticMessage(
- "SECURITY_APIKEY_QUERY",
- "Security scheme '" + name + "' (apiKey in query)
is supported by Camel.",
- null));
- } else {
- warnings.add(new DiagnosticMessage(
- "SECURITY_APIKEY_NOT_QUERY",
- "Security scheme '" + name + "' (apiKey in " +
scheme.getIn()
- + ") is defined but
not enforced by Camel's REST OpenAPI integration. "
- + "You must handle
authentication in your route logic.",
- null));
- }
- break;
- case HTTP:
- warnings.add(new DiagnosticMessage(
- "SECURITY_HTTP",
- "Security scheme '" + name + "' (HTTP " +
scheme.getScheme()
- + ") is defined but not enforced by
Camel's REST OpenAPI integration. "
- + "You must handle authentication in
your route logic.",
- null));
- break;
- case OAUTH2:
- warnings.add(new DiagnosticMessage(
- "SECURITY_OAUTH2",
- "Security scheme '" + name
- + "' (OAuth2) is defined but not
enforced by Camel's REST OpenAPI integration. "
- + "You must handle authentication
in your route logic.",
- null));
- break;
- case OPENIDCONNECT:
- warnings.add(new DiagnosticMessage(
- "SECURITY_OPENIDCONNECT",
- "Security scheme '" + name
- + "' (OpenID Connect) is
defined but not enforced by Camel's REST OpenAPI integration. "
- + "You must handle
authentication in your route logic.",
- null));
- break;
- case MUTUALTLS:
- warnings.add(new DiagnosticMessage(
- "SECURITY_MUTUALTLS",
- "Security scheme '" + name
- + "' (Mutual TLS) is defined but
not enforced by Camel's REST OpenAPI integration. "
- + "You must handle
authentication in your route logic.",
- null));
- break;
- default:
- break;
- }
+ String kind = switch (scheme.getType()) {
+ case APIKEY -> "apiKey in " +
Objects.requireNonNullElse(scheme.getIn(), "<unset>");
+ case HTTP -> "HTTP " +
Objects.requireNonNullElse(scheme.getScheme(), "<unset>");
+ case OAUTH2 -> "OAuth2";
+ case OPENIDCONNECT -> "OpenID Connect";
+ case MUTUALTLS -> "Mutual TLS";
+ };
+ warnings.add(new DiagnosticMessage(
+ "SECURITY_" + scheme.getType().name(),
+ "Security scheme '" + name + "' (" + kind + ")" + NOT_ENFORCED,
+ null));
}
private String getModeExplanation(String mode) {
return switch (mode) {
- case "fail" -> "In 'fail' mode (the default), Camel throws an
exception at startup if any operationId "
- + "in the OpenAPI spec does not have a
corresponding direct:<operationId> route. "
- + "This ensures all API operations are explicitly
implemented.";
- case "ignore" -> "In 'ignore' mode, Camel silently skips
operations that do not have a corresponding "
- + "direct:<operationId> route. Requests to those
endpoints return HTTP 404. "
- + "Useful during incremental development.";
- case "mock" -> "In 'mock' mode, Camel auto-generates mock
responses for operations without a "
- + "direct:<operationId> route. For GET requests, it
looks for mock data files under "
- + "camel-mock/ directory. For POST/PUT/DELETE, it
echoes the request body. "
- + "GET without a mock file returns HTTP 204. Useful
for prototyping and testing.";
+ case "fail" -> "In 'fail' mode, Camel fails at startup, listing
the unmapped operations, if an operation "
+ + "of the OpenAPI spec has no direct:<operationId>
route. 'fail' is the default, except "
+ + "under the 'dev' profile (the default of camel
run), where the default is 'mock'.";
+ case "ignore" -> "In 'ignore' mode, Camel logs the operations
without a direct:<operationId> route at "
+ + "startup and starts anyway. A request to such
an operation is not routed: Camel "
+ + "answers with the request message as is. Useful
during incremental development.";
+ case "mock" -> "In 'mock' mode, an operation without a
direct:<operationId> route is answered with "
+ + "HTTP 200 and the content of the first file of
mockIncludePattern (default "
+ + "classpath:camel-mock/**: camel-mock/ next to the
routes with camel run, "
+ + "src/main/resources/camel-mock/ in a Maven
project) whose location without extension "
+ + "ends with the request path, whatever the method.
When the request has a JSON or XML "
+ + "Content-Type, only a .json or .xml file matches.
Without a file, DELETE, PUT, POST "
+ + "and PATCH echo the request body with HTTP 200;
the other methods answer the example of "
+ + "the '200' response of the spec for the media
type matching the request's Content-Type "
+ + "or Accept header (Accept */* takes
application/json, then application/xml), and "
+ + "otherwise (such as a request without Accept
header, or no '200' example) HTTP 204 with "
+ + "an empty body.";
default -> "";
};
}
- private String getFileExtension(String contentType) {
- if (contentType == null) {
- return ".json";
- }
- if (contentType.contains("json")) {
- return ".json";
- }
- if (contentType.contains("xml")) {
- return ".xml";
- }
- if (contentType.contains("text")) {
- return ".txt";
- }
- return ".json";
- }
-
// -- Result records --
public record DiagnosticMessage(String code, String message, String path) {
@@ -543,22 +520,26 @@ public class OpenApiTools {
int operationCount,
String specFilename,
String missingOperation,
- String apiTitle) {
+ String apiTitle,
+ String modeExplanation,
+ List<MockFileInfo> mockFiles) {
}
+ /**
+ * A mock file for one path of the spec.
+ *
+ * @param filePath the file to add, named after a request path: each
path parameter carries the sample value
+ * {@code 1}, such as {@code camel-mock/pets/1.json}
for {@code /pets/{petId}}
+ * @param requestPath the path of the spec, as a template, such as
{@code /pets/{petId}}
+ * @param operationIds the operations of the path
+ * @param exampleContent an example of the spec for the file content, or
null
+ * @param note what Camel answers with and without the file
+ */
public record MockFileInfo(
String filePath,
- String operation,
- String contentType,
+ String requestPath,
+ List<String> operationIds,
String exampleContent,
String note) {
}
-
- public record MockGuidanceResult(
- String mode,
- String modeExplanation,
- String configurationYaml,
- String directoryStructure,
- List<MockFileInfo> mockFiles) {
- }
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiScaffoldRestOpenApiTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiScaffoldRestOpenApiTest.java
new file mode 100644
index 000000000000..5359fee48490
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiScaffoldRestOpenApiTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.mcp;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.component.platform.http.PlatformHttpConstants;
+import org.apache.camel.component.platform.http.PlatformHttpEndpoint;
+import org.apache.camel.component.platform.http.spi.PlatformHttpConsumer;
+import org.apache.camel.component.platform.http.spi.PlatformHttpEngine;
+import org.apache.camel.component.rest.openapi.RestOpenApiComponent;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResourceHelper;
+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;
+
+/**
+ * Binds what camel_openapi_scaffold generates for the petstore spec with the
real camel-rest-openapi component. The
+ * HTTP server is replaced by an engine that hands the requests to the
rest-openapi consumer in-process.
+ */
+class OpenApiScaffoldRestOpenApiTest {
+
+ private static String petstore;
+
+ private final OpenApiTools tools = new OpenApiTools();
+ private final AtomicReference<Processor> consumerProcessor = new
AtomicReference<>();
+
+ @TempDir
+ Path dir;
+
+ @BeforeAll
+ static void loadSpec() throws Exception {
+ try (InputStream is =
OpenApiScaffoldRestOpenApiTest.class.getResourceAsStream("/petstore.yaml")) {
+ petstore = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ @Test
+ void scaffoldedRouteOfAnOperationWithoutOperationIdIsCalled() throws
Exception {
+ OpenApiTools.ScaffoldResult scaffold =
tools.camel_openapi_scaffold(petstore, "petstore.yaml", null);
+
+ // in the default 'fail' mode the context only starts when every
operation of the spec has its route
+ try (CamelContext context = start(scaffold.yaml(), null)) {
+ Exchange answer = call(context, "/v1/pets/42");
+
+ assertThat(answer.getException()).isNull();
+
assertThat(answer.getMessage().getBody(String.class)).isEqualTo("TODO:
implement GENOPID_GET.pets._petId_");
+ }
+ }
+
+ @Test
+ void scaffoldedMockFileIsServed() throws Exception {
+ OpenApiTools.ScaffoldResult scaffold =
tools.camel_openapi_scaffold(petstore, "petstore.yaml", "mock");
+
+ assertThat(scaffold.mockFiles())
+ .extracting(OpenApiTools.MockFileInfo::filePath)
+ .containsExactly("camel-mock/pets.json",
"camel-mock/pets/1.json");
+ Path file = dir.resolve(scaffold.mockFiles().get(1).filePath());
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, "{\"id\": 1, \"name\": \"Tom\"}");
+
+ // keep only the rest block, so that no operation has a route
+ String restOnly = scaffold.yaml().substring(0,
scaffold.yaml().indexOf("- route:"));
+ try (CamelContext context = start(restOnly, "file:" +
dir.toAbsolutePath() + "/camel-mock/**")) {
+ Exchange answer = call(context, "/v1/pets/1");
+
+ assertThat(answer.getException()).isNull();
+
assertThat(answer.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(200);
+
assertThat(answer.getMessage().getBody(String.class)).isEqualToIgnoringNewLines("{\"id\":
1, \"name\": \"Tom\"}");
+ }
+ }
+
+ private CamelContext start(String yaml, String mockIncludePattern) throws
Exception {
+ CamelContext context = new DefaultCamelContext();
+ PlatformHttpEngine engine = (endpoint, processor) -> {
+ consumerProcessor.set(processor);
+ return new InVmConsumer(endpoint, processor);
+ };
+
context.getRegistry().bind(PlatformHttpConstants.PLATFORM_HTTP_ENGINE_NAME,
engine);
+ if (mockIncludePattern != null) {
+ context.getComponent("rest-openapi",
RestOpenApiComponent.class).setMockIncludePattern(mockIncludePattern);
+ }
+
PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("scaffold.camel.yaml",
yaml));
+ context.start();
+ return context;
+ }
+
+ private Exchange call(CamelContext context, String path) throws Exception {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "GET");
+ exchange.getMessage().setHeader(Exchange.HTTP_PATH, path);
+ consumerProcessor.get().process(exchange);
+ return exchange;
+ }
+
+ private static final class InVmConsumer extends DefaultConsumer implements
PlatformHttpConsumer {
+
+ InVmConsumer(PlatformHttpEndpoint endpoint, Processor processor) {
+ super(endpoint, processor);
+ }
+
+ @Override
+ public PlatformHttpEndpoint getEndpoint() {
+ return (PlatformHttpEndpoint) super.getEndpoint();
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiToolsTest.java
index 8dfc29830956..faf26194810f 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiToolsTest.java
@@ -221,7 +221,7 @@ class OpenApiToolsTest {
assertThat(result.valid()).isTrue();
assertThat(result.warnings()).anySatisfy(w -> {
assertThat(w.code()).isEqualTo("MISSING_OPERATION_ID");
- assertThat(w.message()).contains("GENOPID_");
+ assertThat(w.message()).contains("direct:GENOPID_GET.items");
});
}
@@ -229,14 +229,12 @@ class OpenApiToolsTest {
void validateSecuritySchemeWarnings() {
OpenApiTools.ValidateResult result =
tools.camel_openapi_validate(SPEC_WITH_SECURITY);
- // apiKey in query should be info
- assertThat(result.info()).anySatisfy(i ->
assertThat(i.code()).isEqualTo("SECURITY_APIKEY_QUERY"));
- // apiKey in header should be warning
- assertThat(result.warnings()).anySatisfy(w ->
assertThat(w.code()).isEqualTo("SECURITY_APIKEY_NOT_QUERY"));
- // HTTP bearer should be warning
- assertThat(result.warnings()).anySatisfy(w ->
assertThat(w.code()).isEqualTo("SECURITY_HTTP"));
- // OAuth2 should be warning
- assertThat(result.warnings()).anySatisfy(w ->
assertThat(w.code()).isEqualTo("SECURITY_OAUTH2"));
+ // camel-rest-openapi enforces none of the schemes, an apiKey in query
included
+ assertThat(result.info()).isEmpty();
+ assertThat(result.warnings())
+ .extracting(OpenApiTools.DiagnosticMessage::code)
+ .containsExactlyInAnyOrder("SECURITY_APIKEY",
"SECURITY_APIKEY", "SECURITY_HTTP", "SECURITY_OAUTH2");
+ assertThat(result.warnings()).allSatisfy(w ->
assertThat(w.message()).contains("does not enforce"));
}
@Test
@@ -252,7 +250,8 @@ class OpenApiToolsTest {
OpenApiTools.ValidateResult result =
tools.camel_openapi_validate(SPEC_31_WITH_WEBHOOKS);
assertThat(result.warnings()).anySatisfy(w ->
assertThat(w.code()).isEqualTo("WEBHOOKS_PRESENT"));
- assertThat(result.warnings()).anySatisfy(w ->
assertThat(w.code()).isEqualTo("OPENAPI_31"));
+ // 3.1 is parsed and served like 3.0: information, not a warning
+ assertThat(result.info()).anySatisfy(i ->
assertThat(i.code()).isEqualTo("OPENAPI_31"));
}
// ---- Scaffold tests ----
@@ -351,84 +350,96 @@ class OpenApiToolsTest {
.hasMessageContaining("missingOperation");
}
- // ---- Mock guidance tests ----
-
- @Test
- void mockGuidanceDefaultModeIsMock() {
- OpenApiTools.MockGuidanceResult result =
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, null);
-
- assertThat(result.mode()).isEqualTo("mock");
- assertThat(result.modeExplanation()).contains("mock");
- }
-
@Test
- void mockGuidanceGeneratesMockFilePaths() {
- OpenApiTools.MockGuidanceResult result =
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "mock");
-
- assertThat(result.mockFiles()).isNotNull();
- assertThat(result.mockFiles()).anySatisfy(f ->
assertThat(f.filePath()).contains("camel-mock/"));
- }
-
- @Test
- void mockGuidanceFailModeExplanation() {
- OpenApiTools.MockGuidanceResult result =
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "fail");
-
- assertThat(result.mode()).isEqualTo("fail");
- assertThat(result.modeExplanation()).contains("fail");
- assertThat(result.modeExplanation()).contains("exception");
- // No mock files for fail mode
- assertThat(result.mockFiles()).isNull();
- assertThat(result.directoryStructure()).isNull();
- }
-
- @Test
- void mockGuidanceIgnoreModeExplanation() {
- OpenApiTools.MockGuidanceResult result =
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "ignore");
+ void scaffoldMockModeListsOneMockFilePerPath() {
+ OpenApiTools.ScaffoldResult result =
tools.camel_openapi_scaffold(MINIMAL_SPEC, null, "mock");
- assertThat(result.mode()).isEqualTo("ignore");
- assertThat(result.modeExplanation()).contains("ignore");
- assertThat(result.modeExplanation()).contains("404");
+ assertThat(result.modeExplanation()).contains("ends with the request
path");
+ assertThat(result.mockFiles()).singleElement().satisfies(f -> {
+ assertThat(f.filePath()).isEqualTo("camel-mock/pets.json");
+ assertThat(f.operationIds()).containsExactly("listPets",
"createPet");
+ assertThat(f.exampleContent()).contains("dog");
+ assertThat(f.note()).contains("GET answers the example of its
'200' response with HTTP 200 for a matching"
+ + " Content-Type or Accept (see
modeExplanation), otherwise HTTP 204",
+ "POST echoes the request body");
+ });
}
@Test
- void mockGuidanceConfigYamlCorrect() {
- OpenApiTools.MockGuidanceResult result =
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "mock");
-
- assertThat(result.configurationYaml()).contains("missingOperation:
mock");
- assertThat(result.configurationYaml()).contains("specification:
openapi.json");
- }
+ void scaffoldNamesTheMockFileAfterTheMediaTypeOfItsExample() {
+ String spec = """
+ {
+ "openapi": "3.0.0",
+ "info": { "title": "Pets", "version": "1.0" },
+ "paths": {
+ "/pets": {
+ "get": {
+ "operationId": "listPets",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": { "schema": { "type":
"array" } },
+ "application/xml": { "example":
"<pets><pet>dog</pet></pets>" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """;
+ OpenApiTools.ScaffoldResult result =
tools.camel_openapi_scaffold(spec, null, "mock");
- @Test
- void mockGuidanceNullSpecThrows() {
- assertThatThrownBy(() -> tools.camel_openapi_mock_guidance(null,
"mock"))
- .isInstanceOf(ToolCallException.class)
- .hasMessageContaining("spec");
+ assertThat(result.mockFiles()).singleElement().satisfies(f -> {
+ assertThat(f.filePath()).isEqualTo("camel-mock/pets.xml");
+
assertThat(f.exampleContent()).isEqualTo("<pets><pet>dog</pet></pets>");
+ });
}
@Test
- void mockGuidanceExampleContentPopulated() {
- OpenApiTools.MockGuidanceResult result =
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "mock");
-
- // The MINIMAL_SPEC has example data on the GET /pets response
- assertThat(result.mockFiles()).isNotNull();
- assertThat(result.mockFiles()).anySatisfy(f -> {
- assertThat(f.operation()).isEqualTo("listPets");
- assertThat(f.exampleContent()).isNotNull();
+ void scaffoldNamesTheRootPathMockFileVisibly() {
+ OpenApiTools.ScaffoldResult result
+ =
tools.camel_openapi_scaffold(MINIMAL_SPEC.replace("\"/pets\":", "\"/\":"),
null, "mock");
+
+ assertThat(result.mockFiles()).singleElement().satisfies(f -> {
+ assertThat(f.filePath()).isEqualTo("camel-mock/index.json");
+ assertThat(f.requestPath()).isEqualTo("/");
+ assertThat(f.note()).contains("any mock file");
});
}
@Test
- void mockGuidanceInvalidModeThrows() {
- assertThatThrownBy(() ->
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "invalid"))
- .isInstanceOf(ToolCallException.class)
- .hasMessageContaining("mode");
+ void scaffoldNamesThePathParameterMockFileWithASampleValue() {
+ String spec = """
+ {
+ "openapi": "3.0.0",
+ "info": { "title": "Pets", "version": "1.0" },
+ "paths": {
+ "/pets/{petId}": {
+ "get": {
+ "operationId": "showPetById",
+ "responses": { "201": { "description": "Created" } }
+ }
+ }
+ }
+ }
+ """;
+ OpenApiTools.ScaffoldResult result =
tools.camel_openapi_scaffold(spec, null, "mock");
+
+ assertThat(result.mockFiles()).singleElement().satisfies(f -> {
+ assertThat(f.filePath()).isEqualTo("camel-mock/pets/1.json");
+ assertThat(f.requestPath()).isEqualTo("/pets/{petId}");
+ assertThat(f.note()).contains("camel-mock/pets/1.json answers
/pets/1",
+ "GET answers HTTP 204 with an empty body");
+ });
}
@Test
- void mockGuidanceDirectoryStructurePresent() {
- OpenApiTools.MockGuidanceResult result =
tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "mock");
+ void scaffoldListsNoMockFileOutsideMockMode() {
+ OpenApiTools.ScaffoldResult result =
tools.camel_openapi_scaffold(MINIMAL_SPEC, null, "ignore");
- assertThat(result.directoryStructure()).isNotNull();
- assertThat(result.directoryStructure()).contains("camel-mock");
+ assertThat(result.modeExplanation()).contains("ignore");
+ assertThat(result.mockFiles()).isNull();
}
}
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/resources/petstore.yaml
b/dsl/camel-jbang/camel-jbang-mcp/src/test/resources/petstore.yaml
new file mode 100644
index 000000000000..fe2b9ad4e8e9
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/resources/petstore.yaml
@@ -0,0 +1,83 @@
+#
+# 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.
+#
+
+# The OpenAPI Initiative petstore example, with no operationId on GET
/pets/{petId}
+openapi: 3.0.3
+info:
+ title: Swagger Petstore
+ version: 1.0.0
+servers:
+ - url: http://petstore.swagger.io/v1
+paths:
+ /pets:
+ get:
+ operationId: listPets
+ summary: List all pets
+ responses:
+ '200':
+ description: A paged array of pets
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pets'
+ post:
+ operationId: createPets
+ summary: Create a pet
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ responses:
+ '201':
+ description: Null response
+ /pets/{petId}:
+ get:
+ summary: Info for a specific pet
+ parameters:
+ - name: petId
+ in: path
+ required: true
+ description: The id of the pet to retrieve
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Expected response to a valid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+components:
+ schemas:
+ Pet:
+ type: object
+ required:
+ - id
+ - name
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ tag:
+ type: string
+ Pets:
+ type: array
+ items:
+ $ref: '#/components/schemas/Pet'