Repository: api-ghost/api-ghost
Pull request #51: feat(cli): loadtest command
Author: login: sung-jun98
Labels: name: BETA-0.0.1
name: feature
Created: 2025-05-15T15:04:22Z | Merged: 2025-05-17T14:28:25Z

<!--
Thank you for contributing to the API GHOST project.
This document provides guidelines to ensure smooth collaboration and maintain high code quality.
-->

<!--
PR Title Format Guideline

Use the following format for your PR title:

    type(scope): concise description

Examples:
    feat(api): add scenario execution support
    fix(parser): resolve YAML parsing error
    docs(readme): update CLI usage instructions

Available types:
    feat:     Add a new feature
    fix:      Fix a bug
    build:    Modify build system or external dependencies
    chore:    Modify configuration files unrelated to source or test files (e.g., .gitignore, .editorconfig)
    ci:       Update CI configuration files and scripts
    test:     Add or update tests
    docs:     Update documentation (e.g., README)
    refactor: Code changes that neither fix a bug nor add a feature
    style:    modify CSS styles
-->

### Related Issue

#44 

### Description

- This PR includes implementations related to the CLI commands for Loadtest.
- The build.gradle file, CliUtil, and FileType have been modified or added to support the Loadtest commands.
- You can use the following commands to edit or read YAML files related to Loadtest parameters.

 `apighost edit-load fileName.yaml` <br> 
`apighost edit-load --create fileName.yaml` <br>
 `apighost edit-load -c fileName.yaml`

- In addition, the following command allows you to retrieve a list of available Loadtest parameter files.<br>
`apighost ls loadtest` 

