This commit is contained in:
@@ -63,6 +63,7 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
registration.setFilter(new MdcLoggingFilter());
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setOrder(1);
|
||||
registration.setAsyncSupported(true);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
registration.setFilter(new McpRequestHeaderFilter(sessionService));
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setOrder(2);
|
||||
registration.setAsyncSupported(true);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package io.shinhanlife.dat.lib.util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Glow MCI 응답 전문({@code *_O.java})을 분석하여 LLM 노출용 Response DTO와
|
||||
* MapStruct Converter 소스를 생성합니다. AI는 필드명 제안에만 사용하고 전문 구조와
|
||||
* {@code GlowTrgmField.description}은 이 클래스가 원문에서 직접 추출합니다.
|
||||
*/
|
||||
public final class MciResponseScaffolder {
|
||||
|
||||
private static final Pattern PACKAGE_PATTERN = Pattern.compile(
|
||||
"(?m)^\\s*package\\s+([A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*)\\s*;");
|
||||
private static final Pattern CLASS_PATTERN = Pattern.compile(
|
||||
"\\bclass\\s+([A-Za-z_$][\\w$]*)\\b");
|
||||
private static final Pattern FIELD_PATTERN = Pattern.compile(
|
||||
"(?s)((?:@[A-Za-z_$][\\w.$]*(?:\\s*\\([^;{}]*?\\))?\\s*)*)"
|
||||
+ "(?:private|protected|public)\\s+"
|
||||
+ "(?:(?:static|final|transient|volatile)\\s+)*"
|
||||
+ "([A-Za-z_$][\\w.$]*(?:\\s*<[^;{}=]+>)?(?:\\s*\\[\\])?)\\s+"
|
||||
+ "([A-Za-z_$][\\w$]*)\\s*(?:=[^;{}]*)?;");
|
||||
private static final Pattern ORDER_PATTERN = Pattern.compile("\\border\\s*=\\s*(\\d+)");
|
||||
private static final Pattern LENGTH_PATTERN = Pattern.compile("\\blength\\s*=\\s*(\\d+)");
|
||||
private static final Pattern DESCRIPTION_PATTERN = Pattern.compile(
|
||||
"\\bdescription\\s*=\\s*\"((?:\\\\.|[^\"\\\\])*)\"");
|
||||
private static final Pattern JAVA_NAME_PATTERN = Pattern.compile("^[a-z][A-Za-z0-9]*$");
|
||||
private static final Pattern PACKAGE_NAME_PATTERN = Pattern.compile(
|
||||
"^[a-z_][a-z0-9_]*(?:\\.[a-z_][a-z0-9_]*)*$");
|
||||
private static final Pattern CLASS_NAME_PATTERN = Pattern.compile("^[A-Z][A-Za-z0-9]*$");
|
||||
private static final List<String> SENSITIVE_KEYWORDS = List.of(
|
||||
"주민등록", "주민번호", "외국인등록", "여권번호", "계좌번호", "카드번호",
|
||||
"비밀번호", "암호", "resident registration", "social security", "passport", "password");
|
||||
|
||||
private MciResponseScaffolder() {
|
||||
}
|
||||
|
||||
public record ParsedSource(String packageName, String rootClassName, List<ParsedType> types) {
|
||||
}
|
||||
|
||||
public record ParsedType(String name, String parentName, List<ParsedField> fields) {
|
||||
}
|
||||
|
||||
public record ParsedField(String ownerType, int order, int length, String type, String name,
|
||||
String description, boolean sensitive) {
|
||||
}
|
||||
|
||||
public record FieldMapping(String ownerType, String sourceName, String targetName, boolean include) {
|
||||
}
|
||||
|
||||
public record GeneratedSources(String responseSource, String converterSource) {
|
||||
}
|
||||
|
||||
public static ParsedSource parse(String source) {
|
||||
if (source == null || source.isBlank()) {
|
||||
throw new IllegalArgumentException("MCI 응답 Java 소스를 입력해주세요.");
|
||||
}
|
||||
String normalized = source.replace("\uFEFF", "");
|
||||
Matcher packageMatcher = PACKAGE_PATTERN.matcher(normalized);
|
||||
if (!packageMatcher.find()) {
|
||||
throw new IllegalArgumentException("package 선언을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
List<ClassRange> ranges = findClassRanges(normalized);
|
||||
if (ranges.isEmpty()) {
|
||||
throw new IllegalArgumentException("Java class 선언을 찾을 수 없습니다.");
|
||||
}
|
||||
ClassRange root = ranges.stream()
|
||||
.filter(range -> range.parentName() == null && range.name().endsWith("_O"))
|
||||
.findFirst()
|
||||
.orElseGet(() -> ranges.stream().filter(range -> range.parentName() == null)
|
||||
.findFirst().orElse(ranges.getFirst()));
|
||||
|
||||
List<ParsedType> types = new ArrayList<>();
|
||||
types.add(new ParsedType(root.name(), null, parseFields(normalized, root)));
|
||||
ranges.stream()
|
||||
.filter(range -> range != root && isDescendantOf(range, root, ranges))
|
||||
.sorted(Comparator.comparingInt(ClassRange::openBrace))
|
||||
.forEach(range -> types.add(new ParsedType(
|
||||
range.name(), range.parentName(), parseFields(normalized, range))));
|
||||
|
||||
if (types.stream().allMatch(type -> type.fields().isEmpty())) {
|
||||
throw new IllegalArgumentException("변환할 응답 필드를 찾을 수 없습니다.");
|
||||
}
|
||||
return new ParsedSource(packageMatcher.group(1), root.name(), List.copyOf(types));
|
||||
}
|
||||
|
||||
public static GeneratedSources generate(ParsedSource parsed, String responsePackage,
|
||||
String responseClassName, String converterPackage,
|
||||
String converterClassName, List<FieldMapping> mappings) {
|
||||
if (parsed == null || parsed.types() == null || parsed.types().isEmpty()) {
|
||||
throw new IllegalArgumentException("분석된 MCI 응답 정보가 없습니다.");
|
||||
}
|
||||
validatePackage(responsePackage, "Response package");
|
||||
validatePackage(converterPackage, "Converter package");
|
||||
validateClassName(responseClassName, "Response class");
|
||||
validateClassName(converterClassName, "Converter class");
|
||||
|
||||
Map<String, FieldMapping> mappingBySource = normalizeMappings(parsed, mappings);
|
||||
validateTargetNames(parsed, mappingBySource);
|
||||
return new GeneratedSources(
|
||||
responseSource(parsed, responsePackage, responseClassName, mappingBySource),
|
||||
converterSource(parsed, responsePackage, responseClassName, converterPackage,
|
||||
converterClassName, mappingBySource));
|
||||
}
|
||||
|
||||
private static List<ClassRange> findClassRanges(String source) {
|
||||
List<ClassRangeDraft> drafts = new ArrayList<>();
|
||||
Matcher matcher = CLASS_PATTERN.matcher(source);
|
||||
while (matcher.find()) {
|
||||
int openBrace = source.indexOf('{', matcher.end());
|
||||
if (openBrace < 0) {
|
||||
continue;
|
||||
}
|
||||
int closeBrace = matchingBrace(source, openBrace);
|
||||
if (closeBrace > openBrace) {
|
||||
drafts.add(new ClassRangeDraft(matcher.group(1), openBrace, closeBrace));
|
||||
}
|
||||
}
|
||||
drafts.sort(Comparator.comparingInt(ClassRangeDraft::openBrace));
|
||||
List<ClassRange> ranges = new ArrayList<>();
|
||||
for (ClassRangeDraft draft : drafts) {
|
||||
String parent = drafts.stream()
|
||||
.filter(candidate -> candidate != draft
|
||||
&& candidate.openBrace() < draft.openBrace()
|
||||
&& candidate.closeBrace() > draft.closeBrace())
|
||||
.min(Comparator.comparingInt(candidate -> candidate.closeBrace() - candidate.openBrace()))
|
||||
.map(ClassRangeDraft::name)
|
||||
.orElse(null);
|
||||
ranges.add(new ClassRange(draft.name(), parent, draft.openBrace(), draft.closeBrace()));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private static boolean isDescendantOf(ClassRange range, ClassRange root, List<ClassRange> ranges) {
|
||||
if (range.openBrace() <= root.openBrace() || range.closeBrace() >= root.closeBrace()) {
|
||||
return false;
|
||||
}
|
||||
String parent = range.parentName();
|
||||
while (parent != null) {
|
||||
if (parent.equals(root.name())) {
|
||||
return true;
|
||||
}
|
||||
String current = parent;
|
||||
parent = ranges.stream().filter(candidate -> candidate.name().equals(current))
|
||||
.map(ClassRange::parentName).findFirst().orElse(null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<ParsedField> parseFields(String source, ClassRange range) {
|
||||
String body = source.substring(range.openBrace() + 1, range.closeBrace());
|
||||
Matcher matcher = FIELD_PATTERN.matcher(body);
|
||||
List<FieldWithPosition> fields = new ArrayList<>();
|
||||
while (matcher.find()) {
|
||||
int absoluteStart = range.openBrace() + 1 + matcher.start();
|
||||
if (braceDepth(source, range.openBrace() + 1, absoluteStart) != 0) {
|
||||
continue;
|
||||
}
|
||||
String annotations = matcher.group(1) == null ? "" : matcher.group(1);
|
||||
String type = matcher.group(2).replaceAll("\\s+", "");
|
||||
String name = matcher.group(3);
|
||||
int order = intAttribute(annotations, ORDER_PATTERN, Integer.MAX_VALUE);
|
||||
int length = intAttribute(annotations, LENGTH_PATTERN, 0);
|
||||
String description = stringAttribute(annotations, DESCRIPTION_PATTERN);
|
||||
fields.add(new FieldWithPosition(absoluteStart, new ParsedField(
|
||||
range.name(), order, length, type, name, description, isSensitive(name, description))));
|
||||
}
|
||||
fields.sort(Comparator
|
||||
.comparingInt((FieldWithPosition value) -> value.field().order())
|
||||
.thenComparingInt(FieldWithPosition::position));
|
||||
return fields.stream().map(FieldWithPosition::field).toList();
|
||||
}
|
||||
|
||||
private static int intAttribute(String source, Pattern pattern, int defaultValue) {
|
||||
Matcher matcher = pattern.matcher(source);
|
||||
return matcher.find() ? Integer.parseInt(matcher.group(1)) : defaultValue;
|
||||
}
|
||||
|
||||
private static String stringAttribute(String source, Pattern pattern) {
|
||||
Matcher matcher = pattern.matcher(source);
|
||||
return matcher.find() ? unescapeJavaString(matcher.group(1)) : "";
|
||||
}
|
||||
|
||||
private static String unescapeJavaString(String value) {
|
||||
return value.replace("\\\"", "\"")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
.replace("\\t", "\t")
|
||||
.replace("\\\\", "\\");
|
||||
}
|
||||
|
||||
private static boolean isSensitive(String name, String description) {
|
||||
String text = (name + " " + description).toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_KEYWORDS.stream().anyMatch(text::contains);
|
||||
}
|
||||
|
||||
private static int matchingBrace(String source, int openBrace) {
|
||||
int depth = 0;
|
||||
ScanState state = ScanState.CODE;
|
||||
for (int i = openBrace; i < source.length(); i++) {
|
||||
char current = source.charAt(i);
|
||||
char next = i + 1 < source.length() ? source.charAt(i + 1) : '\0';
|
||||
if (state == ScanState.CODE) {
|
||||
if (current == '/' && next == '/') { state = ScanState.LINE_COMMENT; i++; continue; }
|
||||
if (current == '/' && next == '*') { state = ScanState.BLOCK_COMMENT; i++; continue; }
|
||||
if (current == '"') { state = ScanState.STRING; continue; }
|
||||
if (current == '\'') { state = ScanState.CHAR; continue; }
|
||||
if (current == '{') depth++;
|
||||
if (current == '}' && --depth == 0) return i;
|
||||
} else if (state == ScanState.LINE_COMMENT && (current == '\n' || current == '\r')) {
|
||||
state = ScanState.CODE;
|
||||
} else if (state == ScanState.BLOCK_COMMENT && current == '*' && next == '/') {
|
||||
state = ScanState.CODE; i++;
|
||||
} else if ((state == ScanState.STRING || state == ScanState.CHAR) && current == '\\') {
|
||||
i++;
|
||||
} else if (state == ScanState.STRING && current == '"') {
|
||||
state = ScanState.CODE;
|
||||
} else if (state == ScanState.CHAR && current == '\'') {
|
||||
state = ScanState.CODE;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int braceDepth(String source, int from, int to) {
|
||||
int depth = 0;
|
||||
ScanState state = ScanState.CODE;
|
||||
for (int i = from; i < to; i++) {
|
||||
char current = source.charAt(i);
|
||||
char next = i + 1 < to ? source.charAt(i + 1) : '\0';
|
||||
if (state == ScanState.CODE) {
|
||||
if (current == '/' && next == '/') { state = ScanState.LINE_COMMENT; i++; continue; }
|
||||
if (current == '/' && next == '*') { state = ScanState.BLOCK_COMMENT; i++; continue; }
|
||||
if (current == '"') { state = ScanState.STRING; continue; }
|
||||
if (current == '\'') { state = ScanState.CHAR; continue; }
|
||||
if (current == '{') depth++;
|
||||
if (current == '}') depth--;
|
||||
} else if (state == ScanState.LINE_COMMENT && (current == '\n' || current == '\r')) {
|
||||
state = ScanState.CODE;
|
||||
} else if (state == ScanState.BLOCK_COMMENT && current == '*' && next == '/') {
|
||||
state = ScanState.CODE; i++;
|
||||
} else if ((state == ScanState.STRING || state == ScanState.CHAR) && current == '\\') {
|
||||
i++;
|
||||
} else if (state == ScanState.STRING && current == '"') {
|
||||
state = ScanState.CODE;
|
||||
} else if (state == ScanState.CHAR && current == '\'') {
|
||||
state = ScanState.CODE;
|
||||
}
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
private static Map<String, FieldMapping> normalizeMappings(ParsedSource parsed, List<FieldMapping> mappings) {
|
||||
Map<String, FieldMapping> normalized = new LinkedHashMap<>();
|
||||
if (mappings != null) {
|
||||
for (FieldMapping mapping : mappings) {
|
||||
if (mapping == null || mapping.ownerType() == null || mapping.sourceName() == null) continue;
|
||||
normalized.put(key(mapping.ownerType(), mapping.sourceName()), mapping);
|
||||
}
|
||||
}
|
||||
for (ParsedType type : parsed.types()) {
|
||||
for (ParsedField field : type.fields()) {
|
||||
normalized.putIfAbsent(key(type.name(), field.name()),
|
||||
new FieldMapping(type.name(), field.name(), field.name(), true));
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static void validateTargetNames(ParsedSource parsed, Map<String, FieldMapping> mappings) {
|
||||
for (ParsedType type : parsed.types()) {
|
||||
Set<String> targets = new HashSet<>();
|
||||
for (ParsedField field : type.fields()) {
|
||||
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
|
||||
if (!mapping.include()) continue;
|
||||
String target = mapping.targetName() == null ? "" : mapping.targetName().trim();
|
||||
if (!JAVA_NAME_PATTERN.matcher(target).matches()) {
|
||||
throw new IllegalArgumentException("올바르지 않은 LLM 필드명: " + target);
|
||||
}
|
||||
if (!targets.add(target)) {
|
||||
throw new IllegalArgumentException(type.name() + " 안에 중복 LLM 필드명이 있습니다: " + target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String responseSource(ParsedSource parsed, String responsePackage, String responseClassName,
|
||||
Map<String, FieldMapping> mappings) {
|
||||
boolean usesList = parsed.types().stream().flatMap(type -> type.fields().stream())
|
||||
.anyMatch(field -> field.type().contains("List<"));
|
||||
boolean usesBigDecimal = parsed.types().stream().flatMap(type -> type.fields().stream())
|
||||
.anyMatch(field -> field.type().contains("BigDecimal"));
|
||||
StringBuilder source = new StringBuilder("package ").append(responsePackage).append(";\n\n")
|
||||
.append("import com.fasterxml.jackson.annotation.JsonInclude;\n")
|
||||
.append("import io.swagger.v3.oas.annotations.media.Schema;\n")
|
||||
.append("import lombok.Data;\n");
|
||||
if (usesBigDecimal) source.append("import java.math.BigDecimal;\n");
|
||||
if (usesList) source.append("import java.util.List;\n");
|
||||
source.append("\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\n")
|
||||
.append("public class ").append(responseClassName).append(" {\n\n")
|
||||
.append(responseFields(parsed.types().getFirst(), mappings, " "));
|
||||
|
||||
for (ParsedType type : parsed.types().stream().skip(1).toList()) {
|
||||
source.append(" @Data\n")
|
||||
.append(" @JsonInclude(JsonInclude.Include.NON_NULL)\n")
|
||||
.append(" public static class ").append(type.name()).append(" {\n\n")
|
||||
.append(responseFields(type, mappings, " "))
|
||||
.append(" }\n\n");
|
||||
}
|
||||
return source.append("}\n").toString();
|
||||
}
|
||||
|
||||
private static String responseFields(ParsedType type, Map<String, FieldMapping> mappings, String indent) {
|
||||
StringBuilder source = new StringBuilder();
|
||||
for (ParsedField field : type.fields()) {
|
||||
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
|
||||
if (!mapping.include()) continue;
|
||||
source.append(indent).append("@Schema(description = \"")
|
||||
.append(escapeJava(field.description())).append("\")\n")
|
||||
.append(indent).append("private ").append(field.type()).append(' ')
|
||||
.append(mapping.targetName().trim()).append(";\n\n");
|
||||
}
|
||||
return source.toString();
|
||||
}
|
||||
|
||||
private static String converterSource(ParsedSource parsed, String responsePackage, String responseClassName,
|
||||
String converterPackage, String converterClassName,
|
||||
Map<String, FieldMapping> mappings) {
|
||||
StringBuilder source = new StringBuilder("package ").append(converterPackage).append(";\n\n")
|
||||
.append("import ").append(responsePackage).append('.').append(responseClassName).append(";\n")
|
||||
.append("import ").append(parsed.packageName()).append('.').append(parsed.rootClassName()).append(";\n")
|
||||
.append("import org.mapstruct.Mapper;\n")
|
||||
.append("import org.mapstruct.Mapping;\n")
|
||||
.append("import org.mapstruct.ReportingPolicy;\n\n")
|
||||
.append("@Mapper(componentModel = \"spring\", unmappedTargetPolicy = ReportingPolicy.IGNORE)\n")
|
||||
.append("public interface ").append(converterClassName).append(" {\n\n");
|
||||
|
||||
appendMappingMethod(source, parsed.types().getFirst(), responseClassName,
|
||||
parsed.rootClassName(), "toResponse", mappings);
|
||||
Map<String, ParsedType> typeByName = new HashMap<>();
|
||||
parsed.types().forEach(type -> typeByName.put(type.name(), type));
|
||||
for (ParsedType type : parsed.types().stream().skip(1).toList()) {
|
||||
appendMappingMethod(source, type, responseClassName + "." + type.name(),
|
||||
sourceTypePath(parsed.rootClassName(), type, typeByName), "to" + type.name(), mappings);
|
||||
}
|
||||
return source.append("}\n").toString();
|
||||
}
|
||||
|
||||
private static void appendMappingMethod(StringBuilder source, ParsedType type, String targetType,
|
||||
String sourceType, String methodName,
|
||||
Map<String, FieldMapping> mappings) {
|
||||
for (ParsedField field : type.fields()) {
|
||||
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
|
||||
if (mapping.include() && !field.name().equals(mapping.targetName())) {
|
||||
source.append(" @Mapping(source = \"").append(field.name())
|
||||
.append("\", target = \"").append(mapping.targetName()).append("\")\n");
|
||||
}
|
||||
}
|
||||
source.append(" ").append(targetType).append(' ').append(methodName)
|
||||
.append('(').append(sourceType).append(" source);\n\n");
|
||||
}
|
||||
|
||||
private static String sourceTypePath(String rootClassName, ParsedType type,
|
||||
Map<String, ParsedType> typeByName) {
|
||||
List<String> names = new ArrayList<>();
|
||||
ParsedType current = type;
|
||||
while (current != null && !current.name().equals(rootClassName)) {
|
||||
names.addFirst(current.name());
|
||||
current = current.parentName() == null ? null : typeByName.get(current.parentName());
|
||||
}
|
||||
return rootClassName + "." + String.join(".", names);
|
||||
}
|
||||
|
||||
private static void validatePackage(String value, String label) {
|
||||
if (value == null || !PACKAGE_NAME_PATTERN.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException(label + " 이름이 올바르지 않습니다: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateClassName(String value, String label) {
|
||||
if (value == null || !CLASS_NAME_PATTERN.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException(label + " 이름이 올바르지 않습니다: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private static String key(String ownerType, String sourceName) {
|
||||
return ownerType + "#" + sourceName;
|
||||
}
|
||||
|
||||
private static String escapeJava(String value) {
|
||||
return value == null ? "" : value.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"").replace("\r", "\\r").replace("\n", "\\n");
|
||||
}
|
||||
|
||||
private enum ScanState { CODE, STRING, CHAR, LINE_COMMENT, BLOCK_COMMENT }
|
||||
|
||||
private record ClassRangeDraft(String name, int openBrace, int closeBrace) { }
|
||||
private record ClassRange(String name, String parentName, int openBrace, int closeBrace) { }
|
||||
private record FieldWithPosition(int position, ParsedField field) { }
|
||||
}
|
||||
@@ -451,6 +451,17 @@ public class PodScaffolder {
|
||||
}
|
||||
|
||||
private static String applicationProfileYml(String profile, String port) {
|
||||
if ("local".equals(profile)) {
|
||||
return """
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: local
|
||||
import:
|
||||
- classpath:glow/application-glow.yml
|
||||
- classpath:glow/application-glow-local.yml
|
||||
""";
|
||||
}
|
||||
return """
|
||||
server:
|
||||
port: ${PORT:%s}
|
||||
@@ -477,8 +488,8 @@ public class PodScaffolder {
|
||||
List<String> targetModules)
|
||||
throws IOException {
|
||||
Map<String, Object> root = parseManifestYaml(source);
|
||||
if (!(root.get("mcp") instanceof Map<?, ?> mcp)
|
||||
|| !(mcp.get("manifest") instanceof Map<?, ?> manifest)) {
|
||||
Map<String, Object> manifest = manifestNode(root);
|
||||
if (manifest == null) {
|
||||
throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
@@ -491,10 +502,29 @@ public class PodScaffolder {
|
||||
routingFunction.put("server-id", moduleName);
|
||||
routingFunction.put("category-key", categoryKey);
|
||||
routingFunction.put("confusable-servers", allowedTargetModules(rootDir, moduleName, targetModules));
|
||||
((Map<String, Object>) manifest).put("routing-functions", List.of(routingFunction));
|
||||
manifest.put("routing-functions", List.of(routingFunction));
|
||||
return YAML_MAPPER.writeValueAsString(root);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> manifestNode(Map<String, Object> root) {
|
||||
if (root.get("mcp") instanceof Map<?, ?> mcp
|
||||
&& mcp.get("manifest") instanceof Map<?, ?> manifest) {
|
||||
return (Map<String, Object>) manifest;
|
||||
}
|
||||
|
||||
Object routingFunctions = root.remove("mcp.manifest.routing-functions");
|
||||
if (routingFunctions == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> manifest = new java.util.LinkedHashMap<>();
|
||||
manifest.put("routing-functions", routingFunctions);
|
||||
Map<String, Object> mcp = new java.util.LinkedHashMap<>();
|
||||
mcp.put("manifest", manifest);
|
||||
root.put("mcp", mcp);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> routingFunction(Object routingFunctions) {
|
||||
if (routingFunctions instanceof List<?> functions
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package io.shinhanlife.dat.lib.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MciResponseScaffolderTest {
|
||||
|
||||
private static final String MCI_SOURCE = """
|
||||
package io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io;
|
||||
|
||||
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ONBSZ0460_O {
|
||||
@GlowTrgmField(order = 1, length = 20, description = "인사정보 목록")
|
||||
private List<CmnnPrafIfinOutDto> employeeItems;
|
||||
|
||||
@Data
|
||||
public static class CmnnPrafIfinOutDto {
|
||||
@GlowTrgmField(order = 1, length = 8, description = "인사번호")
|
||||
private String prafNo;
|
||||
|
||||
@GlowTrgmField(order = 2, length = 2, description = "인사유형코드")
|
||||
private String prafTypeCd;
|
||||
|
||||
@GlowTrgmField(order = 3, length = 200, description = "인사명")
|
||||
private String prafNm;
|
||||
|
||||
@GlowTrgmField(order = 4, length = 50, description = "주민등록번호")
|
||||
private String rdreNo;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void parsesGlowDescriptionsAndNestedResponseFields() {
|
||||
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
|
||||
|
||||
assertEquals("io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io", parsed.packageName());
|
||||
assertEquals("ONBSZ0460_O", parsed.rootClassName());
|
||||
assertEquals(List.of("ONBSZ0460_O", "CmnnPrafIfinOutDto"),
|
||||
parsed.types().stream().map(MciResponseScaffolder.ParsedType::name).toList());
|
||||
assertEquals("인사번호", parsed.types().get(1).fields().getFirst().description());
|
||||
assertEquals(8, parsed.types().get(1).fields().getFirst().length());
|
||||
assertTrue(parsed.types().get(1).fields().get(3).sensitive());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesLlmResponseAndExplicitMapStructMappings() {
|
||||
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
|
||||
List<MciResponseScaffolder.FieldMapping> mappings = List.of(
|
||||
new MciResponseScaffolder.FieldMapping("ONBSZ0460_O", "employeeItems", "employees", true),
|
||||
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNo", "employeeNumber", true),
|
||||
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafTypeCd", "employeeTypeCode", true),
|
||||
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNm", "employeeName", true),
|
||||
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "rdreNo", "residentRegistrationNumber", true));
|
||||
|
||||
MciResponseScaffolder.GeneratedSources generated = MciResponseScaffolder.generate(
|
||||
parsed,
|
||||
"io.shinhanlife.dat.mcc.biz.pro.dto",
|
||||
"IndividualCustomerDetailInquiryResponse",
|
||||
"io.shinhanlife.dat.mcc.biz.pro.converter",
|
||||
"IndividualCustomerDetailInquiryConverter",
|
||||
mappings);
|
||||
|
||||
assertTrue(generated.responseSource().contains("@Schema(description = \"인사번호\")"));
|
||||
assertTrue(generated.responseSource().contains("private String employeeNumber;"));
|
||||
assertTrue(generated.responseSource().contains("private List<CmnnPrafIfinOutDto> employees;"));
|
||||
assertTrue(generated.responseSource().contains("public static class CmnnPrafIfinOutDto"));
|
||||
assertTrue(generated.converterSource().contains(
|
||||
"@Mapping(source = \"prafNo\", target = \"employeeNumber\")"));
|
||||
assertTrue(generated.converterSource().contains(
|
||||
"IndividualCustomerDetailInquiryResponse toResponse(ONBSZ0460_O source);"));
|
||||
assertTrue(generated.converterSource().contains(
|
||||
"IndividualCustomerDetailInquiryResponse.CmnnPrafIfinOutDto toCmnnPrafIfinOutDto(ONBSZ0460_O.CmnnPrafIfinOutDto source);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateAiTargetNamesWithinTheSameType() {
|
||||
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
|
||||
List<MciResponseScaffolder.FieldMapping> mappings = List.of(
|
||||
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNo", "employeeNumber", true),
|
||||
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafTypeCd", "employeeNumber", true));
|
||||
|
||||
try {
|
||||
MciResponseScaffolder.generate(parsed, "io.example.dto", "EmployeeResponse",
|
||||
"io.example.converter", "EmployeeConverter", mappings);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
assertTrue(exception.getMessage().contains("중복"));
|
||||
return;
|
||||
}
|
||||
throw new AssertionError("Duplicate target names must be rejected");
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,8 @@ class PodScaffolderTest {
|
||||
assertTrue(Files.exists(resources.resolve("application-prod.yml")));
|
||||
assertTrue(Files.readString(resources.resolve("application-local.yml"))
|
||||
.contains("classpath:glow/application-glow-local.yml"));
|
||||
assertTrue(Files.readString(resources.resolve("application-local.yml"))
|
||||
.contains("classpath:application-core-local.yml"));
|
||||
assertFalse(Files.readString(resources.resolve("application-local.yml"))
|
||||
.contains("application-core-local.yml"));
|
||||
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
|
||||
.contains("classpath:glow/application-glow-dev.yml"));
|
||||
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
|
||||
@@ -65,7 +65,7 @@ class PodScaffolderTest {
|
||||
PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.02");
|
||||
|
||||
Path resources = root.resolve("dat-was-cla/src/main/resources");
|
||||
for (String profile : List.of("local", "dev", "test", "prod")) {
|
||||
for (String profile : List.of("dev", "test", "prod")) {
|
||||
String content = Files.readString(resources.resolve("application-" + profile + ".yml"));
|
||||
assertTrue(content.contains("port: ${PORT:8087}"));
|
||||
assertTrue(content.contains("on-profile: " + profile));
|
||||
@@ -78,6 +78,23 @@ class PodScaffolderTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesLocalProfileWithOnlyGlowImports() throws Exception {
|
||||
PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.03");
|
||||
|
||||
String localProfile = Files.readString(root.resolve("dat-was-cla/src/main/resources/application-local.yml"));
|
||||
|
||||
assertEquals("""
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: local
|
||||
import:
|
||||
- classpath:glow/application-glow.yml
|
||||
- classpath:glow/application-glow-local.yml
|
||||
""", localProfile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalizesEhrManifestAndUsesAllExistingPodsAsConfusableServers() throws Exception {
|
||||
Files.writeString(root.resolve("docker-compose.yml"), "services:\n");
|
||||
@@ -137,6 +154,25 @@ class PodScaffolderTest {
|
||||
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesAiManifestWithDottedRoutingFunctionsRoot() throws Exception {
|
||||
String aiManifest = """
|
||||
mcp.manifest.routing-functions:
|
||||
- name: route_to_dat-was-ehr
|
||||
server-id: dat-was-ehr
|
||||
category-key: ehr
|
||||
business-outcome: "인사 정보와 휴가 정보를 제공합니다."
|
||||
confusable-servers: [dat-was-hrd, dat-was-pay]
|
||||
""";
|
||||
|
||||
String normalized = PodScaffolder.normalizeToolServiceManifest(aiManifest, "dat-was-ehr",
|
||||
List.of("dat-was-cus", "dat-was-ehr", "dat-was-sal"));
|
||||
Map<String, Object> routingFunction = routingFunction(normalized);
|
||||
|
||||
assertEquals("route_to_dat-was-ehr", routingFunction.get("name"));
|
||||
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesRedisOptionalPodComposeConfiguration() throws Exception {
|
||||
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");
|
||||
|
||||
Reference in New Issue
Block a user