This is an automated email from the ASF dual-hosted git repository. acosentino pushed a commit to branch 23056 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 259ae4c55d0c6b5f7335d6a550055b7a0e20e0cc Author: Andrea Cosentino <[email protected]> AuthorDate: Mon Feb 23 14:46:31 2026 +0100 CAMEL-23056 - Camel JBang MCP - Add contract-first OpenAPI REST DSL support tools Signed-off-by: Andrea Cosentino <[email protected]> --- dsl/camel-jbang/camel-jbang-mcp/pom.xml | 23 + .../dsl/jbang/core/commands/mcp/OpenApiTools.java | 551 +++++++++++++++++++++ .../jbang/core/commands/mcp/OpenApiToolsTest.java | 403 +++++++++++++++ 3 files changed, 977 insertions(+) diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml b/dsl/camel-jbang/camel-jbang-mcp/pom.xml index b3e4035b73d5..f5f8f0d427f5 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/pom.xml +++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml @@ -106,6 +106,29 @@ <artifactId>camel-yaml-dsl</artifactId> </dependency> + <!-- Swagger/OpenAPI parser for contract-first OpenAPI tools --> + <dependency> + <groupId>io.swagger.core.v3</groupId> + <artifactId>swagger-core-jakarta</artifactId> + <version>${swagger-openapi3-version}</version> + </dependency> + <dependency> + <groupId>io.swagger.core.v3</groupId> + <artifactId>swagger-models-jakarta</artifactId> + <version>${swagger-openapi3-version}</version> + </dependency> + <dependency> + <groupId>io.swagger.parser.v3</groupId> + <artifactId>swagger-parser-v3</artifactId> + <version>${swagger-openapi3-java-parser-version}</version> + <exclusions> + <exclusion> + <groupId>io.swagger.core.v3</groupId> + <artifactId>*</artifactId> + </exclusion> + </exclusions> + </dependency> + <!-- Apache Commons Text for fuzzy string matching (Levenshtein distance) --> <dependency> <groupId>org.apache.commons</groupId> 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 000000000000..bb1f9b09bc09 --- /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<String> 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<DiagnosticMessage> errors = new ArrayList<>(); + 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( + "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<String, PathItem> 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<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("}", ""); + 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<String, SecurityScheme> 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<OperationStub> 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<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; + + 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<OperationStub> collectOperationStubs(OpenAPI openAPI) { + List<OperationStub> stubs = new ArrayList<>(); + if (openAPI.getPaths() == null) { + return stubs; + } + + 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().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<String, ApiResponse> 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<String, ApiResponse> findFirstSuccessResponse(ApiResponses responses) { + // Try 2xx codes in order + for (Map.Entry<String, ApiResponse> 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<DiagnosticMessage> warnings, List<DiagnosticMessage> 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:<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."; + 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<DiagnosticMessage> errors, + List<DiagnosticMessage> warnings, + List<DiagnosticMessage> 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<MockFileInfo> 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 000000000000..7fcb93850c67 --- /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"); + } +}
