This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-24514-devconsole-openapi-get in repository https://gitbox.apache.org/repos/asf/camel.git
commit d287590b6da66f61048b9cc73a9af5268142908d Author: Claus Ibsen <[email protected]> AuthorDate: Sat Aug 29 12:46:16 2026 +0200 CAMEL-24514: camel-console - Generate GET instead of POST for read-only dev consoles in OpenAPI spec ApiDevConsole previously advertised every dev console as a POST operation, even though /q/dev was originally GET-only and most consoles are safe, read-only introspection endpoints. Add a DevConsole.isReadOnly() SPI method (default true) so each console declares whether it mutates runtime state; ApiDevConsole now generates a GET operation with query parameters for read-only consoles, and keeps POST with a JSON requestBody only for the consoles that need an arbitrary body (eval-language, send, sql-query) or that mutate state (route, route-group, processor, trace, jfr-memory-leak, receive, debug, heap-dump, reload, and camel-jfr's jfr console). Co-Authored-By: Claude Sonnet 5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/runtime/jfr/CamelJfrDevConsole.java | 5 + .../camel/runtime/jfr/CamelJfrDevConsoleTest.java | 6 + .../java/org/apache/camel/console/DevConsole.java | 13 ++ .../apache/camel/impl/console/ApiDevConsole.java | 172 ++++++++++++++------- .../apache/camel/impl/console/DebugDevConsole.java | 5 + .../camel/impl/console/EvalLanguageDevConsole.java | 5 + .../camel/impl/console/HeapDumpDevConsole.java | 5 + .../impl/console/JfrMemoryLeakDevConsole.java | 5 + .../camel/impl/console/ProcessorDevConsole.java | 5 + .../camel/impl/console/ReceiveDevConsole.java | 5 + .../camel/impl/console/ReloadDevConsole.java | 5 + .../apache/camel/impl/console/RouteDevConsole.java | 5 + .../camel/impl/console/RouteGroupDevConsole.java | 5 + .../apache/camel/impl/console/SendDevConsole.java | 5 + .../camel/impl/console/SqlQueryDevConsole.java | 5 + .../apache/camel/impl/console/TraceDevConsole.java | 5 + .../camel/impl/console/ApiDevConsoleTest.java | 69 +++++++++ 17 files changed, 271 insertions(+), 54 deletions(-) diff --git a/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java b/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java index 3aeff394ee6c..7b57a2cdc4ef 100644 --- a/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java +++ b/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java @@ -83,6 +83,11 @@ public class CamelJfrDevConsole extends AbstractDevConsole { "Status and live control of camel-jfr runtime instrumentation"); } + @Override + public boolean isReadOnly() { + return false; + } + private boolean isInstrumentationRegistered() { CamelContext ctx = getCamelContext(); if (ctx == null) { diff --git a/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java b/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java index cabb6dbe087e..ce49030870aa 100644 --- a/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java +++ b/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java @@ -62,6 +62,12 @@ class CamelJfrDevConsoleTest extends CamelTestSupport { return recording; } + @Test + void isNotReadOnly() { + // the console can enable/disable JFR events and trigger snapshots, so it must not be advertised as safe + assertThat(resolveConsole(context).isReadOnly()).isFalse(); + } + @Test void statusReportsNotRegisteredByDefault() { // camel-jfr is on the classpath, but its runtime instrumentation is opt-in, so merely having the diff --git a/core/camel-api/src/main/java/org/apache/camel/console/DevConsole.java b/core/camel-api/src/main/java/org/apache/camel/console/DevConsole.java index 087fd269a6b7..ccda24bac038 100644 --- a/core/camel-api/src/main/java/org/apache/camel/console/DevConsole.java +++ b/core/camel-api/src/main/java/org/apache/camel/console/DevConsole.java @@ -62,6 +62,19 @@ public interface DevConsole { */ boolean supportMediaType(MediaType mediaType); + /** + * Whether this console is read-only (safe, and does not change any runtime state). + * <p/> + * Consoles that mutate state (such as starting/stopping a route, sending a message, or triggering a heap dump) must + * override this method to return {@code false}. + * + * @return true if read-only (the default), false if invoking this console changes runtime state. + * @since 4.23 + */ + default boolean isReadOnly() { + return true; + } + /** * Invokes and gets the output from this console. */ 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 index f3963069a2c6..2fe27561dda6 100644 --- 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 @@ -82,15 +82,24 @@ public class ApiDevConsole extends AbstractDevConsole { for (org.apache.camel.console.DevConsole console : consoles) { String id = console.getId(); + boolean readOnly = console.isReadOnly(); + 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 operation = new JsonObject(); + operation.put("summary", console.getDisplayName()); + operation.put("description", console.getDescription()); + operation.put("operationId", id); + + if (readOnly) { + JsonArray parameters = buildConsoleParameters(id); + if (parameters != null) { + operation.put("parameters", parameters); + } + } else { + JsonObject requestBody = buildConsoleRequestBody(id); + if (requestBody != null) { + operation.put("requestBody", requestBody); + } } JsonObject responses = new JsonObject(); @@ -100,9 +109,9 @@ public class ApiDevConsole extends AbstractDevConsole { responseContent.put("application/json", new JsonObject()); ok.put("content", responseContent); responses.put("200", ok); - post.put("responses", responses); + operation.put("responses", responses); - pathItem.put("post", post); + pathItem.put(readOnly ? "get" : "post", operation); paths.put("/q/dev/" + id, pathItem); } } @@ -111,7 +120,12 @@ public class ApiDevConsole extends AbstractDevConsole { return Jsoner.prettyPrint(root.toJson()); } - private JsonObject buildConsoleRequestBody(String consoleId) { + /** + * Loads and parses the option schema for the given console. + * + * @return the parsed options, or null if the console has no options or the schema could not be loaded + */ + private JsonObject loadConsoleOptions(String consoleId) { try { String json = ((CatalogCamelContext) getCamelContext()) .getDevConsoleParameterJsonSchema(consoleId); @@ -126,56 +140,106 @@ public class ApiDevConsole extends AbstractDevConsole { if (!(optionsObj instanceof JsonObject opts) || opts.isEmpty()) { return null; } + return opts; + } catch (Exception e) { + // ignore + 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); + private JsonObject buildConsoleRequestBody(String consoleId) { + JsonObject opts = loadConsoleOptions(consoleId); + if (opts == null) { + return null; + } - Boolean req = opt.getBoolean("required"); - if (req != null && req) { - required.add(name); - } + 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); - JsonObject schema = new JsonObject(); - schema.put("type", "object"); - schema.put("properties", properties); - if (!required.isEmpty()) { - schema.put("required", required); + Boolean req = opt.getBoolean("required"); + if (req != null && req) { + required.add(name); } + } - 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 + 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; + } + + private JsonArray buildConsoleParameters(String consoleId) { + JsonObject opts = loadConsoleOptions(consoleId); + if (opts == null) { return null; } + + JsonArray parameters = new JsonArray(); + for (Map.Entry<String, Object> entry : opts.entrySet()) { + String name = entry.getKey(); + if (!(entry.getValue() instanceof JsonObject opt)) { + continue; + } + JsonObject param = new JsonObject(); + param.put("name", name); + param.put("in", "query"); + String description = opt.getString("description"); + if (description != null) { + param.put("description", description); + } + Boolean req = opt.getBoolean("required"); + param.put("required", req != null && req); + + JsonObject schema = new JsonObject(); + String type = opt.getString("type"); + if (type != null) { + schema.put("type", type); + } + Object defaultValue = opt.get("defaultValue"); + if (defaultValue != null) { + schema.put("default", defaultValue); + } + Object enumValues = opt.get("enum"); + if (enumValues instanceof JsonArray ea && !ea.isEmpty()) { + schema.put("enum", enumValues); + } + param.put("schema", schema); + + parameters.add(param); + } + return parameters.isEmpty() ? null : parameters; } } diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/DebugDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/DebugDevConsole.java index 211598e215ed..decaa2f7e681 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/DebugDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/DebugDevConsole.java @@ -61,6 +61,11 @@ public class DebugDevConsole extends AbstractDevConsole { super("camel", "debug", "Debug", "Camel route debugger"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected String doCallText(Map<String, Object> options) { String command = optionString(options, COMMAND); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java index 2eeea51acd74..aed61a0f7e2d 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java @@ -55,6 +55,11 @@ public class EvalLanguageDevConsole extends AbstractDevConsole { super("camel", "eval-language", "Evaluate Language", "Evaluate Language and display result"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected String doCallText(Map<String, Object> options) { StringBuilder sb = new StringBuilder(); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/HeapDumpDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/HeapDumpDevConsole.java index 9351218b149a..5f208ba50b7d 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/HeapDumpDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/HeapDumpDevConsole.java @@ -51,6 +51,11 @@ public class HeapDumpDevConsole extends AbstractDevConsole { super("jvm", "heap-dump", "Heap Dump", "Write a heap dump (.hprof) file for deep memory analysis"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected String doCallText(Map<String, Object> options) { JsonObject json = doCallJson(options); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/JfrMemoryLeakDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/JfrMemoryLeakDevConsole.java index 705b375790de..1ebdcdcbb319 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/JfrMemoryLeakDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/JfrMemoryLeakDevConsole.java @@ -99,6 +99,11 @@ public class JfrMemoryLeakDevConsole extends AbstractDevConsole { "JFR-based old object sampling for memory leak diagnosis"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected String doCallText(Map<String, Object> options) { JsonObject json = doCallJson(options); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDevConsole.java index a1c0b8baf57a..dd830b513a8a 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDevConsole.java @@ -64,6 +64,11 @@ public class ProcessorDevConsole extends AbstractDevConsole { super("camel", "processor", "Processor", "Processor information"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected String doCallText(Map<String, Object> options) { String action = optionString(options, ACTION); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/ReceiveDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/ReceiveDevConsole.java index 71e8da82d952..32d935674459 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/ReceiveDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/ReceiveDevConsole.java @@ -85,6 +85,11 @@ public class ReceiveDevConsole extends AbstractDevConsole { super("camel", "receive", "Camel Receive", "Consume messages from endpoints"); } + @Override + public boolean isReadOnly() { + return false; + } + public int getCapacity() { return capacity; } diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/ReloadDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/ReloadDevConsole.java index 1507da680973..582badf694e2 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/ReloadDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/ReloadDevConsole.java @@ -49,6 +49,11 @@ public class ReloadDevConsole extends AbstractDevConsole { super("camel", "reload", "Reload", "Console for reloading running Camel"); } + @Override + public boolean isReadOnly() { + return false; + } + protected String doCallText(Map<String, Object> options) { boolean trigger = optionBoolean(options, RELOAD, false); boolean wait = optionBoolean(options, RELOAD_WAIT, false); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteDevConsole.java index 5e77d6f69f5f..826b1a8514f4 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteDevConsole.java @@ -70,6 +70,11 @@ public class RouteDevConsole extends AbstractDevConsole { super("camel", "route", "Route", "Route information"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected String doCallText(Map<String, Object> options) { String action = optionString(options, ACTION); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteGroupDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteGroupDevConsole.java index 80984b9fd8a5..b05e1de71819 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteGroupDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteGroupDevConsole.java @@ -62,6 +62,11 @@ public class RouteGroupDevConsole extends AbstractDevConsole { super("camel", "route-group", "Route Group", "Route Group information"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected String doCallText(Map<String, Object> options) { String action = optionString(options, ACTION); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java index 8fd1c5e39034..c75f6b9db08b 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java @@ -87,6 +87,11 @@ public class SendDevConsole extends AbstractDevConsole { super("camel", "send", "Camel Send", "Send messages to endpoints"); } + @Override + public boolean isReadOnly() { + return false; + } + @Override protected void doInit() throws Exception { super.doInit(); diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java index 914d1de0a948..1b92020f9021 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java @@ -88,6 +88,11 @@ public class SqlQueryDevConsole extends AbstractDevConsole { super("camel", "sql-query", "SQL Query", "Execute SQL queries on DataSource beans"); } + @Override + public boolean isReadOnly() { + return false; + } + public int getDefaultMaxRows() { return defaultMaxRows; } diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/TraceDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/TraceDevConsole.java index d1aeda3d3d48..7782ea4ed879 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/TraceDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/TraceDevConsole.java @@ -51,6 +51,11 @@ public class TraceDevConsole extends AbstractDevConsole { super("camel", "trace", "Camel Tracing", "Trace routed messages"); } + @Override + public boolean isReadOnly() { + return false; + } + public int getCapacity() { return capacity; } diff --git a/core/camel-console/src/test/java/org/apache/camel/impl/console/ApiDevConsoleTest.java b/core/camel-console/src/test/java/org/apache/camel/impl/console/ApiDevConsoleTest.java new file mode 100644 index 000000000000..0fbab572ad9b --- /dev/null +++ b/core/camel-console/src/test/java/org/apache/camel/impl/console/ApiDevConsoleTest.java @@ -0,0 +1,69 @@ +/* + * 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 org.apache.camel.console.DevConsole; +import org.apache.camel.console.DevConsoleRegistry; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ApiDevConsoleTest extends AbstractDevConsoleTest { + + private JsonObject openApiPaths() { + DevConsoleRegistry dcr = context.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class); + dcr.loadDevConsoles(); + + DevConsole con = assertConsoleExists("api", "camel"); + JsonObject root = callJson(con); + JsonObject paths = root.getJsonObject("paths"); + assertThat(paths).isNotNull(); + return paths; + } + + @Test + public void testReadOnlyConsoleUsesGet() { + JsonObject paths = openApiPaths(); + + JsonObject contextPath = paths.getJsonObject("/q/dev/context"); + assertThat(contextPath).as("context console path should exist").isNotNull(); + assertThat(contextPath.getJsonObject("get")).as("context console should be declared as GET").isNotNull(); + assertThat(contextPath.getJsonObject("post")).as("context console should not be declared as POST").isNull(); + } + + @Test + public void testMutatingConsoleUsesPost() { + JsonObject paths = openApiPaths(); + + JsonObject routePath = paths.getJsonObject("/q/dev/route"); + assertThat(routePath).as("route console path should exist").isNotNull(); + assertThat(routePath.getJsonObject("get")).as("route console should not be declared as GET").isNull(); + + JsonObject post = routePath.getJsonObject("post"); + assertThat(post).as("route console should be declared as POST").isNotNull(); + assertThat(post.get("requestBody")).as("route console POST should carry a requestBody").isNotNull(); + } + + @Test + public void testIsReadOnlyDefaults() { + assertThat(new ContextDevConsole().isReadOnly()).isTrue(); + assertThat(new RouteDevConsole().isReadOnly()).isFalse(); + assertThat(new EvalLanguageDevConsole().isReadOnly()).isFalse(); + assertThat(new SendDevConsole().isReadOnly()).isFalse(); + } +}
