Repository: api-ghost/api-ghost
Pull request #19: feat(parser): Create TemplateConvertor
Author: login: haazz
Labels: name: BETA-0.0.1
name: feature
Created: 2025-05-06T16:23:42Z | Merged: 2025-05-07T04:46:42Z

<!--
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

- #10 

### Description

- Add parser, model module depedency to core module
- Change validator package
- Change ScenarioValidator conditions
- Create TemplateConvertor
  - It change ${variable} to actual variable value
  

### Testing


### 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/core/build.gradle b/core/build.gradle
index 6991dcc..e325fa1 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -11,6 +11,7 @@ repositories {
 
 dependencies {
     implementation project(":model")
+    implementation project(":parser")
     testImplementation platform('org.junit:junit-bom:5.10.0')
     testImplementation 'org.junit.jupiter:junit-jupiter'
 
diff --git a/core/src/main/java/com/apighost/scenario/validator/ScenarioValidator.java b/core/src/main/java/com/apighost/validator/ScenarioValidator.java
similarity index 95%
rename from core/src/main/java/com/apighost/scenario/validator/ScenarioValidator.java
rename to core/src/main/java/com/apighost/validator/ScenarioValidator.java
index 05cc7cd..4408152 100644
--- a/core/src/main/java/com/apighost/scenario/validator/ScenarioValidator.java
+++ b/core/src/main/java/com/apighost/validator/ScenarioValidator.java
@@ -1,4 +1,4 @@
-package com.apighost.scenario.validator;
+package com.apighost.validator;
 
 import com.apighost.model.scenario.Scenario;
 import com.apighost.model.scenario.step.ProtocolType;
@@ -144,8 +144,7 @@ public static void validateRouteForExecution(LinkedHashMap<String, Step> steps,
 
             Then then = route.getThen();
             if (then == null || then.getStep() == null || then.getStep().isEmpty()) {
-                throw new IllegalArgumentException(
-                    "Then action in step '" + stepKey + "' must specify a next step.");
+                continue;
             }
             if (!steps.containsKey(then.getStep())) {
                 throw new IllegalArgumentException(
@@ -190,7 +189,7 @@ public static void validateNoRouteCycle(Scenario scenario, String startStepKey)
      * @param currentStepKey  the step key currently being visited
      * @throws IllegalArgumentException if a route cycle is detected
      */
-    public static void detectRouteCycle(LinkedHashMap<String, Step> steps,
+    private static void detectRouteCycle(LinkedHashMap<String, Step> steps,
         Set<String> visitedStepKeys, String currentStepKey) {
         if (visitedStepKeys.contains(currentStepKey)) {
             throw new IllegalArgumentException("Cycle detected involving step: " + currentStepKey);
@@ -207,8 +206,7 @@ public static void detectRouteCycle(LinkedHashMap<String, Step> steps,
         for (Route route : step.getRoute()) {
             Then then = route.getThen();
             if (then == null || then.getStep() == null || then.getStep().isEmpty()) {
-                throw new IllegalArgumentException(
-                    "Then action in step '" + currentStepKey + "' must specify a next step.");
+                return;
             }
 
             String nextStepKey = then.getStep();
diff --git a/core/src/test/java/core/validator/ScenarioValidatorTest.java b/core/src/test/java/core/validator/ScenarioValidatorTest.java
new file mode 100644
index 0000000..8e5047e
--- /dev/null
+++ b/core/src/test/java/core/validator/ScenarioValidatorTest.java
@@ -0,0 +1,182 @@
+package core.validator;
+
+import com.apighost.model.scenario.Scenario;
+import com.apighost.model.scenario.step.*;
+import com.apighost.model.scenario.request.*;
+import com.apighost.validator.ScenarioValidator;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for the {@link ScenarioValidator} class.
+ * <p>
+ * This test suite validates that scenarios, steps, requests, and routing logic are correctly
+ * validated before execution. It covers error handling, invalid configurations, and edge cases such
+ * as cycles.
+ * </p>
+ *
+ * @author haazz
+ * @version BETA-0.0.1
+ */
+class ScenarioValidatorTest {
+
+    /**
+     * Tests that passing a null scenario to the validator results in an
+     * {@link IllegalArgumentException}.
+     */
+    @Test
+    void testValidateScenarioForExecution_nullScenario_throwsException() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            ScenarioValidator.validateScenarioForExecution(null);
+        });
+    }
+
+    /**
+     * Tests that a scenario with no steps results in an {@link IllegalArgumentException}.
+     */
+    @Test
+    void testValidateScenarioForExecution_emptySteps_throwsException() {
+        Scenario scenario = new Scenario.Builder().steps(new LinkedHashMap<>()).build();
+        assertThrows(IllegalArgumentException.class, () -> {
+            ScenarioValidator.validateScenarioForExecution(scenario);
+        });
+    }
+
+    /**
+     * Tests that a step missing its request object fails validation.
+     */
+    @Test
+    void testValidateStepForExecution_missingRequest_throwsException() {
+        Step invalidStep = new Step.Builder().type(ProtocolType.HTTP).build();
+        LinkedHashMap<String, Step> steps = new LinkedHashMap<>();
+        steps.put("step1", invalidStep);
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            ScenarioValidator.validateStepForExecution(steps, steps.entrySet().iterator().next());
+        });
+    }
+
+    /**
+     * Tests that providing both JSON and FormData in a request body causes an
+     * {@link IllegalArgumentException} due to invalid configuration.
+     */
+    @Test
+    void testValidateRequestForExecution_bothJsonAndFormData_throwsException() {
+        RequestBody body = new RequestBody.Builder()
+            .json("{ \"key\": \"value\" }")
+            .formdata(new FormData.Builder()
+                .text(null)
+                .build())
+            .build();
+
+        Request request = new Request.Builder()
+            .method(HTTPMethod.POST)
+            .url("https://example.com")
+            .body(body)
+            .build();
+
+        Step step = new Step.Builder().type(ProtocolType.HTTP).build();
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            ScenarioValidator.validateRequestForExecution(step, "step1", request);
+        });
+    }
+
+    /**
+     * Tests that referencing a non-existent step in a route's `then` clause results in an
+     * {@link IllegalArgumentException}.
+     */
+    @Test
+    void testValidateRouteForExecution_thenStepNotInSteps_throwsException() {
+        LinkedHashMap<String, Step> steps = new LinkedHashMap<>();
+        steps.put("step1", new Step.Builder()
+            .type(ProtocolType.HTTP)
+            .request(new Request.Builder().method(HTTPMethod.GET).url("https://a.com").build())
+            .build());
+
+        Route route = new Route.Builder()
+            .expected(new Expected.Builder().status("200").build())
+            .then(new Then.Builder().step("nonExistentStep").build())
+            .build();
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            ScenarioValidator.validateRouteForExecution(steps, "step1", List.of(route));
+        });
+    }
+
+    /**
+     * Tests that a cyclic reference between steps via route `then` clauses is detected and results
+     * in an {@link IllegalArgumentException}.
+     */
+    @Test
+    void testValidateNoRouteCycle_cycleDetected_throwsException() {
+        Step stepA = new Step.Builder()
+            .type(ProtocolType.HTTP)
+            .request(new Request.Builder().method(HTTPMethod.GET).url("https://a.com").build())
+            .route(List.of(new Route.Builder()
+                .expected(new Expected.Builder().status("200").build())
+                .then(new Then.Builder().step("stepB").build())
+                .build()))
+            .build();
+
+        Step stepB = new Step.Builder()
+            .type(ProtocolType.HTTP)
+            .request(new Request.Builder().method(HTTPMethod.GET).url("https://b.com").build())
+            .route(List.of(new Route.Builder()
+                .expected(new Expected.Builder().status("200").build())
+                .then(new Then.Builder().step("stepA").build()) // cycle
+                .build()))
+            .build();
+
+        LinkedHashMap<String, Step> steps = new LinkedHashMap<>();
+        steps.put("stepA", stepA);
+        steps.put("stepB", stepB);
+
+        Scenario scenario = new Scenario.Builder()
+            .name("Cycle Scenario")
+            .steps(steps)
+            .build();
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            ScenarioValidator.validateNoRouteCycle(scenario, "stepA");
+        });
+    }
+
+    /**
+     * Tests that a scenario with valid routing and no cycles passes validation successfully.
+     */
+    @Test
+    void testValidateNoRouteCycle_noCycle_passes() {
+        Step stepA = new Step.Builder()
+            .type(ProtocolType.HTTP)
+            .request(new Request.Builder().method(HTTPMethod.GET).url("https://a.com").build())
+            .route(List.of(new Route.Builder()
+                .expected(new Expected.Builder().status("200").build())
+                .then(new Then.Builder().step("stepB").build())
+                .build()))
+            .build();
+
+        Step stepB = new Step.Builder()
+            .type(ProtocolType.HTTP)
+            .request(new Request.Builder().method(HTTPMethod.GET).url("https://b.com").build())
+            .route(List.of(new Route.Builder()
+                .expected(new Expected.Builder().status("200").build())
+                .then(new Then.Builder().step(null).build())
+                .build()))
+            .build();
+
+        LinkedHashMap<String, Step> steps = new LinkedHashMap<>();
+        steps.put("stepA", stepA);
+        steps.put("stepB", stepB);
+
+        Scenario scenario = new Scenario.Builder().steps(steps).build();
+
+        assertDoesNotThrow(() -> {
+            ScenarioValidator.validateNoRouteCycle(scenario, "stepA");
+        });
+    }
+}
diff --git a/parser/src/main/java/com/apighost/parser/template/TemplateConvertor.java b/parser/src/main/java/com/apighost/parser/template/TemplateConvertor.java
new file mode 100644
index 0000000..5ef1c1c
--- /dev/null
+++ b/parser/src/main/java/com/apighost/parser/template/TemplateConvertor.java
@@ -0,0 +1,52 @@
+package com.apighost.parser.template;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Replaces placeholders in the format ${variable} with actual values from a map.
+ * <p>
+ * Example: template: "Hello, ${name}!" variables: { "name" -> "Alice" } result: "Hello, Alice!"
+ *
+ * @author haazz
+ * @version 1.0
+ */
+public class TemplateConvertor {
+
+    private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{([^}]+)}");
+
+    /**
+     * Replaces all ${key} placeholders in the template with the corresponding value from the map.
+     *
+     * @param template  the input string containing placeholders
+     * @param variables a map containing variable names and their values
+     * @return the substituted string
+     */
+    public static String convert(String template, Map<String, Object> variables) {
+        if (template == null) {
+            return template;
+        }
+
+        Matcher matcher = VARIABLE_PATTERN.matcher(template);
+        StringBuilder result = new StringBuilder();
+
+        while (matcher.find()) {
+            String key = matcher.group(1);
+            String convertValue = "";
+
+            if (variables != null && variables.containsKey(key)) {
+                Object value = variables.get(key);
+                if (value instanceof String str) {
+                    convertValue = str.trim();
+                } else if (value instanceof Number) {
+                    convertValue = String.valueOf(value);
+                }
+            }
+            matcher.appendReplacement(result, Matcher.quoteReplacement(convertValue));
+        }
+
+        matcher.appendTail(result);
+        return result.toString();
+    }
+}