### Testing
![image](https://github.com/user-attachments/assets/dd27b6c8-45c1-476d-966a-854d2ae5fe45)
<br>
![image](https://github.com/user-attachments/assets/f988a284-7ede-43ff-bbdd-929cde75a89d)


### Additional Notes


### Pre-Submission Checklist

- [x] Have you completed code style (convention) checks locally?
- [x] Have you passed the CI tests in your local environment?



Diff:
diff --git a/cli/build.gradle b/cli/build.gradle
index e79af0f..002cda3 100644
--- a/cli/build.gradle
+++ b/cli/build.gradle
@@ -23,6 +23,7 @@ dependencies {
     implementation project(':model')
     implementation project(':parser')
     implementation project(':web')
+    implementation project(':util')
 }
 
 shadowJar {
diff --git a/cli/src/main/java/com/apighost/cli/ApiGhostCli.java b/cli/src/main/java/com/apighost/cli/ApiGhostCli.java
index 96b98bd..8add79f 100644
--- a/cli/src/main/java/com/apighost/cli/ApiGhostCli.java
+++ b/cli/src/main/java/com/apighost/cli/ApiGhostCli.java
@@ -3,6 +3,7 @@
 
 import com.apighost.cli.command.ExecuteScenairoCommand;
 import com.apighost.cli.command.ShowGuiCommand;
+import com.apighost.cli.command.ViewLoadTestParamCommand;
 import com.apighost.cli.command.ViewResultCommand;
 import com.apighost.cli.command.ViewScenarioCommand;
 import com.apighost.cli.command.ViewScenarioListCommand;
@@ -27,7 +28,8 @@
         ViewScenarioCommand.class,
         ShowGuiCommand.class,
         ViewResultCommand.class,
-        ExecuteScenairoCommand.class
+        ExecuteScenairoCommand.class,
+        ViewLoadTestParamCommand.class
     }
 )
 public class ApiGhostCli implements Runnable {
diff --git a/cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamCommand.java b/cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamCommand.java
new file mode 100644
index 0000000..c46afaa
--- /dev/null
+++ b/cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamCommand.java
@@ -0,0 +1,78 @@
+package com.apighost.cli.command;
+
+import com.apighost.cli.util.ConsoleOutput;
+import com.apighost.util.file.BasePathHolder;
+import com.apighost.util.file.FileType;
+import com.apighost.util.file.FileUtil;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+import com.apighost.cli.util.CliUtil;
+
+/**
+ * Command implementation class that modifies and generates a subway test setting file
+ *
+ * <p>
+ * `apighost edit-load fileName.yaml` <br> `apighost edit-load --create fileName.yaml` <br>
+ * `apighost edit-load -c fileName.yaml`
+ * </p>
+ *
+ * @author sung-jun98
+ * @version BETA-0.0.1
+ */
+@Command(
+    name = "edit-load",
+    description = "Open a YAML load test parameter file in vi editor",
+    mixinStandardHelpOptions = true
+)
+public class ViewLoadTestParamCommand implements Callable<Integer> {
+
+    @Parameters(
+        index = "0",
+        description = "The name of the load test parameter YAML file to edit",
+        arity = "1"
+    )
+    private String fileName;
+
+    @Option(names = {"-c", "--create"}, description = "Create a new file if none exists")
+    private boolean createIfNone;
+
+    /**
+     * Opens the specified YAML file in vi editor(Linux, mac) or notePad(window)
+     *
+     * @return Integer If 0 is success, 1 if it fails
+     * @throws Exception
+     */
+    @Override
+    public Integer call() throws Exception {
+        CliUtil cliUtil = new CliUtil();
+
+        Path loadTestDirectory = FileUtil.findDirectory(FileType.LOADTEST,
+            BasePathHolder.getInstance().getBasePath());
+
+        Path filePath = loadTestDirectory.resolve(fileName);
+        boolean fileExists = Files.exists(filePath) && Files.isRegularFile(filePath);
+
+        if (!fileExists) {
+            if (createIfNone) {
+                Path newPath = Files.createFile(filePath);
+                cliUtil.openInEditor(newPath);
+            }
+
+            if (!createIfNone) {
+                ConsoleOutput.printError(
+                    "File not found.\n Please use --create option to create a new file");
+            }
+        }
+
+        if (fileExists) {
+            cliUtil.openInEditor(filePath);
+            return 0;
+        }
+
+        return 0;
+    }
+}
diff --git a/cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamListCommand.java b/cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamListCommand.java
new file mode 100644
index 0000000..163ebf6
--- /dev/null
+++ b/cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamListCommand.java
@@ -0,0 +1,62 @@
+package com.apighost.cli.command;
+
+import com.apighost.cli.util.ConsoleOutput;
+import com.apighost.util.file.BasePathHolder;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import com.apighost.util.file.FileType;
+import com.apighost.util.file.FileUtil;
+
+/**
+ * CLI command implementation class to search and output a subway test configuration file inside the
+ * computer
+ * <p>usage example : `apighost ls loadtest` </p>
+ *
+ * @author sung-jun98
+ * @version BETA-0.0.1
+ */
+@Command(
+    name = "loadtest",
+    description = "View LoadTest Parameter YAML file content",
+    mixinStandardHelpOptions = true
+)
+public class ViewLoadTestParamListCommand implements Callable<Integer> {
+
+    /**
+     * 1. Find a `.apighost/loadtest` directory <br> 2. If there is no directory, it is created <br>
+     * 3. If the inside of the directory is empty, the error message output <br> 4. If not empty,
+     * the internal file list output <br>
+     *
+     * @return return code
+     * @throws RuntimeException
+     * @throws IOException
+     */
+    @Override
+    public Integer call() throws RuntimeException, IOException {
+
+        Path loadTestDirectory = FileUtil.findDirectory(FileType.LOADTEST,
+            BasePathHolder.getInstance().getBasePath());
+
+        long fileCount = Files.list(loadTestDirectory).count();
+
+        if (fileCount == 0) {
+            ConsoleOutput.printError("No Load Test files found.");
+            ConsoleOutput.print(
+                "Use `apighost edit --create-loadtest` command to create a new file");
+
+            return 0;
+        }
+
+        try {
+            Files.walk(loadTestDirectory)
+                .filter(path -> Files.isRegularFile(path))
+                .forEach(path -> ConsoleOutput.print(path.getFileName().toString()));
+        } catch (IOException e) {
+            throw new RuntimeException("Error occurs during file list check", e);
+        }
+        return 0;
+    }
+}
diff --git a/cli/src/main/java/com/apighost/cli/command/ViewScenarioListCommand.java b/cli/src/main/java/com/apighost/cli/command/ViewScenarioListCommand.java
index f778196..3eb514f 100644
--- a/cli/src/main/java/com/apighost/cli/command/ViewScenarioListCommand.java
+++ b/cli/src/main/java/com/apighost/cli/command/ViewScenarioListCommand.java
@@ -19,7 +19,10 @@
     name = "ls",
     description = "View YAML file content",
     mixinStandardHelpOptions = true,
-    subcommands = {ViewResultListCommand.class}
+    subcommands = {
+        ViewResultListCommand.class,
+        ViewLoadTestParamListCommand.class
+    }
 )
 public class ViewScenarioListCommand implements Callable<Integer> {
 
@@ -37,8 +40,8 @@ public Integer call() throws Exception {
 
             /** List files with .yaml or .yml extensions only */
             File[] files = targetDir.listFiles((dir, name) ->
-                                                   name.toLowerCase().endsWith(".yaml") ||
-                                                       name.toLowerCase().endsWith(".yml")
+                name.toLowerCase().endsWith(".yaml") ||
+                    name.toLowerCase().endsWith(".yml")
             );
 
             /**
diff --git a/cli/src/main/java/com/apighost/cli/util/CliUtil.java b/cli/src/main/java/com/apighost/cli/util/CliUtil.java
new file mode 100644
index 0000000..cf6b8fc
--- /dev/null
+++ b/cli/src/main/java/com/apighost/cli/util/CliUtil.java
@@ -0,0 +1,56 @@
+package com.apighost.cli.util;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * A class that collects utility -related methods used only inside the CLI module. Things used in
+ * two or more modules are missed into a common UTIL module. *
+ *
+ * @author sung-jun98
+ * @version BETA-0.0.1
+ */
+public class CliUtil {
+
+    /**
+     * Opens the given file in the default text editor based on the operating system. For Windows,
+     * it uses Notepad. For Unix/Linux/Mac systems, it uses Vi. Returns the exit code of the editor
+     * process.
+     *
+     * @param path The file to be opened in the editor.
+     * @return An integer representing the editor process's exit code (0 for success, non-zero for
+     * error).
+     * @throws InterruptedException If there is an issue starting the editor process or accessing
+     *                              the file.
+     */
+    public int openInEditor(Path path) throws InterruptedException, IOException {
+
+        try {
+            /** Determine the operating system to choose the right command */
+            String os = System.getProperty("os.name").toLowerCase();
+            ProcessBuilder processBuilder;
+
+            if (os.contains("win")) {
+                processBuilder = new ProcessBuilder("notepad.exe",
+                    path.toAbsolutePath().toString());
+            } else {
+                processBuilder = new ProcessBuilder("vi", path.toAbsolutePath().toString());
+            }
+
+            processBuilder.redirectErrorStream(true);
+            Process process = processBuilder.start();
+            int exitCode = process.waitFor();
+
+            if (exitCode != 0) {
+                System.out.println("Editor exited with code: " + exitCode);
+                return exitCode;
+            }
+
+            return 0;
+        } catch (InterruptedException | IOException e) {
+            System.err.println("Error opening file in editor: " + e.getMessage());
+            e.printStackTrace();
+            return 1;
+        }
+    }
+}
diff --git a/util/src/main/java/com/apighost/util/file/FileType.java b/util/src/main/java/com/apighost/util/file/FileType.java
index 7b04635..f885c81 100644
--- a/util/src/main/java/com/apighost/util/file/FileType.java
+++ b/util/src/main/java/com/apighost/util/file/FileType.java
@@ -11,5 +11,6 @@ public enum FileType {
     SCENARIO,
     RESULT,
     CONFIG,
+    LOADTEST,
     RESOURCES
 }


Reviews:
user: login: haerim-kweon
body: Thank you for your hard work!!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T02:34:20Z
user: login: haerim-kweon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T03:27:58Z
user: login: sung-jun98
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T12:41:37Z

Review comments:
diff_hunk: @@ -0,0 +1,78 @@
+package com.apighost.cli.command;
+
+import com.apighost.cli.util.ConsoleOutput;
+import com.apighost.util.file.BasePathHolder;
+import com.apighost.util.file.FileType;
+import com.apighost.util.file.FileUtil;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+import com.apighost.cli.util.CliUtil;
+
+/**
+ * Command implementation class that modifies and generates a subway test setting file
+ *
+ * <p>
+ * `apighost edit-load fileName.yaml` <br> `apighost edit-load --create fileName.yaml` <br>
+ * `apighost edit-load -c fileName.yaml`
+ * </p>
+ *
+ * @author sung-jun98
+ * @version BETA-0.0.1
+ */
+@Command(
+    name = "edit-load",
+    description = "Open a YAML load test parameter file in vi editor",
+    mixinStandardHelpOptions = true
+)
+public class ViewLoadTestParamCommand implements Callable<Integer> {
+
+    @Parameters(
+        index = "0",
+        description = "The name of the load test parameter YAML file to edit",
+        arity = "1"
+    )
+    private String fileName;
+
+    @Option(names = {"-c", "--create"}, description = "Create a new file if none exists")
+    private boolean createIfNone;
+
+    /**
+     * Opens the specified YAML file in vi editor(Linux, mac) or notePad(window)
+     *
+     * @return Integer If 0 is success, 1 if it fails
+     * @throws Exception
+     */
+    @Override
+    public Integer call() throws Exception {
+        CliUtil cliUtil = new CliUtil();
+
+        Path loadTestDirectory = FileUtil.findDirectory(FileType.LOADTEST,
+            BasePathHolder.getInstance().getBasePath());
+
+        Path filePath = loadTestDirectory.resolve(fileName);
+        boolean fileExists = Files.exists(filePath) && Files.isRegularFile(filePath);
+
+        if (!fileExists) {
+            if (createIfNone) {
+                Path newPath = Files.createFile(filePath);
+                cliUtil.openInEditor(newPath);
+            }
+
+            if (!createIfNone) {
+                ConsoleOutput.printError(
path: cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamCommand.java
user: login: haerim-kweon
body: How about using if-else statements?
created_at: 2025-05-16T02:30:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,62 @@
+package com.apighost.cli.command;
+
+import com.apighost.cli.util.ConsoleOutput;
+import com.apighost.util.file.BasePathHolder;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import com.apighost.util.file.FileType;
+import com.apighost.util.file.FileUtil;
+
+/**
+ * CLI command implementation class to search and output a subway test configuration file inside the
+ * computer
+ * <p>usage example : `apighost ls loadtest` </p>
+ *
+ * @author sung-jun98
+ * @version BETA-0.0.1
+ */
+@Command(
+    name = "loadtest",
+    description = "View LoadTest Parameter YAML file content",
+    mixinStandardHelpOptions = true
+)
+public class ViewLoadTestParamListCommand implements Callable<Integer> {
+
+    /**
+     * 1. Find a `.apighost/loadtest` directory <br> 2. If there is no directory, it is created <br>
+     * 3. If the inside of the directory is empty, the error message output <br> 4. If not empty,
+     * the internal file list output <br>
+     *
+     * @return return code
+     * @throws RuntimeException
+     * @throws IOException
+     */
+    @Override
+    public Integer call() throws RuntimeException, IOException {
path: cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamListCommand.java
user: login: haerim-kweon
body: The class catches IOException with a try-catch statement, but I'm wondering if there's a reason to throw it again.
created_at: 2025-05-16T02:32:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,56 @@
+package com.apighost.cli.util;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * A class that collects utility -related methods used only inside the CLI module. Things used in
+ * two or more modules are missed into a common UTIL module. *
+ *
+ * @author sung-jun98
+ * @version BETA-0.0.1
+ */
+public class CliUtil {
+
+    /**
+     * Opens the given file in the default text editor based on the operating system. For Windows,
+     * it uses Notepad. For Unix/Linux/Mac systems, it uses Vi. Returns the exit code of the editor
+     * process.
+     *
+     * @param path The file to be opened in the editor.
+     * @return An integer representing the editor process's exit code (0 for success, non-zero for
+     * error).
+     * @throws InterruptedException If there is an issue starting the editor process or accessing
+     *                              the file.
+     */
+    public int openInEditor(Path path) throws InterruptedException, IOException {
+
+        try {
path: cli/src/main/java/com/apighost/cli/util/CliUtil.java
user: login: haerim-kweon
body: 
The class in question catches IOException with a try-catch statement, but I'm wondering if there's a reason to throw it again.
created_at: 2025-05-16T03:27:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+package com.apighost.cli.command;
+
+import com.apighost.cli.util.ConsoleOutput;
+import com.apighost.util.file.BasePathHolder;
+import com.apighost.util.file.FileType;
+import com.apighost.util.file.FileUtil;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+import com.apighost.cli.util.CliUtil;
+
+/**
+ * Command implementation class that modifies and generates a subway test setting file
+ *
+ * <p>
+ * `apighost edit-load fileName.yaml` <br> `apighost edit-load --create fileName.yaml` <br>
+ * `apighost edit-load -c fileName.yaml`
+ * </p>
+ *
+ * @author sung-jun98
+ * @version BETA-0.0.1
+ */
+@Command(
+    name = "edit-load",
+    description = "Open a YAML load test parameter file in vi editor",
+    mixinStandardHelpOptions = true
+)
+public class ViewLoadTestParamCommand implements Callable<Integer> {
+
+    @Parameters(
+        index = "0",
+        description = "The name of the load test parameter YAML file to edit",
+        arity = "1"
+    )
+    private String fileName;
+
+    @Option(names = {"-c", "--create"}, description = "Create a new file if none exists")
+    private boolean createIfNone;
+
+    /**
+     * Opens the specified YAML file in vi editor(Linux, mac) or notePad(window)
+     *
+     * @return Integer If 0 is success, 1 if it fails
+     * @throws Exception
+     */
+    @Override
+    public Integer call() throws Exception {
+        CliUtil cliUtil = new CliUtil();
+
+        Path loadTestDirectory = FileUtil.findDirectory(FileType.LOADTEST,
+            BasePathHolder.getInstance().getBasePath());
+
+        Path filePath = loadTestDirectory.resolve(fileName);
+        boolean fileExists = Files.exists(filePath) && Files.isRegularFile(filePath);
+
+        if (!fileExists) {
+            if (createIfNone) {
+                Path newPath = Files.createFile(filePath);
+                cliUtil.openInEditor(newPath);
+            }
+
+            if (!createIfNone) {
+                ConsoleOutput.printError(
path: cli/src/main/java/com/apighost/cli/command/ViewLoadTestParamCommand.java
user: login: sung-jun98
body: That sounds much more intuitive! Thank you!!🐋
created_at: 2025-05-16T12:41:37Z
author_association: COLLABORATOR

PR comments:
user: login: cod0216
created_at: 2025-05-16T03:29:21Z
body: To. @sung-jun98 
I read through everything carefully—great job, and thank you for your hard work!
author_association: MEMBER
user: login: sung-jun98
created_at: 2025-05-16T12:43:44Z
body: @haerim-kweon To be honest, I'm not very familiar with exception handling. But since oneweeeek submitted a PR related to exception handling, I'll study it and incorporate the ideas while learning from the code. Thank you!😂
author_association: COLLABORATOR
user: login: sung-jun98
created_at: 2025-05-16T12:44:30Z
body: > To. @sung-jun98 I read through everything carefully—great job, and thank you for your hard work!

Thank you very much! Your support means a lot to me.🙂👍
author_association: COLLABORATOR