feat: support nested MCP input schema validation
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
This commit is contained in:
@@ -5,10 +5,14 @@ import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
|||||||
import io.shinhanlife.dap.lib.annotation.McpParameter;
|
import io.shinhanlife.dap.lib.annotation.McpParameter;
|
||||||
import io.shinhanlife.dap.lib.annotation.McpValidation;
|
import io.shinhanlife.dap.lib.annotation.McpValidation;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.ParameterizedType;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,17 +35,23 @@ public class JsonSchemaGenerator {
|
|||||||
* Java DTO 클래스를 분석하여 MCP 규격의 완전한 JSON Schema를 생성합니다.
|
* Java DTO 클래스를 분석하여 MCP 규격의 완전한 JSON Schema를 생성합니다.
|
||||||
*/
|
*/
|
||||||
public static Map<String, Object> generateSchema(Class<?> clazz) {
|
public static Map<String, Object> generateSchema(Class<?> clazz) {
|
||||||
|
return generateSchema(clazz, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> generateSchema(Class<?> clazz, Set<Class<?>> visiting) {
|
||||||
Map<String, Object> schema = new HashMap<>();
|
Map<String, Object> schema = new HashMap<>();
|
||||||
schema.put("type", "object");
|
schema.put("type", "object");
|
||||||
|
if (!visiting.add(clazz)) {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> properties = new HashMap<>();
|
Map<String, Object> properties = new HashMap<>();
|
||||||
List<String> requiredList = new ArrayList<>();
|
List<String> requiredList = new ArrayList<>();
|
||||||
|
|
||||||
for (Field field : clazz.getDeclaredFields()) {
|
for (Field field : clazz.getDeclaredFields()) {
|
||||||
Map<String, Object> fieldSchema = new HashMap<>();
|
Map<String, Object> fieldSchema = createFieldSchema(field, visiting);
|
||||||
|
|
||||||
// 1. 타입 매핑
|
// 1. 타입 매핑
|
||||||
fieldSchema.put("type", mapJavaTypeToJsonType(field.getType()));
|
|
||||||
|
|
||||||
// 2. 어노테이션 기반 설명 추출
|
// 2. 어노테이션 기반 설명 추출
|
||||||
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
|
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
|
||||||
@@ -82,9 +92,47 @@ public class JsonSchemaGenerator {
|
|||||||
schema.put("required", requiredList);
|
schema.put("required", requiredList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visiting.remove(clazz);
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> createFieldSchema(Field field, Set<Class<?>> visiting) {
|
||||||
|
Class<?> fieldType = field.getType();
|
||||||
|
if (isSimpleType(fieldType)) {
|
||||||
|
return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(fieldType)));
|
||||||
|
}
|
||||||
|
if (List.class.isAssignableFrom(fieldType)) {
|
||||||
|
Map<String, Object> fieldSchema = new HashMap<>();
|
||||||
|
fieldSchema.put("type", "array");
|
||||||
|
fieldSchema.put("items", generateItemsSchema(field, visiting));
|
||||||
|
return fieldSchema;
|
||||||
|
}
|
||||||
|
return generateSchema(fieldType, visiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> generateItemsSchema(Field field, Set<Class<?>> visiting) {
|
||||||
|
Type genericType = field.getGenericType();
|
||||||
|
if (genericType instanceof ParameterizedType parameterizedType) {
|
||||||
|
Type itemType = parameterizedType.getActualTypeArguments()[0];
|
||||||
|
if (itemType instanceof Class<?> itemClass) {
|
||||||
|
if (isSimpleType(itemClass)) {
|
||||||
|
return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(itemClass)));
|
||||||
|
}
|
||||||
|
return generateSchema(itemClass, visiting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new HashMap<>(Map.of("type", "object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSimpleType(Class<?> clazz) {
|
||||||
|
return clazz == String.class
|
||||||
|
|| clazz == Integer.class || clazz == int.class
|
||||||
|
|| clazz == Long.class || clazz == long.class
|
||||||
|
|| clazz == Double.class || clazz == double.class
|
||||||
|
|| clazz == Float.class || clazz == float.class
|
||||||
|
|| clazz == Boolean.class || clazz == boolean.class;
|
||||||
|
}
|
||||||
|
|
||||||
private static String mapJavaTypeToJsonType(Class<?> clazz) {
|
private static String mapJavaTypeToJsonType(Class<?> clazz) {
|
||||||
if (clazz == String.class) return "string";
|
if (clazz == String.class) return "string";
|
||||||
if (clazz == Integer.class || clazz == int.class) return "integer";
|
if (clazz == Integer.class || clazz == int.class) return "integer";
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
package io.shinhanlife.dap.lib.util;
|
package io.shinhanlife.dap.lib.util;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.networknt.schema.JsonSchema;
|
||||||
|
import com.networknt.schema.JsonSchemaFactory;
|
||||||
|
import com.networknt.schema.SpecVersion;
|
||||||
|
import com.networknt.schema.ValidationMessage;
|
||||||
import io.shinhanlife.dap.lib.annotation.McpParameter;
|
import io.shinhanlife.dap.lib.annotation.McpParameter;
|
||||||
import io.shinhanlife.dap.lib.annotation.McpValidation;
|
import io.shinhanlife.dap.lib.annotation.McpValidation;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,11 +43,55 @@ class JsonSchemaGeneratorTest {
|
|||||||
assertEquals(List.of("APPROVE", "REJECT"), properties.get("approvalStatus").get("enum"));
|
assertEquals(List.of("APPROVE", "REJECT"), properties.get("approvalStatus").get("enum"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void includesNestedDtoConstraintsInGeneratedSchema() {
|
||||||
|
Map<String, Object> schema = JsonSchemaGenerator.generateSchema(NestedRequest.class);
|
||||||
|
Map<String, Object> childSchema = property(schema, "child");
|
||||||
|
|
||||||
|
assertEquals("object", childSchema.get("type"));
|
||||||
|
assertTrue(required(childSchema).contains("businessDate"));
|
||||||
|
assertEquals("^\\d{8}$", property(childSchema, "businessDate").get("pattern"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void includesNestedDtoSchemaForListItems() {
|
||||||
|
Map<String, Object> schema = JsonSchemaGenerator.generateSchema(ListRequest.class);
|
||||||
|
Map<String, Object> itemSchema = map(property(schema, "items").get("items"));
|
||||||
|
|
||||||
|
assertEquals("object", itemSchema.get("type"));
|
||||||
|
assertTrue(required(itemSchema).contains("businessDate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatorRejectsInvalidNestedValue() throws Exception {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)
|
||||||
|
.getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class)));
|
||||||
|
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(Map.of(
|
||||||
|
"child", Map.of("businessDate", "2026-07-28"))));
|
||||||
|
|
||||||
|
assertFalse(errors.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, Map<String, Object>> properties(Map<String, Object> schema) {
|
private Map<String, Map<String, Object>> properties(Map<String, Object> schema) {
|
||||||
return (Map<String, Map<String, Object>>) schema.get("properties");
|
return (Map<String, Map<String, Object>>) schema.get("properties");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> property(Map<String, Object> schema, String name) {
|
||||||
|
return properties(schema).get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> map(Object value) {
|
||||||
|
return (Map<String, Object>) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<String> required(Map<String, Object> schema) {
|
||||||
|
return (List<String>) schema.get("required");
|
||||||
|
}
|
||||||
|
|
||||||
private static class ValidatedRequest {
|
private static class ValidatedRequest {
|
||||||
@McpParameter(description = "recipient phone number", required = true)
|
@McpParameter(description = "recipient phone number", required = true)
|
||||||
@McpValidation(pattern = "^01[0-9]{8,9}$")
|
@McpValidation(pattern = "^01[0-9]{8,9}$")
|
||||||
@@ -54,4 +105,17 @@ class JsonSchemaGeneratorTest {
|
|||||||
@McpValidation(required = true, allowedValues = {"APPROVE", "REJECT"})
|
@McpValidation(required = true, allowedValues = {"APPROVE", "REJECT"})
|
||||||
private String approvalStatus;
|
private String approvalStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class NestedRequest {
|
||||||
|
private NestedChild child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ListRequest {
|
||||||
|
private List<NestedChild> items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class NestedChild {
|
||||||
|
@McpValidation(required = true, pattern = "^\\d{8}$")
|
||||||
|
private String businessDate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user