feat: support nested MCP input schema validation
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled

This commit is contained in:
jade
2026-07-28 14:39:31 +09:00
parent 0c89a09ccf
commit 1e18d2bc47
2 changed files with 114 additions and 2 deletions

View File

@@ -5,10 +5,14 @@ import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
@@ -31,17 +35,23 @@ public class JsonSchemaGenerator {
* Java DTO 클래스를 분석하여 MCP 규격의 완전한 JSON Schema를 생성합니다.
*/
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<>();
schema.put("type", "object");
if (!visiting.add(clazz)) {
return schema;
}
Map<String, Object> properties = new HashMap<>();
List<String> requiredList = new ArrayList<>();
for (Field field : clazz.getDeclaredFields()) {
Map<String, Object> fieldSchema = new HashMap<>();
Map<String, Object> fieldSchema = createFieldSchema(field, visiting);
// 1. 타입 매핑
fieldSchema.put("type", mapJavaTypeToJsonType(field.getType()));
// 2. 어노테이션 기반 설명 추출
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
@@ -82,9 +92,47 @@ public class JsonSchemaGenerator {
schema.put("required", requiredList);
}
visiting.remove(clazz);
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) {
if (clazz == String.class) return "string";
if (clazz == Integer.class || clazz == int.class) return "integer";

View File

@@ -1,12 +1,19 @@
package io.shinhanlife.dap.lib.util;
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 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.McpValidation;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
/**
@@ -36,11 +43,55 @@ class JsonSchemaGeneratorTest {
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")
private Map<String, Map<String, Object>> properties(Map<String, Object> schema) {
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 {
@McpParameter(description = "recipient phone number", required = true)
@McpValidation(pattern = "^01[0-9]{8,9}$")
@@ -54,4 +105,17 @@ class JsonSchemaGeneratorTest {
@McpValidation(required = true, allowedValues = {"APPROVE", "REJECT"})
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;
}
}