Repository: api-ghost/api-ghost-agent-spring
Pull request #10: feat(core): implementing collecting requestSchema and convert to json
Author: login: oneweeeek
Labels: name: 0.0.1 - BETA
name: feature
description: PR - Feature Implement
Created: 2025-05-04T08:42:46Z | Merged: 2025-05-06T04:40:10Z

<!--  
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

- #5 

### Description

- Analyze Spring controller methods to collect DTOs annotated with @RequestBody and @ModelAttribute
- Implement recursive JSON conversion logic using Jackson and ClassGraph to analyze DTO structures
- Add support for nested objects and collection types
- Prevent infinite recursion by tracking visited classes with a visited set


### Testing
![image](https://github.com/user-attachments/assets/fff466ad-f990-4b97-aeec-a590c9323bd0)
![image](https://github.com/user-attachments/assets/17d30b52-df8e-47b5-aa8f-23f98bf3e5cb)

- Manually verify that JSON is generated normally even with cyclic references or complex types
- Further test cases will be added in the future
### 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/src/main/java/com/apighost/agent/collector/ApiCollector.java b/core/src/main/java/com/apighost/agent/collector/ApiCollector.java
index 5739bc9..4a049de 100644
--- a/core/src/main/java/com/apighost/agent/collector/ApiCollector.java
+++ b/core/src/main/java/com/apighost/agent/collector/ApiCollector.java
@@ -1,6 +1,17 @@
 package com.apighost.agent.collector;
 
+import com.apighost.agent.model.DtoSchema;
+import com.apighost.agent.model.FieldMeta;
+import io.github.classgraph.MethodParameterInfo;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 
 import com.apighost.agent.model.EndPoint;
@@ -11,174 +22,352 @@
 import io.github.classgraph.ClassInfo;
 import io.github.classgraph.MethodInfo;
 import io.github.classgraph.ScanResult;
+import java.util.Set;
 
 public class ApiCollector {
 
-  private final String basePackage;
-  private final List<EndPoint> endPointList = new ArrayList<>();
+    private final String basePackage;
+    private final List<EndPoint> endPointList = new ArrayList<>();
+    private ClassLoader classLoader;
 
-  public ApiCollector(String basePackage) {
-    this.basePackage = basePackage;
-  }
+    public ApiCollector(String basePackage) {
+        this.basePackage = basePackage;
+    }
+
+    public List<EndPoint> getEndPointList() {
+        return endPointList;
+    }
+
+    public void scan() {
+
+        ClassGraph classGraph = new ClassGraph().enableAllInfo();
+
+        if (basePackage != null && !basePackage.isEmpty()) {
+            classGraph.acceptPackages(basePackage);
+        }
+
+        try (ScanResult scanResult = classGraph.scan()) {
 
-  public List<EndPoint> getEndPointList() {
-    return endPointList;
-  }
+            this.classLoader = scanResult.getClass().getClassLoader();
+
+            for (ClassInfo classInfo : scanResult.getClassesWithAnnotation(
+                "org.springframework.web.bind.annotation.RestController")) {
+
+                AnnotationInfoList classAnnotations = classInfo.getAnnotationInfo();
+
+                String classPath = "";
+                List<String> classProduces = Collections.emptyList();
+                List<String> classConsumes = Collections.emptyList();
+
+                AnnotationInfo classRequestMapping = classAnnotations.get(
+                    "org.springframework.web.bind.annotation.RequestMapping");
+
+                if (classRequestMapping != null) {
+                    classPath = extractPath(classRequestMapping);
+                    classProduces = extractStringArray(classRequestMapping, "produces");
+                    classConsumes = extractStringArray(classRequestMapping, "consumes");
+                }
+                classPath = formatPath(classPath);
+
+                for (MethodInfo methodInfo : classInfo.getDeclaredMethodInfo()) {
+                    EndPoint endPoint = toEndpoint(methodInfo, classPath, classProduces,
+                        classConsumes);
+                    if (endPoint != null) {
+                        endPointList.add(endPoint);
+                    }
+                }
+            }
+        }
+    }
 
-  public void scan() {
-    try (ScanResult scanResult =
-        new ClassGraph().enableAllInfo().acceptPackages(basePackage).scan()) {
+    private EndPoint toEndpoint(MethodInfo methodInfo, String classPath, List<String> classProduces,
+        List<String> classConsumes) {
 
-      for (ClassInfo classInfo :
-          scanResult.getClassesWithAnnotation(
-              "org.springframework.web.bind.annotation.RestController")) {
-        String classPath = "";
-        AnnotationInfoList classAnnotations = classInfo.getAnnotationInfo();
+        AnnotationInfoList methodAnnotations = methodInfo.getAnnotationInfo();
 
-        if (classAnnotations.containsName(
+        String httpMethod = "";
+        String path = "";
+        List<String> produces = Collections.emptyList();
+        List<String> consumes = Collections.emptyList();
+
+        AnnotationInfo annotationInfo = null;
+
+        if (methodAnnotations.containsName("org.springframework.web.bind.annotation.GetMapping")) {
+            annotationInfo = methodAnnotations.get(
+                "org.springframework.web.bind.annotation.GetMapping");
+            httpMethod = "GET";
+            path = extractPath(annotationInfo);
+            produces = resolveConsumesOrProduces(classProduces, annotationInfo, "produces");
+            consumes = resolveConsumesOrProduces(classConsumes, annotationInfo, "consumes");
+        } else if (methodAnnotations.containsName(
+            "org.springframework.web.bind.annotation.PostMapping")) {
+            annotationInfo = methodAnnotations.get(
+                "org.springframework.web.bind.annotation.PostMapping");
+            httpMethod = "POST";
+            path = extractPath(annotationInfo);
+            produces = resolveConsumesOrProduces(classProduces, annotationInfo, "produces");
+            consumes = resolveConsumesOrProduces(classConsumes, annotationInfo, "consumes");
+        } else if (methodAnnotations.containsName(
+            "org.springframework.web.bind.annotation.PutMapping")) {
+            annotationInfo = methodAnnotations.get(
+                "org.springframework.web.bind.annotation.PutMapping");
+            httpMethod = "PUT";
+            path = extractPath(annotationInfo);
+            produces = resolveConsumesOrProduces(classProduces, annotationInfo, "produces");
+            consumes = resolveConsumesOrProduces(classConsumes, annotationInfo, "consumes");
+        } else if (methodAnnotations.containsName(
+            "org.springframework.web.bind.annotation.PatchMapping")) {
+            annotationInfo = methodAnnotations.get(
+                "org.springframework.web.bind.annotation.PatchMapping");
+            httpMethod = "PATCH";
+            path = extractPath(annotationInfo);
+            produces = resolveConsumesOrProduces(classProduces, annotationInfo, "produces");
+            consumes = resolveConsumesOrProduces(classConsumes, annotationInfo, "consumes");
+        } else if (methodAnnotations.containsName(
+            "org.springframework.web.bind.annotation.DeleteMapping")) {
+            annotationInfo = methodAnnotations.get(
+                "org.springframework.web.bind.annotation.DeleteMapping");
+            httpMethod = "DELETE";
+            path = extractPath(annotationInfo);
+            produces = resolveConsumesOrProduces(classProduces, annotationInfo, "produces");
+            consumes = resolveConsumesOrProduces(classConsumes, annotationInfo, "consumes");
+        } else if (methodAnnotations.containsName(
             "org.springframework.web.bind.annotation.RequestMapping")) {
-          classPath =
-              extractPath(
-                  classAnnotations.get("org.springframework.web.bind.annotation.RequestMapping"));
+            annotationInfo = methodAnnotations.get(
+                "org.springframework.web.bind.annotation.RequestMapping");
+            path = extractPath(annotationInfo);
+            httpMethod = extractHttpMethod(annotationInfo);
+            produces = resolveConsumesOrProduces(classProduces, annotationInfo, "produces");
+            consumes = resolveConsumesOrProduces(classConsumes, annotationInfo, "consumes");
         }
-        classPath = formatPath(classPath);
 
-        for (MethodInfo methodInfo : classInfo.getDeclaredMethodInfo()) {
-          EndPoint endPoint = toEndpoint(methodInfo, classPath);
-          if (endPoint != null) {
-            endPointList.add(endPoint);
-          }
+        if (httpMethod.isEmpty() || path == null) {
+            return null;
         }
-      }
+
+        String fullPath = classPath + formatPath(path);
+        DtoSchema dtoSchema = extractDtoSchema(methodInfo, httpMethod);
+
+        return new EndPoint(httpMethod, fullPath, produces, consumes, dtoSchema);
     }
-  }
-
-  private EndPoint toEndpoint(MethodInfo methodInfo, String classPath) {
-
-    AnnotationInfoList methodAnnotations = methodInfo.getAnnotationInfo();
-
-    String httpMethod = "";
-    String path = "";
-
-    if (methodAnnotations.containsName("org.springframework.web.bind.annotation.GetMapping")) {
-      httpMethod = "GET";
-      path =
-          extractPath(methodAnnotations.get("org.springframework.web.bind.annotation.GetMapping"));
-    } else if (methodAnnotations.containsName(
-        "org.springframework.web.bind.annotation.PostMapping")) {
-      httpMethod = "POST";
-      path =
-          extractPath(methodAnnotations.get("org.springframework.web.bind.annotation.PostMapping"));
-    } else if (methodAnnotations.containsName(
-        "org.springframework.web.bind.annotation.PutMapping")) {
-      httpMethod = "PUT";
-      path =
-          extractPath(methodAnnotations.get("org.springframework.web.bind.annotation.PutMapping"));
-    } else if (methodAnnotations.containsName(
-        "org.springframework.web.bind.annotation.PatchMapping")) {
-      httpMethod = "PATCH";
-      path =
-          extractPath(
-              methodAnnotations.get("org.springframework.web.bind.annotation.PatchMapping"));
-    } else if (methodAnnotations.containsName(
-        "org.springframework.web.bind.annotation.DeleteMapping")) {
-      httpMethod = "DELETE";
-      path =
-          extractPath(
-              methodAnnotations.get("org.springframework.web.bind.annotation.DeleteMapping"));
-    } else if (methodAnnotations.containsName(
-        "org.springframework.web.bind.annotation.RequestMapping")) {
-      path =
-          extractPath(
-              methodAnnotations.get("org.springframework.web.bind.annotation.RequestMapping"));
-      httpMethod =
-          extractHttpMethod(
-              methodAnnotations.get("org.springframework.web.bind.annotation.RequestMapping"));
+
+    private String extractPath(AnnotationInfo annotationInfo) {
+        if (annotationInfo == null) {
+            return "";
+        }
+
+        Object value = annotationInfo.getParameterValues().getValue("value");
+        if (value == null) {
+            value = annotationInfo.getParameterValues().getValue("path");
+        }
+
+        if (value instanceof String) {
+            return (String) value;
+        } else if (value instanceof String[] && ((String[]) value).length > 0) {
+            return ((String[]) value)[0];
+        }
+
+        return "";
     }
 
-    if (httpMethod.isEmpty() || path == null) {
-      return null;
+    private String extractHttpMethod(AnnotationInfo annotationInfo) {
+        if (annotationInfo == null) {
+            return "GET";
+        }
+
+        Object methodAttr = annotationInfo.getParameterValues().getValue("method");
+        if (methodAttr == null) {
+            return "GET";
+        }
+        if (methodAttr.getClass().isArray()) {
+            Object[] methods = (Object[]) methodAttr;
+            if (methods.length > 0) {
+                String methodStr = methods[0].toString();
+                if (methodStr.contains(".")) {
+                    String[] parts = methodStr.split("\\.");
+                    return parts[parts.length - 1];
+                }
+                return methodStr;
+            }
+        } else {
+            String methodStr = methodAttr.toString();
+            if (methodStr.contains(".")) {
+                String[] parts = methodStr.split("\\.");
+                return parts[parts.length - 1];
+            }
+            return methodStr;
+        }
+
+        return "GET";
     }
 
-    String fullPath = classPath + formatPath(path);
+    private String formatPath(String path) {
+        if (path == null || path.isEmpty()) {
+            return "";
+        }
+        if (!path.startsWith("/")) {
+            path = "/" + path;
+        }
+        if (path.endsWith("/")) {
+            path = path.substring(0, path.length() - 1);
+        }
+        return path;
+    }
 
-    return new EndPoint(httpMethod, fullPath,null, null, null);
-  }
+    private List<String> extractStringArray(AnnotationInfo annotationInfo, String key) {
+        if (annotationInfo == null) {
+            return Collections.emptyList();
+        }
 
-  private String extractPath(AnnotationInfo annotationInfo) {
-    if (annotationInfo == null) return "";
+        Object value = annotationInfo.getParameterValues().getValue(key);
+        if (value instanceof String) {
+            return Collections.singletonList((String) value);
+        } else if (value instanceof String[]) {
+            String[] array = (String[]) value;
+            List<String> result = new ArrayList<>();
+            Collections.addAll(result, array);
+            return result;
+        }
 
-    Object value = annotationInfo.getParameterValues().getValue("value");
-    if (value == null) {
-      value = annotationInfo.getParameterValues().getValue("path");
+        return Collections.emptyList();
     }
 
-    if (value instanceof String) {
-      return (String) value;
-    } else if (value instanceof String[] strings && strings.length > 0) {
-      return strings[0];
+    private List<String> resolveConsumesOrProduces(List<String> classLevel,
+        AnnotationInfo methodAnnotation, String key) {
+        List<String> methodLevel = extractStringArray(methodAnnotation, key);
+        return !methodLevel.isEmpty() ? methodLevel : classLevel;
     }
 
-    return "";
-  }
+    private DtoSchema extractDtoSchema(MethodInfo methodInfo, String httpMethod) {
+        if (!httpMethod.equals("POST") && !httpMethod.equals("PUT") && !httpMethod.equals(
+            "PATCH")) {
+            return null;
+        }
 
-  private String extractHttpMethod(AnnotationInfo annotationInfo) {
-    if (annotationInfo == null) {
-      return "GET";
+        for (MethodParameterInfo paramInfo : methodInfo.getParameterInfo()) {
+            AnnotationInfoList paramAnnotations = paramInfo.getAnnotationInfo();
+
+            if (paramAnnotations.containsName("org.springframework.web.bind.annotation.RequestBody")
+                ||
+                paramAnnotations.containsName(
+                    "org.springframework.web.bind.annotation.ModelAttribute")) {
+
+                String typeName = paramInfo.getTypeDescriptor().toString();
+
+                try {
+                    Class<?> paramClass = loadClass(typeName);
+                    if (paramClass != null && isDTO(paramClass)) {
+                        return analyzeDto(paramClass, new HashSet<>());
+                    }
+                } catch (ClassNotFoundException e) {
+                    return null;
+                }
+            }
+        }
+        return null;
     }
 
-    Object methodAttr = annotationInfo.getParameterValues().getValue("method");
-    if (methodAttr == null) {
-      return "GET";
+    private DtoSchema analyzeDto(Class<?> dtoClass, Set<Class<?>> visited) {
+
+        if (dtoClass == null || visited.contains(dtoClass)) {
+            return new DtoSchema(new ArrayList<>());
+        }
+        visited.add(dtoClass);
+
+        List<FieldMeta> fields = new ArrayList<>();
+        for (Field field : dtoClass.getDeclaredFields()) {
+            if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) {
+                continue;
+            }
+
+            String fieldName = field.getName();
+            String fieldType = field.getType().getSimpleName();
+            List<FieldMeta> nestedFields = new ArrayList<>();
+
+            Type genericType = field.getGenericType();
+            if (genericType instanceof ParameterizedType && Collection.class.isAssignableFrom(field.getType())) {
+                ParameterizedType parameterizedType = (ParameterizedType) genericType;
+                Type collectionType = parameterizedType.getActualTypeArguments()[0];
+                fieldType = getTypeName(collectionType);
+
+                if (collectionType instanceof Class<?> && isDTO((Class<?>) collectionType)) {
+                    DtoSchema nestedSchema = analyzeDto((Class<?>) collectionType, visited);
+                    nestedFields.addAll(nestedSchema.getFields());
+                }
+            } else if (isDTO(field.getType())) {
+                DtoSchema nestedSchema = analyzeDto(field.getType(), visited);
+                nestedFields.addAll(nestedSchema.getFields());
+            }
+
+            fields.add(new FieldMeta(fieldName, fieldType, nestedFields));
+        }
+        return new DtoSchema(fields);
     }
 
-    if (methodAttr.getClass().isArray()
-        && methodAttr
-            .getClass()
-            .getComponentType()
-            .getName()
-            .equals("org.springframework.web.bind.annotation.RequestMethod")) {
-      Object[] methods = (Object[]) methodAttr;
-      if (methods.length > 0) {
-        String methodStr = methods[0].toString();
-        if (methodStr.contains(".")) {
-          String[] parts = methodStr.split("\\.");
-          return parts[parts.length - 1];
-        }
-        return methodStr;
-      }
-    } else if (methodAttr.getClass().isArray()) {
-      Object[] methods = (Object[]) methodAttr;
-      if (methods.length > 0) {
-        String methodStr = methods[0].toString();
-        if (methodStr.contains(".")) {
-          String[] parts = methodStr.split("\\.");
-          return parts[parts.length - 1];
-        }
-        return methodStr;
-      }
-    } else {
-      String methodStr = methodAttr.toString();
-      if (methodStr.contains(".")) {
-        String[] parts = methodStr.split("\\.");
-        return parts[parts.length - 1];
-      }
-      return methodStr;
+    private String getTypeName(Type type) {
+        if (type instanceof Class<?>) {
+            return ((Class<?>) type).getSimpleName();
+        } else if (type instanceof ParameterizedType) {
+            ParameterizedType parameterizedType = (ParameterizedType) type;
+            Type rawType = parameterizedType.getRawType();
+            return ((Class<?>) rawType).getSimpleName();
+        }
+        return type.toString();
     }
 
-    return "GET";
-  }
+    private Class<?> loadClass(String className) throws ClassNotFoundException {
+        String normalizedClassName = className;
 
-  private String formatPath(String path) {
-    if (path == null || path.isEmpty()) {
-      return "";
-    }
-    if (!path.startsWith("/")) {
-      path = "/" + path;
+        if (normalizedClassName.contains("<")) {
+            normalizedClassName = normalizedClassName.substring(0,
+                normalizedClassName.indexOf('<'));
+        }
+        if (normalizedClassName.contains("[")) {
+            normalizedClassName = normalizedClassName.substring(0,
+                normalizedClassName.indexOf('['));
+        }
+        switch (normalizedClassName) {
+            case "boolean":
+                return boolean.class;
+            case "byte":
+                return byte.class;
+            case "char":
+                return char.class;
+            case "double":
+                return double.class;
+            case "float":
+                return float.class;
+            case "int":
+                return int.class;
+            case "long":
+                return long.class;
+            case "short":
+                return short.class;
+            case "void":
+                return void.class;
+        }
+
+        return classLoader != null ? classLoader.loadClass(normalizedClassName)
+            : Class.forName(normalizedClassName);
     }
-    if (path.endsWith("/")) {
-      path = path.substring(0, path.length() - 1);
+
+    private boolean isDTO(Class<?> clazz) {
+        if (clazz.isPrimitive() || clazz.getName().startsWith("java.")) {
+            return false;
+        }
+
+        int getterCount = 0, setterCount = 0;
+        for (Method method : clazz.getDeclaredMethods()) {
+            if (Modifier.isPublic(method.getModifiers())) {
+                String name = method.getName();
+                if ((name.startsWith("get") || (name.startsWith("is") && method.getReturnType() == boolean.class))
+                    && method.getParameterCount() == 0) {
+                    getterCount++;
+                } else if (name.startsWith("set") && method.getParameterCount() == 1) {
+                    setterCount++;
+                }
+            }
+        }
+        return getterCount > 0 && setterCount > 0;
     }
-    return path;
-  }
 }
diff --git a/core/src/main/java/com/apighost/agent/model/DtoSchema.java b/core/src/main/java/com/apighost/agent/model/DtoSchema.java
index 8ee875a..035d183 100644
--- a/core/src/main/java/com/apighost/agent/model/DtoSchema.java
+++ b/core/src/main/java/com/apighost/agent/model/DtoSchema.java
@@ -1,46 +1,17 @@
 package com.apighost.agent.model;
 
-import java.lang.reflect.Field;
-import java.util.ArrayList;
 import java.util.List;
 
 public class DtoSchema {
 
-	private String className;
-	private List<FieldMeta> fields;
+    private List<FieldMeta> fields;
 
-	public DtoSchema(String className, List<FieldMeta> fields) {
-		this.className = className;
-		this.fields = fields;
-	}
+    public DtoSchema(List<FieldMeta> fields) {
+        this.fields = fields;
+    }
 
-	public String getClassName() {
-		return className;
-	}
+    public List<FieldMeta> getFields() {
+        return fields;
+    }
 
-	public void setClassName(String className) {
-		this.className = className;
-	}
-
-	public List<FieldMeta> getFields() {
-		return fields;
-	}
-
-	public void setFields(List<FieldMeta> fields) {
-		this.fields = fields;
-	}
-
-	/* 수정 사항
-	* */
-	public static DtoSchema fromClass(Class<?> clazz) {
-		if (clazz.isPrimitive() || clazz.getName().startsWith("java.")) {
-			return new DtoSchema(clazz.getSimpleName(), List.of());
-		}
-
-		List<FieldMeta> fieldMetas = new ArrayList<>();
-		for (Field field : clazz.getDeclaredFields()) {
-			fieldMetas.add(new FieldMeta(field.getName(), field.getType().getSimpleName()));
-		}
-		return new DtoSchema(clazz.getSimpleName(), fieldMetas);
-	}
 }
diff --git a/core/src/main/java/com/apighost/agent/model/EndPoint.java b/core/src/main/java/com/apighost/agent/model/EndPoint.java
index 6fae736..f3519fa 100644
--- a/core/src/main/java/com/apighost/agent/model/EndPoint.java
+++ b/core/src/main/java/com/apighost/agent/model/EndPoint.java
@@ -4,58 +4,39 @@
 
 public class EndPoint {
 
-	private String httpMethod;
-	private String path;
-	private List<String> produces;
-	private List<String> consumes;
-	private DtoSchema requestSchema;
-
-	public EndPoint(String httpMethod, String path, List<String> produces, List<String> consumes,
-		DtoSchema requestSchema) {
-		this.httpMethod = httpMethod;
-		this.path = path;
-		this.produces = produces;
-		this.consumes = consumes;
-		this.requestSchema = requestSchema;
-	}
-
-	public String getHttpMethod() {
-		return httpMethod;
-	}
-
-	public void setHttpMethod(String httpMethod) {
-		this.httpMethod = httpMethod;
-	}
-
-	public String getPath() {
-		return path;
-	}
-
-	public void setPath(String path) {
-		this.path = path;
-	}
-
-	public List<String> getProduces() {
-		return produces;
-	}
-
-	public void setProduces(List<String> produces) {
-		this.produces = produces;
-	}
-
-	public List<String> getConsumes() {
-		return consumes;
-	}
-
-	public void setConsumes(List<String> consumes) {
-		this.consumes = consumes;
-	}
-
-	public DtoSchema getRequestSchema() {
-		return requestSchema;
-	}
-
-	public void setRequestSchema(DtoSchema requestSchema) {
-		this.requestSchema = requestSchema;
-	}
+    private final String httpMethod;
+    private final String path;
+    private final List<String> produces;
+    private final List<String> consumes;
+    private final DtoSchema requestSchema;
+
+    public EndPoint(String httpMethod, String path, List<String> produces, List<String> consumes,
+        DtoSchema requestSchema) {
+        this.httpMethod = httpMethod;
+        this.path = path;
+        this.produces = produces;
+        this.consumes = consumes;
+        this.requestSchema = requestSchema;
+    }
+
+    public String getHttpMethod() {
+        return httpMethod;
+    }
+
+    public String getPath() {
+        return path;
+    }
+
+    public List<String> getProduces() {
+        return produces;
+    }
+
+    public List<String> getConsumes() {
+        return consumes;
+    }
+
+    public DtoSchema getRequestSchema() {
+        return requestSchema;
+    }
+
 }
diff --git a/core/src/main/java/com/apighost/agent/model/EndPointJson.java b/core/src/main/java/com/apighost/agent/model/EndPointJson.java
new file mode 100644
index 0000000..00d9429
--- /dev/null
+++ b/core/src/main/java/com/apighost/agent/model/EndPointJson.java
@@ -0,0 +1,25 @@
+package com.apighost.agent.model;
+
+public class EndPointJson {
+    private String HTTPMethod;
+    private String path;
+    private String jsonBody;
+
+    public EndPointJson(String HTTPMethod, String path, String jsonBody) {
+        this.HTTPMethod = HTTPMethod;
+        this.path = path;
+        this.jsonBody = jsonBody;
+    }
+
+    public String getHTTPMethod() {
+        return HTTPMethod;
+    }
+
+    public String getPath() {
+        return path;
+    }
+
+    public String getJsonBody() {
+        return jsonBody;
+    }
+}
diff --git a/core/src/main/java/com/apighost/agent/model/FieldMeta.java b/core/src/main/java/com/apighost/agent/model/FieldMeta.java
index 48908c8..e08ffd5 100644
--- a/core/src/main/java/com/apighost/agent/model/FieldMeta.java
+++ b/core/src/main/java/com/apighost/agent/model/FieldMeta.java
@@ -1,29 +1,36 @@
 package com.apighost.agent.model;
 
-public class FieldMeta {
-
-	private String name;
-	private String type;
-
-	public FieldMeta(String name, String type) {
-		this.name = name;
-		this.type = type;
-	}
+import java.util.ArrayList;
+import java.util.List;
 
-	public String getName() {
-		return name;
-	}
-
-	public void setName(String name) {
-		this.name = name;
-	}
-
-	public String getType() {
-		return type;
-	}
+public class FieldMeta {
 
-	public void setType(String type) {
-		this.type = type;
-	}
+    private final String type;
+    private final String name;
+    private final List<FieldMeta> nestedFields;
+
+    public FieldMeta(String type, String name) {
+        this.type = type;
+        this.name = name;
+        this.nestedFields = new ArrayList<>();
+    }
+
+    public FieldMeta(String name, String type, List<FieldMeta> nestedFields) {
+        this.name = name;
+        this.type = type;
+        this.nestedFields = nestedFields != null ? nestedFields : new ArrayList<>();
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public List<FieldMeta> getNestedFields() {
+        return nestedFields;
+    }
 
 }
diff --git a/core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java b/core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
new file mode 100644
index 0000000..e82cfba
--- /dev/null
+++ b/core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
@@ -0,0 +1,113 @@
+package com.apighost.agent.sender;
+
+import com.apighost.agent.model.EndPoint;
+import com.apighost.agent.model.EndPointJson;
+import com.apighost.agent.model.FieldMeta;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class EndpointJsonBodyConverter {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<EndPointJson> convert(List<EndPoint> endPoints) {
+
+        if (endPoints == null) {
+            return Collections.emptyList();
+        }
+
+        List<EndPointJson> result = new ArrayList<>();
+
+        for (EndPoint endPoint : endPoints) {
+
+            String jsonBody = createJsonBodyFromEndpoint(endPoint);
+            result.add(new EndPointJson(
+                endPoint.getHttpMethod(),
+                endPoint.getPath(),
+                jsonBody
+            ));
+
+        }
+        return result;
+    }
+
+    private String createJsonBodyFromEndpoint(EndPoint endPoint) {
+        String defaultJson = "{}";
+
+        if (endPoint == null || endPoint.getRequestSchema() == null) {
+            return defaultJson;
+        }
+
+        try {
+            ObjectNode root = objectMapper.createObjectNode();
+            List<FieldMeta> fields = endPoint.getRequestSchema().getFields();
+
+            if (fields != null && !fields.isEmpty()) {
+                buildJsonFromSchema(root, fields);
+                return objectMapper.writeValueAsString(root);
+            }
+        } catch (JsonProcessingException e) {
+            return defaultJson;
+        }
+
+        return defaultJson;
+    }
+
+    private void buildJsonFromSchema(ObjectNode node, List<FieldMeta> fields) {
+        if (fields == null) {
+            return;
+        }
+        for (FieldMeta field : fields) {
+            if (field == null || field.getName() == null) {
+                continue;
+            }
+
+            if (hasNestedFields(field)) {
+                processNestedFields(node, field);
+            } else {
+                addLeafField(node, field);
+            }
+        }
+    }
+
+    private boolean hasNestedFields(FieldMeta field) {
+        return field.getNestedFields() != null && !field.getNestedFields().isEmpty();
+    }
+
+    private void processNestedFields(ObjectNode node, FieldMeta field) {
+        String fieldType = field.getType();
+
+        if (fieldType != null && fieldType.startsWith("List")) {
+            processListField(node, field);
+        } else {
+            processObjectField(node, field);
+        }
+    }
+    private void processListField(ObjectNode node, FieldMeta field) {
+        ArrayNode arrayNode = node.putArray(field.getName());
+        ObjectNode itemNode = objectMapper.createObjectNode();
+        buildJsonFromSchema(itemNode, field.getNestedFields());
+        arrayNode.add(itemNode);
+    }
+
+    private void processObjectField(ObjectNode node, FieldMeta field) {
+        ObjectNode nestedNode = objectMapper.createObjectNode();
+        buildJsonFromSchema(nestedNode, field.getNestedFields());
+        node.set(field.getName(), nestedNode);
+    }
+
+    private void addLeafField(ObjectNode node, FieldMeta field) {
+        String fieldType = field.getType();
+
+        if (fieldType != null && fieldType.startsWith("List")) {
+            node.putArray(field.getName()).add("");
+        } else {
+            node.put(field.getName(), "");
+        }
+    }
+}


Reviews:
user: login: haerim-kweon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T03:16:03Z
user: login: cod0216
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T11:18:07Z
user: login: oneweeeek
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T04:32:11Z
user: login: oneweeeek
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T04:33:49Z
user: login: oneweeeek
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T04:34:10Z

Review comments:
diff_hunk: @@ -0,0 +1,57 @@
+package com.apighost.agent.sender;
+
+import com.apighost.agent.model.EndPoint;
+import com.apighost.agent.model.EndPointJson;
+import com.apighost.agent.model.FieldMeta;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
+import java.util.List;
+
+public class EndpointJsonBodyConverter {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<EndPointJson> convert(List<EndPoint> endPoints) {
+        List<EndPointJson> result = new ArrayList<>();
+        for (EndPoint endPoint : endPoints) {
+            String jsonBody = "{}";
+
+            if (endPoint.getRequestSchema() != null) {
+                ObjectNode root = objectMapper.createObjectNode();
+                buildJsonFromSchema(root, endPoint.getRequestSchema().getFields());
+                jsonBody = root.toString();
path: core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
user: login: haerim-kweon
body: How about using objectMapper.writeValueAsString (root)?
created_at: 2025-05-05T02:31:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,57 @@
+package com.apighost.agent.sender;
+
+import com.apighost.agent.model.EndPoint;
+import com.apighost.agent.model.EndPointJson;
+import com.apighost.agent.model.FieldMeta;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
+import java.util.List;
+
+public class EndpointJsonBodyConverter {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<EndPointJson> convert(List<EndPoint> endPoints) {
+        List<EndPointJson> result = new ArrayList<>();
+        for (EndPoint endPoint : endPoints) {
+            String jsonBody = "{}";
+
+            if (endPoint.getRequestSchema() != null) {
+                ObjectNode root = objectMapper.createObjectNode();
+                buildJsonFromSchema(root, endPoint.getRequestSchema().getFields());
+                jsonBody = root.toString();
+            }
+
+            result.add(new EndPointJson(
+                endPoint.getHttpMethod(),
+                endPoint.getPath(),
+                jsonBody
+            ));
+        }
+        return result;
+    }
+
+    private void buildJsonFromSchema(ObjectNode node, List<FieldMeta> fields) {
+        for (FieldMeta field : fields) {
+            if (!field.getNestedFields().isEmpty()) {
path: core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
user: login: haerim-kweon
body: Wouldn't it be necessary to check null?
created_at: 2025-05-05T02:34:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,57 @@
+package com.apighost.agent.sender;
+
+import com.apighost.agent.model.EndPoint;
+import com.apighost.agent.model.EndPointJson;
+import com.apighost.agent.model.FieldMeta;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
+import java.util.List;
+
+public class EndpointJsonBodyConverter {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<EndPointJson> convert(List<EndPoint> endPoints) {
+        List<EndPointJson> result = new ArrayList<>();
+        for (EndPoint endPoint : endPoints) {
+            String jsonBody = "{}";
+
+            if (endPoint.getRequestSchema() != null) {
+                ObjectNode root = objectMapper.createObjectNode();
+                buildJsonFromSchema(root, endPoint.getRequestSchema().getFields());
+                jsonBody = root.toString();
+            }
+
+            result.add(new EndPointJson(
+                endPoint.getHttpMethod(),
+                endPoint.getPath(),
+                jsonBody
+            ));
+        }
+        return result;
+    }
+
+    private void buildJsonFromSchema(ObjectNode node, List<FieldMeta> fields) {
path: core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
user: login: haerim-kweon
body: For code readability and maintenance, it would be better to divide the method by function.
created_at: 2025-05-05T03:15:50Z
author_association: COLLABORATOR
path: core/src/main/java/com/apighost/agent/model/EndPoint.java
user: login: cod0216
body: It would be great if you could apply IntelliJ's auto-formatting!
created_at: 2025-05-05T11:14:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,25 @@
+package com.apighost.agent.model;
+
+public class EndPointJson {
+    private String HTTPMethod;
path: core/src/main/java/com/apighost/agent/model/EndPointJson.java
user: login: cod0216
body: Would it be possible to use an enum here? If only limited values like POST and GET are used, I recommend using an enum.
created_at: 2025-05-05T11:17:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,57 @@
+package com.apighost.agent.sender;
+
+import com.apighost.agent.model.EndPoint;
+import com.apighost.agent.model.EndPointJson;
+import com.apighost.agent.model.FieldMeta;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
+import java.util.List;
+
+public class EndpointJsonBodyConverter {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<EndPointJson> convert(List<EndPoint> endPoints) {
+        List<EndPointJson> result = new ArrayList<>();
+        for (EndPoint endPoint : endPoints) {
+            String jsonBody = "{}";
+
+            if (endPoint.getRequestSchema() != null) {
+                ObjectNode root = objectMapper.createObjectNode();
+                buildJsonFromSchema(root, endPoint.getRequestSchema().getFields());
+                jsonBody = root.toString();
+            }
+
+            result.add(new EndPointJson(
+                endPoint.getHttpMethod(),
+                endPoint.getPath(),
+                jsonBody
+            ));
+        }
+        return result;
+    }
+
+    private void buildJsonFromSchema(ObjectNode node, List<FieldMeta> fields) {
+        for (FieldMeta field : fields) {
+            if (!field.getNestedFields().isEmpty()) {
path: core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
user: login: oneweeeek
body: Added a null check for all major objects and collections. Thank you
created_at: 2025-05-06T04:32:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,57 @@
+package com.apighost.agent.sender;
+
+import com.apighost.agent.model.EndPoint;
+import com.apighost.agent.model.EndPointJson;
+import com.apighost.agent.model.FieldMeta;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
+import java.util.List;
+
+public class EndpointJsonBodyConverter {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<EndPointJson> convert(List<EndPoint> endPoints) {
+        List<EndPointJson> result = new ArrayList<>();
+        for (EndPoint endPoint : endPoints) {
+            String jsonBody = "{}";
+
+            if (endPoint.getRequestSchema() != null) {
+                ObjectNode root = objectMapper.createObjectNode();
+                buildJsonFromSchema(root, endPoint.getRequestSchema().getFields());
+                jsonBody = root.toString();
path: core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
user: login: oneweeeek
body: I applied root.toString() → objectMapper.writeValueAsString(root)
Thank you for reminding me that I didn't consider the standard serialization method
created_at: 2025-05-06T04:33:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,57 @@
+package com.apighost.agent.sender;
+
+import com.apighost.agent.model.EndPoint;
+import com.apighost.agent.model.EndPointJson;
+import com.apighost.agent.model.FieldMeta;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
+import java.util.List;
+
+public class EndpointJsonBodyConverter {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<EndPointJson> convert(List<EndPoint> endPoints) {
+        List<EndPointJson> result = new ArrayList<>();
+        for (EndPoint endPoint : endPoints) {
+            String jsonBody = "{}";
+
+            if (endPoint.getRequestSchema() != null) {
+                ObjectNode root = objectMapper.createObjectNode();
+                buildJsonFromSchema(root, endPoint.getRequestSchema().getFields());
+                jsonBody = root.toString();
+            }
+
+            result.add(new EndPointJson(
+                endPoint.getHttpMethod(),
+                endPoint.getPath(),
+                jsonBody
+            ));
+        }
+        return result;
+    }
+
+    private void buildJsonFromSchema(ObjectNode node, List<FieldMeta> fields) {
path: core/src/main/java/com/apighost/agent/sender/EndpointJsonBodyConverter.java
user: login: oneweeeek
body: The methods were separated by function and modified so that each method had only one responsibility.
created_at: 2025-05-06T04:34:10Z
author_association: COLLABORATOR

PR comments:
user: login: sung-jun98
created_at: 2025-05-04T09:57:08Z
body: You’ve done an incredible job. seriously, hats off to you! 👍
author_association: COLLABORATOR
user: login: kobenlys
created_at: 2025-05-04T09:58:25Z
body: Nice Work!!! 
Thank you for adding high-priority features!! 😋😋
author_association: COLLABORATOR