You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/07/18 18:58:40 UTC

(camel) branch main updated: CAMEL-24125: Expose dev console API contract via OpenAPI spec

This is an automated email from the ASF dual-hosted git repository.

davsclaus 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 0742bc45a399 CAMEL-24125: Expose dev console API contract via OpenAPI spec
0742bc45a399 is described below

commit 0742bc45a3994f4930bf04f9ce84116e25c2b97a
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sat Jul 18 20:58:34 2026 +0200

    CAMEL-24125: Expose dev console API contract via OpenAPI spec
    
    
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
 .../apache/camel/catalog/dev-consoles.properties   |   1 +
 .../org/apache/camel/catalog/dev-consoles/api.json |  15 ++
 components/camel-platform-http-main/pom.xml        |   5 +
 .../platform/http/main/DevConsoleOpenApiTest.java  | 146 +++++++++++++++++
 .../camel/impl/engine/AbstractCamelContext.java    |   2 +-
 .../impl/console/ApiDevConsoleConfigurer.java      |  63 +++++++
 .../META-INF/org/apache/camel/dev-console/api.json |  15 ++
 .../org.apache.camel.impl.console.ApiDevConsole    |   2 +
 .../services/org/apache/camel/dev-console/api      |   2 +
 .../org/apache/camel/dev-consoles.properties       |   2 +-
 .../apache/camel/impl/console/ApiDevConsole.java   | 181 +++++++++++++++++++++
 11 files changed, 432 insertions(+), 2 deletions(-)

diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
index d90d98ef8fc2..f91f5034b386 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
@@ -1,4 +1,5 @@
 activity
+api
 aws-secrets
 aws2-s3
 azure-secrets
diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/api.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/api.json
new file mode 100644
index 000000000000..adaae420e0f1
--- /dev/null
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/api.json
@@ -0,0 +1,15 @@
+{
+  "console": {
+    "kind": "console",
+    "group": "camel",
+    "name": "api",
+    "title": "API",
+    "description": "OpenAPI specification for the dev console API",
+    "deprecated": false,
+    "javaType": "org.apache.camel.impl.console.ApiDevConsole",
+    "groupId": "org.apache.camel",
+    "artifactId": "camel-console",
+    "version": "4.22.0-SNAPSHOT"
+  }
+}
+
diff --git a/components/camel-platform-http-main/pom.xml b/components/camel-platform-http-main/pom.xml
index b4fce995cfa8..f18b9a1476b0 100644
--- a/components/camel-platform-http-main/pom.xml
+++ b/components/camel-platform-http-main/pom.xml
@@ -79,6 +79,11 @@
             <artifactId>camel-health</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-console</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>io.rest-assured</groupId>
             <artifactId>rest-assured</artifactId>