Reviews:
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:00:11Z
user: login: cod0216
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T17:51:04Z
user: login: haerim-kweon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T18:40:27Z
user: login: haazz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T04:41:45Z
user: login: haazz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T04:45:42Z
user: login: haazz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T04:46:18Z

Review comments:
diff_hunk: @@ -0,0 +1,52 @@
+package com.apighost.parser.template;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Replaces placeholders in the format ${variable} with actual values from a map.
+ * <p>
+ * Example: template: "Hello, ${name}!" variables: { "name" -> "Alice" } result: "Hello, Alice!"
+ *
+ * @author haazz
+ * @version 1.0
+ */
+public class TemplateConvertor {
path: parser/src/main/java/com/apighost/parser/template/TemplateConvertor.java
user: login: kobenlys
body: Thank you for developing TemplateConverter! 👍
created_at: 2025-05-06T17:00:11Z
author_association: COLLABORATOR
diff_hunk: @@ -207,8 +206,7 @@ public static void detectRouteCycle(LinkedHashMap<String, Step> steps,
         for (Route route : step.getRoute()) {
             Then then = route.getThen();
             if (then == null || then.getStep() == null || then.getStep().isEmpty()) {
-                throw new IllegalArgumentException(
-                    "Then action in step '" + currentStepKey + "' must specify a next step.");
+                return;
path: core/src/main/java/com/apighost/validator/ScenarioValidator.java
user: login: cod0216
body: I've heard that using return instead of throwing an error is better for performance — great job!
created_at: 2025-05-06T17:48:28Z
author_association: MEMBER
diff_hunk: @@ -190,7 +189,7 @@ public static void validateNoRouteCycle(Scenario scenario, String startStepKey)
      * @param currentStepKey  the step key currently being visited
      * @throws IllegalArgumentException if a route cycle is detected
      */
-    public static void detectRouteCycle(LinkedHashMap<String, Step> steps,
+    private static void detectRouteCycle(LinkedHashMap<String, Step> steps,
path: core/src/main/java/com/apighost/validator/ScenarioValidator.java
user: login: haerim-kweon
body: This seems like a good change.
created_at: 2025-05-06T18:26:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,52 @@
+package com.apighost.parser.template;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Replaces placeholders in the format ${variable} with actual values from a map.
+ * <p>
+ * Example: template: "Hello, ${name}!" variables: { "name" -> "Alice" } result: "Hello, Alice!"
+ *
+ * @author haazz
+ * @version 1.0
+ */
+public class TemplateConvertor {
path: parser/src/main/java/com/apighost/parser/template/TemplateConvertor.java
user: login: haazz
body: You're welcome~! 😉😉
created_at: 2025-05-07T04:41:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -207,8 +206,7 @@ public static void detectRouteCycle(LinkedHashMap<String, Step> steps,
         for (Route route : step.getRoute()) {
             Then then = route.getThen();
             if (then == null || then.getStep() == null || then.getStep().isEmpty()) {
-                throw new IllegalArgumentException(
-                    "Then action in step '" + currentStepKey + "' must specify a next step.");
+                return;
path: core/src/main/java/com/apighost/validator/ScenarioValidator.java
user: login: haazz
body: Thank you for your comments.
But, I prioritized ensuring the scenario executes even when the value is null, rather than focusing on performance.
created_at: 2025-05-07T04:45:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -190,7 +189,7 @@ public static void validateNoRouteCycle(Scenario scenario, String startStepKey)
      * @param currentStepKey  the step key currently being visited
      * @throws IllegalArgumentException if a route cycle is detected
      */
-    public static void detectRouteCycle(LinkedHashMap<String, Step> steps,
+    private static void detectRouteCycle(LinkedHashMap<String, Step> steps,
path: core/src/main/java/com/apighost/validator/ScenarioValidator.java
user: login: haazz
body: I think that too.
Thank you!
created_at: 2025-05-07T04:46:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: cod0216
created_at: 2025-05-06T17:51:53Z
body: To @haazz 
Thank you so much for working hard late into the night!👍👍👍
author_association: MEMBER
user: login: sung-jun98
created_at: 2025-05-07T00:09:52Z
body: I was only thinking about simply parsing YAML or JSON files, and I hadn’t considered the process of validating whether users who type out the content actually wrote it correctly. That’s really impressive—especially the part where you check if both formData and JSON were used at the same time. That’s sharp and well thought out. You’re amazing, hazz!💕
author_association: COLLABORATOR
user: login: haazz
created_at: 2025-05-07T04:46:38Z
body: > I was only thinking about simply parsing YAML or JSON files, and I hadn’t considered the process of validating whether users who type out the content actually wrote it correctly. That’s really impressive—especially the part where you check if both formData and JSON were used at the same time. That’s sharp and well thought out. You’re amazing, hazz!💕

Thank you so much!! 💕💕
author_association: CONTRIBUTOR