gnodet-bot commented on code in PR #28727: URL: https://github.com/apache/camel/pull/28727#discussion_r4530178300
########## docs/user-manual/modules/ROOT/pages/camel-jbang-kaoto.adoc: ########## @@ -0,0 +1,262 @@ += Camel Kaoto Plugin + +*Available as of Camel 4.24* + +The Kaoto plugin for Camel CLI brings the https://kaoto.io/[Kaoto] visual route editor directly +to your development workflow. It serves a self-contained Kaoto web application from an embedded +web server -- no separate installation, no Docker at runtime, no Node.js required. + +With Kaoto you can visually design and edit Camel routes in YAML and XML, using a drag-and-drop +canvas with full access to the Camel component catalog. + +== Getting Started + +Install the plugin and start the editor: + +[source,bash] +---- +camel plugin add kaoto +camel kaoto start +---- + +Kaoto opens automatically in your default browser at `http://localhost:8080`. Review Comment: 🔴 **Port mismatch**: the code defaults to port **5174** (`KaotoStart.java` `defaultValue = "5174"`, `int port = 5174`), but all documentation examples use 8080. Users following this guide will get `Connection refused`. Change to `http://localhost:5174` (or align the code default to 8080). ########## dsl/camel-jbang/camel-jbang-plugin-kaoto/src/main/java/org/apache/camel/dsl/jbang/core/commands/kaoto/KaotoRequestHandler.java: ########## @@ -0,0 +1,518 @@ +/* + * 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.kaoto; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +final class KaotoRequestHandler { + + private static final Pattern ROUTE_FILE_PATTERN + = Pattern.compile(".*\\.camel\\.(yaml|xml)$", Pattern.CASE_INSENSITIVE); + private static final Pattern TEST_FILE_PATTERN + = Pattern.compile(".*\\.citrus(\\.it)?\\.(yaml|xml)$", Pattern.CASE_INSENSITIVE); + private static final String STATIC_ROOT = "kaoto/src"; + private static final String JSON_CONTENT_TYPE = "application/json; charset=UTF-8"; + private static final String CAMEL_LOG_DIR = ".camel-jbang"; + private static final String CITRUS_LOG_DIR = ".citrus-jbang"; + + private static final Map<String, String> CONTENT_TYPES = Map.ofEntries( + Map.entry("html", "text/html; charset=UTF-8"), + Map.entry("js", "application/javascript; charset=UTF-8"), + Map.entry("mjs", "application/javascript; charset=UTF-8"), + Map.entry("css", "text/css; charset=UTF-8"), + Map.entry("json", "application/json; charset=UTF-8"), + Map.entry("svg", "image/svg+xml"), + Map.entry("png", "image/png"), + Map.entry("ico", "image/x-icon"), + Map.entry("woff", "font/woff"), + Map.entry("woff2", "font/woff2"), + Map.entry("ttf", "font/ttf"), + Map.entry("txt", "text/plain; charset=UTF-8")); + + private KaotoRequestHandler() { + } + + static void registerRoutes( + Router router, Path sourceDir, CountDownLatch shutdownLatch, + KaotoWebServer.ProcessManager processManager) { + router.route("/api/*").handler(BodyHandler.create().setBodyLimit(1_048_576)); + + router.get("/api/routes").handler(ctx -> handleListFiles(ctx, sourceDir, ROUTE_FILE_PATTERN, processManager)); + router.get("/api/routes/log/:filename").handler(ctx -> handleGetLog(ctx, sourceDir, CAMEL_LOG_DIR)); + router.post("/api/routes/run/:filename").handler(ctx -> handleRunRoute(ctx, sourceDir, processManager)); + router.post("/api/routes/stop/:filename").handler(ctx -> handleStopProcess(ctx, processManager)); + router.get("/api/routes/:filename").handler(ctx -> handleGetFile(ctx, sourceDir)); + router.put("/api/routes/:filename").handler(ctx -> handlePutFile(ctx, sourceDir)); + + router.get("/api/tests").handler(ctx -> handleListTests(ctx, sourceDir, processManager)); + router.get("/api/tests/log/:filename").handler(ctx -> handleGetLog(ctx, sourceDir, CITRUS_LOG_DIR)); + router.get("/api/tests/results/:filename").handler(ctx -> handleGetTestResults(ctx, sourceDir)); + router.post("/api/tests/run/:filename").handler(ctx -> handleRunTest(ctx, sourceDir, processManager)); + router.post("/api/tests/stop/:filename").handler(ctx -> handleStopProcess(ctx, processManager)); + router.get("/api/tests/:filename").handler(ctx -> handleGetTestFile(ctx, sourceDir)); + router.put("/api/tests/:filename").handler(ctx -> handlePutTestFile(ctx, sourceDir)); + + router.post("/api/shutdown").handler(ctx -> { + sendJson(ctx, 200, Map.of("shutdown", true)); + shutdownLatch.countDown(); + }); + + router.get("/*").handler(ctx -> handleStaticFile(ctx)); + } + + private static void handleListFiles( + RoutingContext ctx, Path dir, Pattern pattern, KaotoWebServer.ProcessManager processManager) { + JsonArray files = new JsonArray(); + collectFiles(dir, pattern, "", files, processManager); + sendJson(ctx, 200, files); + } + + private static void handleListTests( + RoutingContext ctx, Path sourceDir, KaotoWebServer.ProcessManager processManager) { + JsonArray files = new JsonArray(); + collectFiles(sourceDir, TEST_FILE_PATTERN, "", files, processManager); + Path testSubDir = sourceDir.resolve("test"); + if (Files.isDirectory(testSubDir)) { + collectFiles(testSubDir, TEST_FILE_PATTERN, "", files, processManager); + } + sendJson(ctx, 200, files); + } + + private static void handleGetFile(RoutingContext ctx, Path baseDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = baseDir.resolve(filename).normalize(); + if (!filePath.startsWith(baseDir)) { + sendJsonError(ctx, 403, "Access denied"); + return; + } + + if (!Files.isRegularFile(filePath)) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + try { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + JsonObject result = new JsonObject(); + result.put("name", filename); + result.put("content", content); + sendJson(ctx, 200, result); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to read file: " + e.getMessage()); + } + } + + private static void handlePutFile(RoutingContext ctx, Path baseDir) { + String filename = ctx.pathParam("filename"); + writeFile(ctx, baseDir, filename); + } + + private static void handleRunRoute( + RoutingContext ctx, Path sourceDir, KaotoWebServer.ProcessManager processManager) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = sourceDir.resolve(filename).normalize(); + if (!filePath.startsWith(sourceDir)) { + sendJsonError(ctx, 403, "Access denied"); + return; + } + + if (!Files.isRegularFile(filePath)) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + Path logFile = resolveLogFile(sourceDir, CAMEL_LOG_DIR, filename); + try { + processManager.launch(filename, logFile, "run", "--logging-color=false", filePath.toString()); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to launch command: " + e.getMessage()); + return; + } + + JsonObject result = new JsonObject(); + result.put("command", "run"); + result.put("file", filename); + result.put("started", true); + sendJson(ctx, 200, result); + } + + private static void handleRunTest( + RoutingContext ctx, Path sourceDir, KaotoWebServer.ProcessManager processManager) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = resolveTestFile(sourceDir, filename); + if (filePath == null) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + Path logFile = resolveLogFile(sourceDir, CITRUS_LOG_DIR, filename); + try { + processManager.launch(filename, logFile, "test", "run", "--logging-color=false", filePath.toString()); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to launch command: " + e.getMessage()); + return; + } + + JsonObject result = new JsonObject(); + result.put("command", "test run"); + result.put("file", filename); + result.put("started", true); + sendJson(ctx, 200, result); + } + + private static void handleStopProcess(RoutingContext ctx, KaotoWebServer.ProcessManager processManager) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + boolean stopped = processManager.stop(filename); + JsonObject result = new JsonObject(); + result.put("file", filename); + result.put("stopped", stopped); + sendJson(ctx, 200, result); + } + + private static void handleGetTestResults(RoutingContext ctx, Path sourceDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path resultsDir = sourceDir.resolve(".citrus-jbang/citrus-reports"); + if (!Files.isDirectory(resultsDir)) { + ctx.response().setStatusCode(204).end(); + return; + } + + Path testFile = resolveTestFile(sourceDir, filename); + String testName = testFile != null ? extractTestName(testFile) : null; + String flowFileName = (testName != null ? testName : resolveBaseNameFromFilename(filename)) + "-flow"; + Path flowFile = findLatestFlowFile(resultsDir, flowFileName); + if (flowFile == null) { + ctx.response().setStatusCode(204).end(); + return; + } + + try { + String content = Files.readString(flowFile, StandardCharsets.UTF_8); + ctx.response() + .setStatusCode(200) + .putHeader("Content-Type", JSON_CONTENT_TYPE) + .end(content); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to read test results: " + e.getMessage()); + } + } + + private static void handleGetTestFile(RoutingContext ctx, Path sourceDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = resolveTestFile(sourceDir, filename); + if (filePath == null) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + try { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + JsonObject result = new JsonObject(); + result.put("name", filename); + result.put("content", content); + sendJson(ctx, 200, result); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to read file: " + e.getMessage()); + } + } + + private static void handlePutTestFile(RoutingContext ctx, Path sourceDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path existing = resolveTestFile(sourceDir, filename); + Path baseDir = existing != null ? existing.getParent() : sourceDir; + writeFile(ctx, baseDir, filename); + } + + private static Path resolveTestFile(Path sourceDir, String filename) { + Path filePath = sourceDir.resolve(filename).normalize(); + if (filePath.startsWith(sourceDir) && Files.isRegularFile(filePath)) { + return filePath; + } + Path testDir = sourceDir.resolve("test"); + filePath = testDir.resolve(filename).normalize(); + if (filePath.startsWith(testDir) && Files.isRegularFile(filePath)) { + return filePath; + } + return null; + } + + private static final Pattern YAML_NAME_PATTERN = Pattern.compile("^name:\\s*(.+)$", Pattern.MULTILINE); + private static final Pattern XML_NAME_PATTERN = Pattern.compile("<test\\s[^>]*name=\"([^\"]+)\""); + + static String extractTestName(Path testFile) { + try { + String content = Files.readString(testFile, StandardCharsets.UTF_8); + String fileName = testFile.getFileName().toString().toLowerCase(); + Matcher matcher; + if (fileName.endsWith(".xml")) { + matcher = XML_NAME_PATTERN.matcher(content); + } else { + matcher = YAML_NAME_PATTERN.matcher(content); + } + if (matcher.find()) { + return matcher.group(1).trim(); + } + } catch (IOException e) { + // fall through + } + return null; + } + + static String resolveBaseNameFromFilename(String filename) { + if (filename.matches("(?i).*\\.citrus\\.it\\.(yaml|xml)$")) { + return filename.replaceFirst("(?i)\\.citrus\\.it\\.(yaml|xml)$", ""); + } else if (filename.matches("(?i).*\\.citrus\\.(yaml|xml)$")) { + return filename.replaceFirst("(?i)\\.citrus\\.(yaml|xml)$", ""); + } else { + int dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.substring(0, dot) : filename; + } + } + + private static Path findLatestFlowFile(Path resultsDir, String flowFilePrefix) { + Path exact = resultsDir.resolve(flowFilePrefix + ".json"); + if (Files.isRegularFile(exact)) { + return exact; + } + + try (DirectoryStream<Path> stream = Files.newDirectoryStream(resultsDir, + path -> path.getFileName().toString().startsWith(flowFilePrefix) + && path.getFileName().toString().endsWith(".json"))) { + Path latest = null; + for (Path entry : stream) { + if (Files.isRegularFile(entry)) { + if (latest == null + || entry.getFileName().toString().compareTo(latest.getFileName().toString()) > 0) { + latest = entry; + } + } + } + return latest; + } catch (IOException e) { + return null; + } + } + + private static void handleGetLog(RoutingContext ctx, Path sourceDir, String logDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path logFile = resolveLogFile(sourceDir, logDir, filename); + if (!Files.isRegularFile(logFile)) { + ctx.response().setStatusCode(204).end(); + return; + } + + try { + String content = Files.readString(logFile, StandardCharsets.UTF_8); + ctx.response() + .setStatusCode(200) + .putHeader("Content-Type", "text/plain; charset=UTF-8") + .end(content); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to read log: " + e.getMessage()); + } + } + + static Path resolveLogFile(Path sourceDir, String logDir, String filename) { + int dot = filename.lastIndexOf('.'); + String baseName = dot > 0 ? filename.substring(0, dot) : filename; + return sourceDir.resolve(logDir).resolve(baseName + ".log"); + } + + private static void writeFile(RoutingContext ctx, Path baseDir, String filename) { + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = baseDir.resolve(filename).normalize(); + if (!filePath.startsWith(baseDir)) { + sendJsonError(ctx, 403, "Access denied"); + return; + } + + try { + JsonObject json = (JsonObject) Jsoner.deserialize(ctx.body().asString()); + String content = json.getString("content"); + if (content == null) { + sendJsonError(ctx, 400, "Missing 'content' field"); + return; + } + + Files.createDirectories(filePath.getParent()); + Files.writeString(filePath, content, StandardCharsets.UTF_8); + JsonObject result = new JsonObject(); + result.put("name", filename); + result.put("saved", true); + sendJson(ctx, 200, result); + } catch (Exception e) { + sendJsonError(ctx, 500, "Failed to save file: " + e.getMessage()); Review Comment: ⚠️ **Wrong HTTP status for bad client input**: a JSON parse failure (`JsonException` from `Jsoner.deserialize`) is a **client error (400)**, not a server error (500). Split the catch: ```java } catch (com.github.cliftonlabs.json_simple.JsonException e) { sendJsonError(ctx, 400, "Invalid JSON: " + e.getMessage()); } catch (Exception e) { sendJsonError(ctx, 500, "Failed to save file: " + e.getMessage()); } ``` ########## dsl/camel-jbang/camel-jbang-plugin-kaoto/src/main/java/org/apache/camel/dsl/jbang/core/commands/kaoto/KaotoWebServer.java: ########## @@ -0,0 +1,214 @@ +/* + * 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.kaoto; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.BindException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; + +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.Router; +import org.apache.camel.dsl.jbang.core.common.LauncherHelper; + +class KaotoWebServer { + + @FunctionalInterface + interface CommandLauncher { + Process launch(Path logFile, String... args) throws IOException; + } + + interface ProcessManager { + void launch(String key, Path logFile, String... args) throws IOException; + + boolean isRunning(String key); + + boolean stop(String key); + } + + private final int port; + private final Path sourceDir; + private final CountDownLatch shutdownLatch; + private final CommandLauncher launcher; + private final ConcurrentHashMap<String, Process> processes = new ConcurrentHashMap<>(); + private Vertx vertx; + private HttpServer server; + private boolean stopped; + + KaotoWebServer(int port, Path sourceDir, CountDownLatch shutdownLatch) { + this(port, sourceDir, shutdownLatch, + (logFile, args) -> launchCamelProcess(sourceDir, args)); + } + + KaotoWebServer(int port, Path sourceDir, CountDownLatch shutdownLatch, CommandLauncher launcher) { + this.port = port; + this.sourceDir = sourceDir; + this.shutdownLatch = shutdownLatch; + this.launcher = launcher; + } + + void start() throws IOException { + vertx = Vertx.vertx(); + + Router router = Router.router(vertx); + + router.route().handler(ctx -> { + ctx.response().putHeader("X-Frame-Options", "DENY"); + ctx.response().putHeader("Content-Security-Policy", "frame-ancestors 'none'"); + ctx.next(); + }); + + ProcessManager processManager = new ProcessManager() { + @Override + public void launch(String key, Path logFile, String... args) throws IOException { + stop(key); + Process process = launcher.launch(logFile, args); + processes.put(key, process); + monitorProcess(process, logFile); + } + + @Override + public boolean isRunning(String key) { + Process p = processes.get(key); + return p != null && p.isAlive(); + } + + @Override + public boolean stop(String key) { + Process p = processes.get(key); + if (p != null && p.isAlive()) { + p.descendants().forEach(ProcessHandle::destroy); + p.destroy(); + return true; + } + return false; Review Comment: Minor: `stop(key)` destroys the process but never removes it from `processes`. Dead `Process` references accumulate until the key is re-launched or `stop()` (global) clears the map. Add `processes.remove(key)` after `p.destroy()`: ```suggestion p.descendants().forEach(ProcessHandle::destroy); p.destroy(); processes.remove(key); return true; ``` ########## dsl/camel-jbang/camel-jbang-plugin-kaoto/src/main/java/org/apache/camel/dsl/jbang/core/commands/kaoto/KaotoRequestHandler.java: ########## @@ -0,0 +1,518 @@ +/* + * 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.kaoto; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +final class KaotoRequestHandler { + + private static final Pattern ROUTE_FILE_PATTERN + = Pattern.compile(".*\\.camel\\.(yaml|xml)$", Pattern.CASE_INSENSITIVE); + private static final Pattern TEST_FILE_PATTERN + = Pattern.compile(".*\\.citrus(\\.it)?\\.(yaml|xml)$", Pattern.CASE_INSENSITIVE); + private static final String STATIC_ROOT = "kaoto/src"; + private static final String JSON_CONTENT_TYPE = "application/json; charset=UTF-8"; + private static final String CAMEL_LOG_DIR = ".camel-jbang"; + private static final String CITRUS_LOG_DIR = ".citrus-jbang"; + + private static final Map<String, String> CONTENT_TYPES = Map.ofEntries( + Map.entry("html", "text/html; charset=UTF-8"), + Map.entry("js", "application/javascript; charset=UTF-8"), + Map.entry("mjs", "application/javascript; charset=UTF-8"), + Map.entry("css", "text/css; charset=UTF-8"), + Map.entry("json", "application/json; charset=UTF-8"), + Map.entry("svg", "image/svg+xml"), + Map.entry("png", "image/png"), + Map.entry("ico", "image/x-icon"), + Map.entry("woff", "font/woff"), + Map.entry("woff2", "font/woff2"), + Map.entry("ttf", "font/ttf"), + Map.entry("txt", "text/plain; charset=UTF-8")); + + private KaotoRequestHandler() { + } + + static void registerRoutes( + Router router, Path sourceDir, CountDownLatch shutdownLatch, + KaotoWebServer.ProcessManager processManager) { + router.route("/api/*").handler(BodyHandler.create().setBodyLimit(1_048_576)); + + router.get("/api/routes").handler(ctx -> handleListFiles(ctx, sourceDir, ROUTE_FILE_PATTERN, processManager)); + router.get("/api/routes/log/:filename").handler(ctx -> handleGetLog(ctx, sourceDir, CAMEL_LOG_DIR)); + router.post("/api/routes/run/:filename").handler(ctx -> handleRunRoute(ctx, sourceDir, processManager)); + router.post("/api/routes/stop/:filename").handler(ctx -> handleStopProcess(ctx, processManager)); + router.get("/api/routes/:filename").handler(ctx -> handleGetFile(ctx, sourceDir)); + router.put("/api/routes/:filename").handler(ctx -> handlePutFile(ctx, sourceDir)); + + router.get("/api/tests").handler(ctx -> handleListTests(ctx, sourceDir, processManager)); + router.get("/api/tests/log/:filename").handler(ctx -> handleGetLog(ctx, sourceDir, CITRUS_LOG_DIR)); + router.get("/api/tests/results/:filename").handler(ctx -> handleGetTestResults(ctx, sourceDir)); + router.post("/api/tests/run/:filename").handler(ctx -> handleRunTest(ctx, sourceDir, processManager)); + router.post("/api/tests/stop/:filename").handler(ctx -> handleStopProcess(ctx, processManager)); + router.get("/api/tests/:filename").handler(ctx -> handleGetTestFile(ctx, sourceDir)); + router.put("/api/tests/:filename").handler(ctx -> handlePutTestFile(ctx, sourceDir)); + + router.post("/api/shutdown").handler(ctx -> { + sendJson(ctx, 200, Map.of("shutdown", true)); + shutdownLatch.countDown(); + }); + + router.get("/*").handler(ctx -> handleStaticFile(ctx)); + } + + private static void handleListFiles( + RoutingContext ctx, Path dir, Pattern pattern, KaotoWebServer.ProcessManager processManager) { + JsonArray files = new JsonArray(); + collectFiles(dir, pattern, "", files, processManager); + sendJson(ctx, 200, files); + } + + private static void handleListTests( + RoutingContext ctx, Path sourceDir, KaotoWebServer.ProcessManager processManager) { + JsonArray files = new JsonArray(); + collectFiles(sourceDir, TEST_FILE_PATTERN, "", files, processManager); + Path testSubDir = sourceDir.resolve("test"); + if (Files.isDirectory(testSubDir)) { + collectFiles(testSubDir, TEST_FILE_PATTERN, "", files, processManager); + } + sendJson(ctx, 200, files); + } + + private static void handleGetFile(RoutingContext ctx, Path baseDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = baseDir.resolve(filename).normalize(); + if (!filePath.startsWith(baseDir)) { + sendJsonError(ctx, 403, "Access denied"); + return; + } + + if (!Files.isRegularFile(filePath)) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + try { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + JsonObject result = new JsonObject(); + result.put("name", filename); + result.put("content", content); + sendJson(ctx, 200, result); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to read file: " + e.getMessage()); + } + } + + private static void handlePutFile(RoutingContext ctx, Path baseDir) { + String filename = ctx.pathParam("filename"); + writeFile(ctx, baseDir, filename); + } + + private static void handleRunRoute( + RoutingContext ctx, Path sourceDir, KaotoWebServer.ProcessManager processManager) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = sourceDir.resolve(filename).normalize(); + if (!filePath.startsWith(sourceDir)) { + sendJsonError(ctx, 403, "Access denied"); + return; + } + + if (!Files.isRegularFile(filePath)) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + Path logFile = resolveLogFile(sourceDir, CAMEL_LOG_DIR, filename); + try { + processManager.launch(filename, logFile, "run", "--logging-color=false", filePath.toString()); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to launch command: " + e.getMessage()); + return; + } + + JsonObject result = new JsonObject(); + result.put("command", "run"); + result.put("file", filename); + result.put("started", true); + sendJson(ctx, 200, result); + } + + private static void handleRunTest( + RoutingContext ctx, Path sourceDir, KaotoWebServer.ProcessManager processManager) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = resolveTestFile(sourceDir, filename); + if (filePath == null) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + Path logFile = resolveLogFile(sourceDir, CITRUS_LOG_DIR, filename); + try { + processManager.launch(filename, logFile, "test", "run", "--logging-color=false", filePath.toString()); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to launch command: " + e.getMessage()); + return; + } + + JsonObject result = new JsonObject(); + result.put("command", "test run"); + result.put("file", filename); + result.put("started", true); + sendJson(ctx, 200, result); + } + + private static void handleStopProcess(RoutingContext ctx, KaotoWebServer.ProcessManager processManager) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + boolean stopped = processManager.stop(filename); + JsonObject result = new JsonObject(); + result.put("file", filename); + result.put("stopped", stopped); + sendJson(ctx, 200, result); + } + + private static void handleGetTestResults(RoutingContext ctx, Path sourceDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path resultsDir = sourceDir.resolve(".citrus-jbang/citrus-reports"); + if (!Files.isDirectory(resultsDir)) { + ctx.response().setStatusCode(204).end(); + return; + } + + Path testFile = resolveTestFile(sourceDir, filename); + String testName = testFile != null ? extractTestName(testFile) : null; + String flowFileName = (testName != null ? testName : resolveBaseNameFromFilename(filename)) + "-flow"; + Path flowFile = findLatestFlowFile(resultsDir, flowFileName); + if (flowFile == null) { + ctx.response().setStatusCode(204).end(); + return; + } + + try { + String content = Files.readString(flowFile, StandardCharsets.UTF_8); + ctx.response() + .setStatusCode(200) + .putHeader("Content-Type", JSON_CONTENT_TYPE) + .end(content); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to read test results: " + e.getMessage()); + } + } + + private static void handleGetTestFile(RoutingContext ctx, Path sourceDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path filePath = resolveTestFile(sourceDir, filename); + if (filePath == null) { + sendJsonError(ctx, 404, "File not found: " + filename); + return; + } + + try { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + JsonObject result = new JsonObject(); + result.put("name", filename); + result.put("content", content); + sendJson(ctx, 200, result); + } catch (IOException e) { + sendJsonError(ctx, 500, "Failed to read file: " + e.getMessage()); + } + } + + private static void handlePutTestFile(RoutingContext ctx, Path sourceDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path existing = resolveTestFile(sourceDir, filename); + Path baseDir = existing != null ? existing.getParent() : sourceDir; + writeFile(ctx, baseDir, filename); + } + + private static Path resolveTestFile(Path sourceDir, String filename) { + Path filePath = sourceDir.resolve(filename).normalize(); + if (filePath.startsWith(sourceDir) && Files.isRegularFile(filePath)) { + return filePath; + } + Path testDir = sourceDir.resolve("test"); + filePath = testDir.resolve(filename).normalize(); + if (filePath.startsWith(testDir) && Files.isRegularFile(filePath)) { + return filePath; + } + return null; + } + + private static final Pattern YAML_NAME_PATTERN = Pattern.compile("^name:\\s*(.+)$", Pattern.MULTILINE); + private static final Pattern XML_NAME_PATTERN = Pattern.compile("<test\\s[^>]*name=\"([^\"]+)\""); + + static String extractTestName(Path testFile) { + try { + String content = Files.readString(testFile, StandardCharsets.UTF_8); + String fileName = testFile.getFileName().toString().toLowerCase(); + Matcher matcher; + if (fileName.endsWith(".xml")) { + matcher = XML_NAME_PATTERN.matcher(content); + } else { + matcher = YAML_NAME_PATTERN.matcher(content); + } + if (matcher.find()) { + return matcher.group(1).trim(); + } + } catch (IOException e) { + // fall through + } + return null; + } + + static String resolveBaseNameFromFilename(String filename) { + if (filename.matches("(?i).*\\.citrus\\.it\\.(yaml|xml)$")) { + return filename.replaceFirst("(?i)\\.citrus\\.it\\.(yaml|xml)$", ""); + } else if (filename.matches("(?i).*\\.citrus\\.(yaml|xml)$")) { + return filename.replaceFirst("(?i)\\.citrus\\.(yaml|xml)$", ""); + } else { + int dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.substring(0, dot) : filename; + } + } + + private static Path findLatestFlowFile(Path resultsDir, String flowFilePrefix) { + Path exact = resultsDir.resolve(flowFilePrefix + ".json"); + if (Files.isRegularFile(exact)) { + return exact; + } + + try (DirectoryStream<Path> stream = Files.newDirectoryStream(resultsDir, + path -> path.getFileName().toString().startsWith(flowFilePrefix) + && path.getFileName().toString().endsWith(".json"))) { + Path latest = null; + for (Path entry : stream) { + if (Files.isRegularFile(entry)) { + if (latest == null + || entry.getFileName().toString().compareTo(latest.getFileName().toString()) > 0) { + latest = entry; + } + } + } + return latest; + } catch (IOException e) { + return null; + } + } + + private static void handleGetLog(RoutingContext ctx, Path sourceDir, String logDir) { + String filename = ctx.pathParam("filename"); + if (!isSafeFilename(filename)) { + sendJsonError(ctx, 400, "Invalid filename"); + return; + } + + Path logFile = resolveLogFile(sourceDir, logDir, filename); + if (!Files.isRegularFile(logFile)) { + ctx.response().setStatusCode(204).end(); + return; + } + + try { + String content = Files.readString(logFile, StandardCharsets.UTF_8); Review Comment: ⚠️ **OOM risk**: `Files.readString` loads the **entire** log file into heap. `monitorProcess()` appends output continuously — after a chatty route runs long enough this will OOM the JVM. Use a capped read instead: ```java // Read at most 1 MB from the end of the file long fileSize = Files.size(logFile); long skip = Math.max(0, fileSize - 1_048_576); try (RandomAccessFile raf = new RandomAccessFile(logFile.toFile(), "r")) { raf.seek(skip); byte[] buf = new byte[(int) (fileSize - skip)]; raf.readFully(buf); String content = new String(buf, StandardCharsets.UTF_8); } ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