diff --git a/components/camel-platform-http-main/src/test/java/org/apache/camel/component/platform/http/main/DevConsoleOpenApiTest.java b/components/camel-platform-http-main/src/test/java/org/apache/camel/component/platform/http/main/DevConsoleOpenApiTest.java
new file mode 100644
index 000000000000..5df5c77eaced
--- /dev/null
+++ b/components/camel-platform-http-main/src/test/java/org/apache/camel/component/platform/http/main/DevConsoleOpenApiTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.component.platform.http.main;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.console.DevConsoleRegistry;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.impl.console.ApiDevConsole;
+import org.apache.camel.impl.console.ContextDevConsole;
+import org.apache.camel.impl.console.DefaultDevConsoleRegistry;
+import org.apache.camel.impl.console.RouteDevConsole;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.util.json.DeserializationException;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DevConsoleOpenApiTest {
+
+    @RegisterExtension
+    AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+    private CamelContext camelContext;
+    private ManagementHttpServer server;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        camelContext = new DefaultCamelContext();
+        camelContext.setDevConsole(true);
+
+        DefaultDevConsoleRegistry registry = new DefaultDevConsoleRegistry(camelContext);
+        registry.register(new ApiDevConsole());
+        registry.register(new ContextDevConsole());
+        registry.register(new RouteDevConsole());
+        camelContext.getCamelContextExtension().addContextPlugin(DevConsoleRegistry.class, registry);
+
+        camelContext.start();
+
+        server = new ManagementHttpServer();
+        server.setCamelContext(camelContext);
+        server.setHost("0.0.0.0");
+        server.setPort(port.getPort());
+        server.setPath("/");
+        server.setDevConsoleEnabled(true);
+        server.start();
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        if (server != null) {
+            server.stop();
+        }
+        if (camelContext != null) {
+            camelContext.stop();
+        }
+    }
+
+    @Test
+    void testDevConsoleOpenApiEndpoint() throws IOException, InterruptedException, DeserializationException {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create("http://localhost:" + port.getPort() + "/q/dev/api"))
+                .header("Accept", "application/json")
+                .build();
+
+        HttpResponse<String> response = HttpClient.newBuilder().build().send(request, HttpResponse.BodyHandlers.ofString());
+
+        assertEquals(200, response.statusCode());
+        assertTrue(response.headers().firstValue("content-type").orElse("").contains("application/json"));
+
+        // the dev console handler wraps output under the console id
+        Object parsed = Jsoner.deserialize(response.body());
+        assertTrue(parsed instanceof JsonObject);
+        JsonObject wrapper = (JsonObject) parsed;
+        JsonObject root = wrapper.getJsonObject("api");
+        assertNotNull(root, "Response should contain 'api' key with OpenAPI spec");
+
+        assertEquals("3.0.3", root.getString("openapi"));
+
+        JsonObject info = root.getJsonObject("info");
+        assertNotNull(info);
+        assertEquals("Camel Dev Console API", info.getString("title"));
+        assertNotNull(info.getString("version"));
+
+        JsonObject paths = root.getJsonObject("paths");
+        assertNotNull(paths);
+
+        // should have api, context and route consoles
+        assertNotNull(paths.get("/q/dev/api"), "Should have api console path");
+        assertNotNull(paths.get("/q/dev/context"), "Should have context console path");
+        assertNotNull(paths.get("/q/dev/route"), "Should have route console path");
+
+        // verify route console has request body with parameters
+        JsonObject routePath = ((JsonObject) paths.get("/q/dev/route"));
+        JsonObject post = routePath.getJsonObject("post");
+        assertNotNull(post);
+        assertEquals("route", post.getString("operationId"));
+
+        JsonObject requestBody = post.getJsonObject("requestBody");
+        assertNotNull(requestBody, "Route console should have request body with parameters");
+
+        JsonObject content = requestBody.getJsonObject("content");
+        JsonObject appJson = content.getJsonObject("application/json");
+        JsonObject schema = appJson.getJsonObject("schema");
+        assertEquals("object", schema.getString("type"));
+
+        JsonObject properties = schema.getJsonObject("properties");
+        assertNotNull(properties);
+        assertNotNull(properties.get("filter"), "Should have filter parameter");
+        assertNotNull(properties.get("limit"), "Should have limit parameter");
+
+        // verify filter property has correct type
+        JsonObject filterProp = (JsonObject) properties.get("filter");
+        assertEquals("string", filterProp.getString("type"));
+
+        // verify limit property has correct type
+        JsonObject limitProp = (JsonObject) properties.get("limit");
+        assertEquals("integer", limitProp.getString("type"));
+    }
+}
diff --git a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
index f9b61322b617..0875b7af7d7c 100644
--- a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
+++ b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
@@ -1803,7 +1803,7 @@ public abstract class AbstractCamelContext extends BaseService
     @Override
     public String getDevConsoleParameterJsonSchema(String devConsoleName) throws IOException {
         String name = sanitizeFileName(devConsoleName) + ".json";
-        String path = DefaultDevConsoleResolver.DEV_CONSOLE_RESOURCE_PATH + name;
+        String path = "META-INF/org/apache/camel/dev-console/" + name;
         String inputStream = doLoadResource(devConsoleName, path, "console");
         if (inputStream != null) {
             return inputStream;
diff --git a/core/camel-console/src/generated/java/org/apache/camel/impl/console/ApiDevConsoleConfigurer.java b/core/camel-console/src/generated/java/org/apache/camel/impl/console/ApiDevConsoleConfigurer.java
new file mode 100644
index 000000000000..c46d0220b2b6
--- /dev/null
+++ b/core/camel-console/src/generated/java/org/apache/camel/impl/console/ApiDevConsoleConfigurer.java
@@ -0,0 +1,63 @@
+/* Generated by camel build tools - do NOT edit this file! */
+package org.apache.camel.impl.console;
+
+import javax.annotation.processing.Generated;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.spi.ExtendedPropertyConfigurerGetter;
+import org.apache.camel.spi.PropertyConfigurerGetter;
+import org.apache.camel.spi.ConfigurerStrategy;
+import org.apache.camel.spi.GeneratedPropertyConfigurer;
+import org.apache.camel.util.CaseInsensitiveMap;
+import org.apache.camel.impl.console.ApiDevConsole;
+
+/**
+ * Generated by camel build tools - do NOT edit this file!
+ */
+@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo")
+@SuppressWarnings("unchecked")
+public class ApiDevConsoleConfigurer extends org.apache.camel.support.component.PropertyConfigurerSupport implements GeneratedPropertyConfigurer, ExtendedPropertyConfigurerGetter {
+
+    private static final Map<String, Object> ALL_OPTIONS;
+    static {
+        Map<String, Object> map = new CaseInsensitiveMap();
+        map.put("CamelContext", org.apache.camel.CamelContext.class);
+        ALL_OPTIONS = map;
+    }
+
+    @Override
+    public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) {
+        org.apache.camel.impl.console.ApiDevConsole target = (org.apache.camel.impl.console.ApiDevConsole) obj;
+        switch (ignoreCase ? name.toLowerCase() : name) {
+        case "camelcontext":
+        case "camelContext": target.setCamelContext(property(camelContext, org.apache.camel.CamelContext.class, value)); return true;
+        default: return false;
+        }
+    }
+
+    @Override
+    public Map<String, Object> getAllOptions(Object target) {
+        return ALL_OPTIONS;
+    }
+
+    @Override
+    public Class<?> getOptionType(String name, boolean ignoreCase) {
+        switch (ignoreCase ? name.toLowerCase() : name) {
+        case "camelcontext":
+        case "camelContext": return org.apache.camel.CamelContext.class;
+        default: return null;
+        }
+    }
+
+    @Override
+    public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
+        org.apache.camel.impl.console.ApiDevConsole target = (org.apache.camel.impl.console.ApiDevConsole) obj;
+        switch (ignoreCase ? name.toLowerCase() : name) {
+        case "camelcontext":
+        case "camelContext": return target.getCamelContext();
+        default: return null;
+        }
+    }
+}
+
diff --git a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/api.json b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/api.json
new file mode 100644
index 000000000000..adaae420e0f1
--- /dev/null
+++ b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/api.json
@@ -0,0 +1,15 @@
+{
+  "console": {
+    "kind": "console",
+    "group": "camel",
+    "name": "api",
+    "title": "API",
+    "description": "OpenAPI specification for the dev console API",
+    "deprecated": false,
+    "javaType": "org.apache.camel.impl.console.ApiDevConsole",
+    "groupId": "org.apache.camel",
+    "artifactId": "camel-console",
+    "version": "4.22.0-SNAPSHOT"
+  }
+}
+
diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.ApiDevConsole b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.ApiDevConsole
new file mode 100644
index 000000000000..d53ecaf95941
--- /dev/null
+++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.ApiDevConsole
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.impl.console.ApiDevConsoleConfigurer
diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/api b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/api
new file mode 100644
index 000000000000..971f051a0b81
--- /dev/null
+++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/api
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.impl.console.ApiDevConsole
diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
index 9573a4157934..10e14834d7af 100644
--- a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
+++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
@@ -1,5 +1,5 @@
 # Generated by camel build tools - do NOT edit this file!
