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 32438585bf9cb..0777b7c6373ec 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -24,7 +24,7 @@ By default, the HTTP server is disabled. To enable it, set `quarkus.http.host-en == Available Tools -The server exposes 16 tools organized into six functional areas. +The server exposes 19 tools organized into seven functional areas. === Catalog Exploration @@ -118,6 +118,33 @@ The server exposes 16 tools organized into six functional areas. | Assists with route DSL format transformation between YAML and XML. |=== +=== OpenAPI Contract-First + +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. + +[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. + 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:` 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. +|=== + === Version Management [cols="1,3",options="header"] @@ -325,3 +352,54 @@ What are the latest LTS versions of Camel for Spring Boot? The assistant calls `camel_version_list` with `runtime=spring-boot` and `lts=true` and returns version information including release dates, end-of-life dates, and JDK requirements. + +=== Validating an OpenAPI Spec for Camel + +---- +I have this OpenAPI spec for my Pet Store API. Can you validate it for use with Camel's contract-first REST support? +---- + +Paste the OpenAPI spec (JSON or YAML) and the assistant calls `camel_openapi_validate`. It reports any compatibility +issues such as missing `operationId` fields, unsupported security schemes (OAuth2, mutual TLS), OpenAPI 3.1 +limitations, and webhooks usage. A valid spec with no issues returns `valid: true` with an operation count. + +=== Scaffolding a Contract-First REST API + +---- +Generate a Camel YAML scaffold for this OpenAPI spec. The spec file will be called petstore.yaml +and I want missing operations to use mock mode. +---- + +The assistant calls `camel_openapi_scaffold` with `specFilename=petstore.yaml` and `missingOperation=mock`. +It returns a ready-to-use YAML file containing: + +* A `rest:openApi` configuration block referencing the spec file with `missingOperation: mock` +* A `direct:` route stub for each operation with `Content-Type` and response code headers + +=== Getting Mock Guidance + +---- +I want to use Camel's mock mode for my OpenAPI REST API during development. Show me the directory +structure and mock files I need to create. +---- + +The assistant calls `camel_openapi_mock_guidance` with `mode=mock`. It returns: + +* An explanation of how mock mode works +* The YAML configuration snippet with `missingOperation: mock` +* The `camel-mock/` directory structure with mock file paths derived from the API paths +* Example content for mock files based on examples defined in the spec + +=== Combined Contract-First Workflow + +For a complete prototyping workflow, you can combine all three tools: + +---- +I'm building a new REST API with Camel using contract-first. Here's my OpenAPI spec. +Please validate it for compatibility issues, then generate the Camel YAML scaffold +with mock mode so I can prototype quickly. +---- + +The assistant first calls `camel_openapi_validate` to check for issues, then calls `camel_openapi_scaffold` +to generate the route scaffold. This gives you a validated spec and a complete starting point where you can +implement routes one at a time while Camel auto-mocks the rest. diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml b/dsl/camel-jbang/camel-jbang-mcp/pom.xml index b3e4035b73d5f..f5f8f0d427f5e 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/pom.xml +++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml @@ -106,6 +106,29 @@ camel-yaml-dsl + + + io.swagger.core.v3 + swagger-core-jakarta + ${swagger-openapi3-version} + + + io.swagger.core.v3 + swagger-models-jakarta + ${swagger-openapi3-version} + + + io.swagger.parser.v3 + swagger-parser-v3 + ${swagger-openapi3-java-parser-version} + + + io.swagger.core.v3 + * + + + + org.apache.commons 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 new file mode 100644 index 0000000000000..bb1f9b09bc092 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiTools.java @@ -0,0 +1,551 @@ +/* + * 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.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkiverse.mcp.server.Tool; +import io.quarkiverse.mcp.server.ToolArg; +import io.quarkiverse.mcp.server.ToolCallException; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +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; + +/** + * 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. + */ +@ApplicationScoped +public class OpenApiTools { + + private static final Set VALID_MISSING_OPERATION_MODES = Set.of("fail", "ignore", "mock"); + + @Tool(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.") + public ValidateResult camel_openapi_validate( + @ToolArg(description = "OpenAPI 3.x specification content (JSON or YAML string)") String spec) { + + OpenAPI openAPI = parseSpec(spec); + + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + List info = new ArrayList<>(); + + // Check OpenAPI version for 3.1 limitations + if (openAPI.getOpenapi() != null && openAPI.getOpenapi().startsWith("3.1")) { + warnings.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.", + null)); + } + + // Check for paths + if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) { + errors.add(new DiagnosticMessage( + "NO_PATHS", + "No paths defined in the specification. Camel REST requires at least one path with operations.", + null)); + } else { + // Check each operation + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + String path = pathEntry.getKey(); + PathItem pathItem = pathEntry.getValue(); + + if (pathItem.readOperationsMap() == null || pathItem.readOperationsMap().isEmpty()) { + warnings.add(new DiagnosticMessage( + "EMPTY_PATH_ITEM", + "Path '" + path + "' has no operations defined.", + path)); + continue; + } + + for (Map.Entry 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("}", ""); + warnings.add(new DiagnosticMessage( + "MISSING_OPERATION_ID", + "Operation " + method + " " + path + " has no operationId. " + + "Camel will auto-generate: " + generated, + path)); + } + } + } + } + + // Check webhooks + if (openAPI.getWebhooks() != null && !openAPI.getWebhooks().isEmpty()) { + warnings.add(new DiagnosticMessage( + "WEBHOOKS_PRESENT", + "Webhooks are defined in the spec but are not supported by Camel's REST OpenAPI integration.", + null)); + } + + // Check security schemes + if (openAPI.getComponents() != null && openAPI.getComponents().getSecuritySchemes() != null) { + for (Map.Entry schemeEntry : openAPI.getComponents().getSecuritySchemes().entrySet()) { + String name = schemeEntry.getKey(); + SecurityScheme scheme = schemeEntry.getValue(); + checkSecurityScheme(name, scheme, warnings, info); + } + } + + int operationCount = countOperations(openAPI); + boolean valid = errors.isEmpty(); + + return new ValidateResult(valid, errors, warnings, info, operationCount); + } + + @Tool(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.") + 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 " + + "(default: 'openapi.json')") 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)") 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(); + + if (!VALID_MISSING_OPERATION_MODES.contains(mode)) { + throw new ToolCallException( + "'missingOperation' must be 'fail', 'ignore', or 'mock', got: " + missingOperation, null); + } + + String apiTitle = openAPI.getInfo() != null ? openAPI.getInfo().getTitle() : null; + List stubs = collectOperationStubs(openAPI); + + StringBuilder yaml = new StringBuilder(); + + // rest:openApi block + yaml.append("- rest:\n"); + yaml.append(" openApi:\n"); + yaml.append(" specification: ").append(filename).append("\n"); + if (!"fail".equals(mode)) { + yaml.append(" missingOperation: ").append(mode).append("\n"); + } + + // Route stubs + for (OperationStub stub : stubs) { + yaml.append("- route:\n"); + yaml.append(" id: ").append(stub.operationId()).append("\n"); + yaml.append(" from:\n"); + yaml.append(" uri: direct:").append(stub.operationId()).append("\n"); + yaml.append(" steps:\n"); + + // Set Content-Type header if we know it + if (stub.contentType() != null) { + yaml.append(" - setHeader:\n"); + yaml.append(" name: Content-Type\n"); + yaml.append(" constant: ").append(stub.contentType()).append("\n"); + } + + // Set response code if we know it + if (stub.responseCode() != null) { + yaml.append(" - setHeader:\n"); + yaml.append(" name: CamelHttpResponseCode\n"); + yaml.append(" constant: ").append(stub.responseCode()).append("\n"); + } + + yaml.append(" - setBody:\n"); + yaml.append(" constant: \"TODO: implement ").append(stub.operationId()).append("\"\n"); + } + + return new ScaffoldResult(yaml.toString(), stubs.size(), filename, mode, apiTitle); + } + + @Tool(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'") 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 mockFiles = new ArrayList<>(); + String directoryStructure = null; + + if ("mock".equals(effectiveMode)) { + // Build mock file info from spec + Set directories = new TreeSet<>(); + directories.add("camel-mock/"); + + if (openAPI.getPaths() != null) { + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + String path = pathEntry.getKey(); + PathItem pathItem = pathEntry.getValue(); + + if (pathItem.readOperationsMap() == null) { + continue; + } + + for (Map.Entry 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 successResponse = findFirstSuccessResponse(op.getResponses()); + if (successResponse != null) { + responseCode = successResponse.getKey(); + ApiResponse resp = successResponse.getValue(); + if (resp.getContent() != null && !resp.getContent().isEmpty()) { + Map.Entry 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/. + 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; + + 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); + } + + // -- Shared helpers -- + + OpenAPI parseSpec(String spec) { + if (spec == null || spec.isBlank()) { + throw new ToolCallException("'spec' parameter is required and must not be blank", null); + } + + SwaggerParseResult parseResult = new OpenAPIV3Parser().readContents(spec); + OpenAPI openAPI = parseResult.getOpenAPI(); + if (openAPI == null) { + String errors = parseResult.getMessages() != null + ? String.join("; ", parseResult.getMessages()) + : "Unknown parse error"; + throw new ToolCallException("Failed to parse OpenAPI spec: " + errors, null); + } + return openAPI; + } + + private int countOperations(OpenAPI openAPI) { + if (openAPI.getPaths() == null) { + return 0; + } + int count = 0; + for (PathItem item : openAPI.getPaths().values()) { + if (item.readOperationsMap() != null) { + count += item.readOperationsMap().size(); + } + } + return count; + } + + private List collectOperationStubs(OpenAPI openAPI) { + List stubs = new ArrayList<>(); + if (openAPI.getPaths() == null) { + return stubs; + } + + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + String path = pathEntry.getKey(); + PathItem pathItem = pathEntry.getValue(); + + if (pathItem.readOperationsMap() == null) { + continue; + } + + for (Map.Entry opEntry : pathItem.readOperationsMap().entrySet()) { + String method = opEntry.getKey().name().toLowerCase(); + Operation op = opEntry.getValue(); + + String operationId = op.getOperationId(); + if (operationId == null || operationId.isBlank()) { + operationId = "GENOPID_" + method.toUpperCase() + + path.replace("/", "_").replace("{", "").replace("}", ""); + } + + String responseCode = null; + String contentType = null; + String consumesType = null; + + // Find response info + if (op.getResponses() != null) { + Map.Entry successResponse = findFirstSuccessResponse(op.getResponses()); + if (successResponse != null) { + responseCode = successResponse.getKey(); + ApiResponse resp = successResponse.getValue(); + if (resp.getContent() != null && !resp.getContent().isEmpty()) { + contentType = resp.getContent().keySet().iterator().next(); + } + } + } + + // Find request body content type + if (op.getRequestBody() != null && op.getRequestBody().getContent() != null + && !op.getRequestBody().getContent().isEmpty()) { + consumesType = op.getRequestBody().getContent().keySet().iterator().next(); + } + + String summary = op.getSummary(); + + stubs.add(new OperationStub(operationId, method, path, responseCode, contentType, consumesType, summary)); + } + } + return stubs; + } + + private Map.Entry findFirstSuccessResponse(ApiResponses responses) { + // Try 2xx codes in order + for (Map.Entry entry : responses.entrySet()) { + String code = entry.getKey(); + if (code.startsWith("2")) { + return entry; + } + } + // Fall back to "default" + if (responses.getDefault() != null) { + return Map.entry("200", responses.getDefault()); + } + return null; + } + + private void checkSecurityScheme( + String name, SecurityScheme scheme, + List warnings, List info) { + + 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; + } + } + + 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: 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: 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: 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."; + 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) { + } + + public record ValidateResult( + boolean valid, + List errors, + List warnings, + List info, + int operationCount) { + } + + public record OperationStub( + String operationId, + String method, + String path, + String responseCode, + String contentType, + String consumesType, + String summary) { + } + + public record ScaffoldResult( + String yaml, + int operationCount, + String specFilename, + String missingOperation, + String apiTitle) { + } + + public record MockFileInfo( + String filePath, + String operation, + String contentType, + String exampleContent, + String note) { + } + + public record MockGuidanceResult( + String mode, + String modeExplanation, + String configurationYaml, + String directoryStructure, + List mockFiles) { + } +} 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 new file mode 100644 index 0000000000000..7fcb93850c678 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/OpenApiToolsTest.java @@ -0,0 +1,403 @@ +/* + * 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 io.quarkiverse.mcp.server.ToolCallException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OpenApiToolsTest { + + private final OpenApiTools tools = new OpenApiTools(); + + private static final String MINIMAL_SPEC = """ + { + "openapi": "3.0.3", + "info": { + "title": "Pet Store", + "version": "1.0.0" + }, + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "responses": { + "200": { + "description": "A list of pets", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "type": "string" } }, + "example": ["dog", "cat"] + } + } + } + } + }, + "post": { + "operationId": "createPet", + "summary": "Create a pet", + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + }, + "responses": { + "201": { "description": "Pet created" } + } + } + } + } + } + """; + + private static final String SPEC_NO_OPERATION_IDS = """ + { + "openapi": "3.0.3", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/items": { + "get": { + "responses": { "200": { "description": "OK" } } + } + } + } + } + """; + + private static final String SPEC_WITH_SECURITY = """ + { + "openapi": "3.0.3", + "info": { "title": "Secure API", "version": "1.0.0" }, + "paths": { + "/data": { + "get": { + "operationId": "getData", + "responses": { "200": { "description": "OK" } } + } + } + }, + "components": { + "securitySchemes": { + "apiKeyQuery": { + "type": "apiKey", + "in": "query", + "name": "api_key" + }, + "apiKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer" + }, + "oauth": { + "type": "oauth2", + "flows": {} + } + } + } + } + """; + + private static final String SPEC_NO_PATHS = """ + { + "openapi": "3.0.3", + "info": { "title": "Empty", "version": "1.0.0" }, + "paths": {} + } + """; + + private static final String SPEC_31_WITH_WEBHOOKS = """ + { + "openapi": "3.1.0", + "info": { "title": "Webhook API", "version": "1.0.0" }, + "paths": { + "/hook": { + "post": { + "operationId": "receiveHook", + "responses": { "200": { "description": "OK" } } + } + } + }, + "webhooks": { + "petEvent": { + "post": { + "operationId": "petWebhook", + "responses": { "200": { "description": "OK" } } + } + } + } + } + """; + + private static final String SPEC_MULTI_RESPONSE = """ + { + "openapi": "3.0.3", + "info": { "title": "Multi", "version": "1.0.0" }, + "paths": { + "/items": { + "get": { + "operationId": "getItems", + "responses": { + "200": { + "description": "Items list", + "content": { + "application/xml": { + "schema": { "type": "string" } + } + } + } + } + }, + "delete": { + "operationId": "deleteItem", + "responses": { + "204": { "description": "Deleted" } + } + } + } + } + } + """; + + // ---- Validate tests ---- + + @Test + void validateValidSpec() { + OpenApiTools.ValidateResult result = tools.camel_openapi_validate(MINIMAL_SPEC); + + assertThat(result.valid()).isTrue(); + assertThat(result.errors()).isEmpty(); + assertThat(result.operationCount()).isEqualTo(2); + } + + @Test + void validateNullSpecThrows() { + assertThatThrownBy(() -> tools.camel_openapi_validate(null)) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("spec"); + } + + @Test + void validateBlankSpecThrows() { + assertThatThrownBy(() -> tools.camel_openapi_validate(" ")) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("spec"); + } + + @Test + void validateInvalidSpecThrows() { + assertThatThrownBy(() -> tools.camel_openapi_validate("{ invalid json !!!")) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("Failed to parse OpenAPI spec"); + } + + @Test + void validateMissingOperationIdWarns() { + OpenApiTools.ValidateResult result = tools.camel_openapi_validate(SPEC_NO_OPERATION_IDS); + + assertThat(result.valid()).isTrue(); + assertThat(result.warnings()).anySatisfy(w -> { + assertThat(w.code()).isEqualTo("MISSING_OPERATION_ID"); + assertThat(w.message()).contains("GENOPID_"); + }); + } + + @Test + 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")); + } + + @Test + void validateNoPathsError() { + OpenApiTools.ValidateResult result = tools.camel_openapi_validate(SPEC_NO_PATHS); + + assertThat(result.valid()).isFalse(); + assertThat(result.errors()).anySatisfy(e -> assertThat(e.code()).isEqualTo("NO_PATHS")); + } + + @Test + void validateWebhooksWarning() { + 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")); + } + + // ---- Scaffold tests ---- + + @Test + void scaffoldGeneratesCorrectYaml() { + OpenApiTools.ScaffoldResult result = tools.camel_openapi_scaffold(MINIMAL_SPEC, null, null); + + assertThat(result.yaml()).contains("rest:"); + assertThat(result.yaml()).contains("openApi:"); + assertThat(result.yaml()).contains("specification: openapi.json"); + assertThat(result.operationCount()).isEqualTo(2); + assertThat(result.apiTitle()).isEqualTo("Pet Store"); + } + + @Test + void scaffoldNullSpecThrows() { + assertThatThrownBy(() -> tools.camel_openapi_scaffold(null, null, null)) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("spec"); + } + + @Test + void scaffoldContainsDirectRoutes() { + OpenApiTools.ScaffoldResult result = tools.camel_openapi_scaffold(MINIMAL_SPEC, null, null); + + assertThat(result.yaml()).contains("direct:listPets"); + assertThat(result.yaml()).contains("direct:createPet"); + } + + @Test + void scaffoldResponseCodesFromSpec() { + OpenApiTools.ScaffoldResult result = tools.camel_openapi_scaffold(MINIMAL_SPEC, null, null); + + assertThat(result.yaml()).contains("constant: 200"); + assertThat(result.yaml()).contains("constant: 201"); + } + + @Test + void scaffoldContentTypeHeaders() { + OpenApiTools.ScaffoldResult result = tools.camel_openapi_scaffold(MINIMAL_SPEC, null, null); + + assertThat(result.yaml()).contains("constant: application/json"); + } + + @Test + void scaffoldCustomFilename() { + OpenApiTools.ScaffoldResult result = tools.camel_openapi_scaffold(MINIMAL_SPEC, "petstore.yaml", null); + + assertThat(result.yaml()).contains("specification: petstore.yaml"); + assertThat(result.specFilename()).isEqualTo("petstore.yaml"); + } + + @Test + void scaffoldMissingOperationModeApplied() { + OpenApiTools.ScaffoldResult result = tools.camel_openapi_scaffold(MINIMAL_SPEC, null, "mock"); + + assertThat(result.yaml()).contains("missingOperation: mock"); + assertThat(result.missingOperation()).isEqualTo("mock"); + } + + @Test + void scaffoldInvalidModeThrows() { + assertThatThrownBy(() -> tools.camel_openapi_scaffold(MINIMAL_SPEC, null, "invalid")) + .isInstanceOf(ToolCallException.class) + .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"); + + assertThat(result.mode()).isEqualTo("ignore"); + assertThat(result.modeExplanation()).contains("ignore"); + assertThat(result.modeExplanation()).contains("404"); + } + + @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"); + } + + @Test + void mockGuidanceNullSpecThrows() { + assertThatThrownBy(() -> tools.camel_openapi_mock_guidance(null, "mock")) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("spec"); + } + + @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(); + }); + } + + @Test + void mockGuidanceInvalidModeThrows() { + assertThatThrownBy(() -> tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "invalid")) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("mode"); + } + + @Test + void mockGuidanceDirectoryStructurePresent() { + OpenApiTools.MockGuidanceResult result = tools.camel_openapi_mock_guidance(MINIMAL_SPEC, "mock"); + + assertThat(result.directoryStructure()).isNotNull(); + assertThat(result.directoryStructure()).contains("camel-mock"); + } +}