-dev-consoles=activity bean blocked browse circuit-breaker consumer context datasource debug endpoint errors eval-language event gc health heap-dump heap-histogram inflight internal-tasks java-security jfr-memory-leak jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure route-topology send service simple-language source sql-query sql-trace startup-recorder system-properties thread top trace [...]
+dev-consoles=activity api bean blocked browse circuit-breaker consumer context datasource debug endpoint errors eval-language event gc health heap-dump heap-histogram inflight internal-tasks java-security jfr-memory-leak jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure route-topology send service simple-language source sql-query sql-trace startup-recorder system-properties thread top t [...]
 groupId=org.apache.camel
 artifactId=camel-console
 version=4.22.0-SNAPSHOT
diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/ApiDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/ApiDevConsole.java
new file mode 100644
index 000000000000..f3963069a2c6
--- /dev/null
+++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/ApiDevConsole.java
@@ -0,0 +1,181 @@
+/*
+ * 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.impl.console;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.CatalogCamelContext;
+import org.apache.camel.console.DevConsoleRegistry;
+import org.apache.camel.spi.Configurer;
+import org.apache.camel.spi.annotations.DevConsole;
+import org.apache.camel.support.console.AbstractDevConsole;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+@DevConsole(name = "api", displayName = "API", description = "OpenAPI specification for the dev console API")
+@Configurer(extended = true)
+public class ApiDevConsole extends AbstractDevConsole {
+
+    private volatile String cachedOpenApi;
+
+    public ApiDevConsole() {
+        super("camel", "api", "API", "OpenAPI specification for the dev console API");
+    }
+
+    @Override
+    protected String doCallText(Map<String, Object> options) {
+        if (cachedOpenApi == null) {
+            cachedOpenApi = buildOpenApi();
+        }
+        return cachedOpenApi;
+    }
+
+    @Override
+    protected JsonObject doCallJson(Map<String, Object> options) {
+        if (cachedOpenApi == null) {
+            cachedOpenApi = buildOpenApi();
+        }
+        // parse the cached string back into a JsonObject so it integrates
+        // with the dev console JSON response structure
+        try {
+            return (JsonObject) Jsoner.deserialize(cachedOpenApi);
+        } catch (Exception e) {
+            JsonObject error = new JsonObject();
+            error.put("error", e.getMessage());
+            return error;
+        }
+    }
+
+    private String buildOpenApi() {
+        JsonObject root = new JsonObject();
+        root.put("openapi", "3.0.3");
+
+        JsonObject info = new JsonObject();
+        info.put("title", "Camel Dev Console API");
+        info.put("version", getCamelContext().getVersion());
+        root.put("info", info);
+
+        JsonObject paths = new JsonObject();
+
+        DevConsoleRegistry dcr = getCamelContext().getCamelContextExtension()
+                .getContextPlugin(DevConsoleRegistry.class);
+        if (dcr != null && dcr.isEnabled()) {
+            List<org.apache.camel.console.DevConsole> consoles = dcr.stream()
+                    .sorted((a, b) -> a.getId().compareToIgnoreCase(b.getId()))
+                    .toList();
+
+            for (org.apache.camel.console.DevConsole console : consoles) {
+                String id = console.getId();
+                JsonObject pathItem = new JsonObject();
+                JsonObject post = new JsonObject();
+                post.put("summary", console.getDisplayName());
+                post.put("description", console.getDescription());
+                post.put("operationId", id);
+
+                JsonObject requestBody = buildConsoleRequestBody(id);
+                if (requestBody != null) {
+                    post.put("requestBody", requestBody);
+                }
+
+                JsonObject responses = new JsonObject();
+                JsonObject ok = new JsonObject();
+                ok.put("description", console.getDisplayName() + " output");
+                JsonObject responseContent = new JsonObject();
+                responseContent.put("application/json", new JsonObject());
+                ok.put("content", responseContent);
+                responses.put("200", ok);
+                post.put("responses", responses);
+
+                pathItem.put("post", post);
+                paths.put("/q/dev/" + id, pathItem);
+            }
+        }
+
+        root.put("paths", paths);
+        return Jsoner.prettyPrint(root.toJson());
+    }
+
+    private JsonObject buildConsoleRequestBody(String consoleId) {
+        try {
+            String json = ((CatalogCamelContext) getCamelContext())
+                    .getDevConsoleParameterJsonSchema(consoleId);
+            if (json == null) {
+                return null;
+            }
+            Object parsed = Jsoner.deserialize(json);
+            if (!(parsed instanceof JsonObject jo)) {
+                return null;
+            }
+            Object optionsObj = jo.get("options");
+            if (!(optionsObj instanceof JsonObject opts) || opts.isEmpty()) {
+                return null;
+            }
+
+            JsonObject properties = new JsonObject();
+            JsonArray required = new JsonArray();
+            for (Map.Entry<String, Object> entry : opts.entrySet()) {
+                String name = entry.getKey();
+                if (!(entry.getValue() instanceof JsonObject opt)) {
+                    continue;
+                }
+                JsonObject prop = new JsonObject();
+                String type = opt.getString("type");
+                if (type != null) {
+                    prop.put("type", type);
+                }
+                String description = opt.getString("description");
+                if (description != null) {
+                    prop.put("description", description);
+                }
+                Object defaultValue = opt.get("defaultValue");
+                if (defaultValue != null) {
+                    prop.put("default", defaultValue);
+                }
+                Object enumValues = opt.get("enum");
+                if (enumValues instanceof JsonArray ea && !ea.isEmpty()) {
+                    prop.put("enum", enumValues);
+                }
+                properties.put(name, prop);
+
+                Boolean req = opt.getBoolean("required");
+                if (req != null && req) {
+                    required.add(name);
+                }
+            }
+
+            JsonObject schema = new JsonObject();
+            schema.put("type", "object");
+            schema.put("properties", properties);
+            if (!required.isEmpty()) {
+                schema.put("required", required);
+            }
+
+            JsonObject mediaType = new JsonObject();
+            mediaType.put("schema", schema);
+            JsonObject content = new JsonObject();
+            content.put("application/json", mediaType);
+            JsonObject requestBody = new JsonObject();
+            requestBody.put("content", content);
+            return requestBody;
+        } catch (Exception e) {
+            // ignore
+            return null;
+        }
+    }
+}