fix: normalize scaffold pod and tool generation
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 35s

This commit is contained in:
jade
2026-09-03 10:10:21 +09:00
parent e6bcfe8934
commit 693ddea18d
7 changed files with 544 additions and 77 deletions

View File

@@ -78,7 +78,8 @@ public class ScaffoldingController {
}
System.setProperty("AXHUB_SOURCE_DIR", workspacePath);
return PodScaffolder.scaffoldPod(moduleName, port, shortName, author, date, req.get("toolServiceManifest"));
return PodScaffolder.scaffoldPod(moduleName, port, shortName, author, date,
req.get("toolServiceManifest"), targetModules(req.get("targetModules")));
} catch (Exception e) {
return "오류 발생: " + e.getMessage();
}
@@ -89,6 +90,9 @@ public class ScaffoldingController {
String description = req.getOrDefault("description", "").trim();
if (description.isBlank()) return ResponseEntity.badRequest().body(Map.of("error", "Pod 업무 설명을 입력해주세요."));
try {
String moduleName = req.getOrDefault("moduleName", "dat-was-cus").trim();
if (!moduleName.startsWith("dat-was-")) moduleName = "dat-was-" + moduleName;
List<String> targetModules = targetModules(req.get("targetModules"));
String prompt = """
Generate only YAML for an MCP tool service manifest.
The root must be mcp.manifest.routing-functions with one routing function.
@@ -105,8 +109,9 @@ public class ScaffoldingController {
Use valid YAML only, without Markdown fences or explanations.
Pod module: %s
Business description: %s
""".formatted(req.getOrDefault("moduleName", "dat-was-cus"), description);
String content = stripCodeFence(generateAiContent(prompt, req.get("model")));
""".formatted(moduleName, description);
String content = PodScaffolder.normalizeToolServiceManifest(
stripCodeFence(generateAiContent(prompt, req.get("model"))), moduleName, targetModules);
return ResponseEntity.ok(Map.of("toolServiceManifest", content));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", "AI Manifest 초안 생성 실패: " + safeMessage(e)));
@@ -140,7 +145,7 @@ public class ScaffoldingController {
if (title == null || title.isBlank()) title = baseName;
String description = req.get("description");
String group = req.getOrDefault("categoryKey", req.getOrDefault("group", "COMMON"));
String routingType = req.getOrDefault("routingType", "HTTP");
String routingType = req.getOrDefault("routingType", "MCI");
String moduleName = req.getOrDefault("moduleName", "dat-was-cus");
String author = req.get("author");
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
@@ -290,9 +295,9 @@ public class ScaffoldingController {
Generate an MCP Tool scaffold from the user request.
Return JSON only. Do not add Markdown, explanations, or code fences.
The response must have this exact shape:
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","examples":["SUCCESS"],"pattern":"","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"MCI","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","examples":["SUCCESS"],"pattern":"","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
categoryKey must be exactly three lowercase letters or digits.
routingType must be either HTTP or MCI. httpApiName can contain only letters, digits, hyphens, and underscores.
routingType must be either MCI or HTTP. Default to MCI. Use HTTP only when the user explicitly requests a REST or HTTP integration. httpApiName can contain only letters, digits, hyphens, and underscores.
Write every V17 metadata field for its distinct purpose; do not copy the same sentence into all fields.
Generate 3 to 10 realistic exampleQueries and concise search tags. Use MCP_TOOL for ownerOrg unless the user names an owner.
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, List. Finite values should be enforced by populating enumValues. List must include itemType and object lists include itemFields.
@@ -529,6 +534,19 @@ public class ScaffoldingController {
return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
}
private List<String> targetModules(String rawTargetModules) throws Exception {
if (rawTargetModules == null || rawTargetModules.isBlank()) {
return List.of();
}
List<String> modules = objectMapper.readValue(rawTargetModules, new TypeReference<List<String>>() { });
return modules.stream()
.filter(java.util.Objects::nonNull)
.map(String::trim)
.filter(name -> name.matches("^dat-was-[a-z0-9-]+$"))
.distinct()
.toList();
}
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
}

View File

@@ -944,7 +944,7 @@
<div class="col-md-6 mt-3 mt-md-0">
<label class="form-label">Protocol</label>
<select class="form-select" name="routingType">
<option value="MCI">MCI (Legacy)</option>
<option value="MCI" selected>MCI (Legacy)</option>
<option value="HTTP">HTTP (REST)</option>
</select>
</div>
@@ -1579,6 +1579,9 @@
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
if (formId === 'podForm') {
data.targetModules = JSON.stringify(currentTargetModules());
}
const btn = this.querySelector('button[type="submit"]');
const originalText = btn.innerHTML;
@@ -1644,6 +1647,12 @@
handleFormSubmit('podForm', '/api/v1/scaffold/pod');
function currentTargetModules() {
return Array.from(document.querySelectorAll('#targetModuleSelect option'))
.map(option => option.value.trim())
.filter(moduleName => /^dat-was-[a-z0-9-]+$/.test(moduleName));
}
async function createPodManifestDraft(button) {
const description = document.getElementById('podDescription').value.trim();
if (!description) { alert('Pod 업무 설명을 입력해주세요.'); return; }
@@ -1651,7 +1660,12 @@
try {
const response = await fetch('/api/v1/scaffold/pod-draft', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({description, moduleName: document.querySelector('#podForm [name="moduleName"]').value, model: document.getElementById('podAiModelSelect').value})
body: JSON.stringify({
description,
moduleName: document.querySelector('#podForm [name="moduleName"]').value,
targetModules: JSON.stringify(currentTargetModules()),
model: document.getElementById('podAiModelSelect').value
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'AI Manifest 생성 실패');
@@ -2281,7 +2295,14 @@
form.elements.categoryKey.value = result.categoryKey || '';
if (typeof loadUseCasesForSelection === 'function') loadUseCasesForSelection();
}
form.elements.routingType.value = result.routingType || 'HTTP';
const routingType = String(result.routingType || 'MCI').trim().toUpperCase();
form.elements.routingType.value = routingType === 'HTTP' ? 'HTTP' : 'MCI';
const useCaseSelect = document.getElementById('toolGroupUseCaseSelect');
const useCaseNameInput = document.getElementById('toolGroupUseCaseName');
if (useCaseSelect.value === '' && !useCaseNameInput.value.trim() && result.baseName) {
useCaseNameInput.value = result.baseName;
useCaseNameInput.readOnly = false;
}
form.elements.httpApiName.value = result.httpApiName || '';
form.elements.functionDescription.value = result.functionDescription || '';
form.elements.displayDescription.value = result.displayDescription || '';

View File

@@ -12,11 +12,14 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.nio.file.Files;
import java.util.List;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -83,7 +86,7 @@ class ScaffoldingControllerToolDraftTest {
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(responseSpec);
when(responseSpec.content()).thenReturn("""
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","functionDescription":"고객 계약의 현재 상태를 조회한다.","displayDescription":"고객 계약 상태 조회","whenToUse":"고객번호로 계약 상태 확인을 요청할 때 사용한다.","whenNotToUse":"계약 변경 또는 해지를 요청할 때는 사용하지 않는다.","ioLimits":"고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다.","exampleQueries":["고객 C123의 계약 상태를 알려줘","C123 계약이 정상인지 확인해줘","고객번호 C123 계약 조회해줘"],"tags":["contract","status","search"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"customerId","type":"String","description":"고객번호","examples":["C123"],"required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","examples":["SUCCESS"],"required":true}]}
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"MCI","httpApiName":"contract-status","functionDescription":"고객 계약의 현재 상태를 조회한다.","displayDescription":"고객 계약 상태 조회","whenToUse":"고객번호로 계약 상태 확인을 요청할 때 사용한다.","whenNotToUse":"계약 변경 또는 해지를 요청할 때는 사용하지 않는다.","ioLimits":"고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다.","exampleQueries":["고객 C123의 계약 상태를 알려줘","C123 계약이 정상인지 확인해줘","고객번호 C123 계약 조회해줘"],"tags":["contract","status","search"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"customerId","type":"String","description":"고객번호","examples":["C123"],"required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","examples":["SUCCESS"],"required":true}]}
""");
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ScaffoldingController(builder, new ObjectMapper()))
@@ -95,6 +98,7 @@ class ScaffoldingControllerToolDraftTest {
.content("{\"description\":\"고객번호로 계약 상태를 조회\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.baseName").value("CustomerContractStatus"))
.andExpect(jsonPath("$.routingType").value("MCI"))
.andExpect(jsonPath("$.functionDescription").value("고객 계약의 현재 상태를 조회한다."))
.andExpect(jsonPath("$.displayDescription").value("고객 계약 상태 조회"))
.andExpect(jsonPath("$.whenToUse").value("고객번호로 계약 상태 확인을 요청할 때 사용한다."))
@@ -104,5 +108,49 @@ class ScaffoldingControllerToolDraftTest {
.andExpect(jsonPath("$.tags[0]").value("contract"))
.andExpect(jsonPath("$.ownerOrg").value("MCP_TOOL"))
.andExpect(jsonPath("$.inputFields[0].name").value("customerId"));
org.mockito.ArgumentCaptor<String> promptCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
verify(requestSpec).user(promptCaptor.capture());
assertTrue(promptCaptor.getValue().contains("\"routingType\":\"MCI\""));
assertTrue(promptCaptor.getValue().contains("Default to MCI"));
}
@Test
void podDraftUsesTheCurrentTargetModuleOptionsForConfusableServers() {
ChatClient.Builder builder = mock(ChatClient.Builder.class);
ChatClient chatClient = mock(ChatClient.class);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class);
when(builder.build()).thenReturn(chatClient);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(responseSpec);
when(responseSpec.content()).thenReturn("""
mcp:
manifest:
routing-functions:
- name: route_to_dat-was-pro
server-id: dat-was-pro
category-key: pro
confusable-servers: [dat-was-hrd, dat-was-pay, dat-was-att]
""");
var response = new ScaffoldingController(builder, new ObjectMapper()).generatePodManifestDraft(java.util.Map.of(
"description", "상품 업무를 처리합니다.",
"moduleName", "dat-was-pro",
"targetModules", "[\"dat-was-cus\",\"dat-was-hr\",\"dat-was-sal\",\"dat-was-pro\",\"dat-was-sys\"]"));
assertEquals(org.springframework.http.HttpStatus.OK, response.getStatusCode());
String manifest = (String) ((java.util.Map<?, ?>) response.getBody()).get("toolServiceManifest");
List<String> confusableServers = manifest.lines()
.map(String::trim)
.filter(line -> line.startsWith("- \"dat-was-"))
.map(line -> line.substring(3, line.length() - 1))
.toList();
assertEquals(List.of("dat-was-cus", "dat-was-hr", "dat-was-sal", "dat-was-sys"), confusableServers);
assertFalse(manifest.contains("dat-was-hrd"), manifest);
assertFalse(manifest.contains("dat-was-pay"), manifest);
assertFalse(manifest.contains("dat-was-att"), manifest);
}
}

View File

@@ -1,5 +1,7 @@
package io.shinhanlife.dat.lib.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -8,10 +10,20 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
public class PodScaffolder {
private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory());
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
@@ -55,10 +67,15 @@ public class PodScaffolder {
public static String scaffoldPod(String moduleName, String portStr, String shortName, String author,
String createDate, String toolServiceManifest) throws IOException {
return scaffoldPod(moduleName, portStr, shortName, author, createDate, toolServiceManifest, null);
}
public static String scaffoldPod(String moduleName, String portStr, String shortName, String author,
String createDate, String toolServiceManifest, List<String> targetModules) throws IOException {
String envSourceDir = System.getProperty("AXHUB_SOURCE_DIR");
if (envSourceDir == null || envSourceDir.isBlank()) envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
return scaffoldPod(rootDir, moduleName, portStr, shortName, author, createDate, toolServiceManifest);
return scaffoldPod(rootDir, moduleName, portStr, shortName, author, createDate, toolServiceManifest, targetModules);
}
static String scaffoldPod(Path rootDir, String moduleName, String portStr, String shortName,
@@ -68,6 +85,11 @@ public class PodScaffolder {
static String scaffoldPod(Path rootDir, String moduleName, String portStr, String shortName,
String author, String createDate, String toolServiceManifest) throws IOException {
return scaffoldPod(rootDir, moduleName, portStr, shortName, author, createDate, toolServiceManifest, null);
}
static String scaffoldPod(Path rootDir, String moduleName, String portStr, String shortName,
String author, String createDate, String toolServiceManifest, List<String> targetModules) throws IOException {
Path modulePath = rootDir.resolve(Paths.get(moduleName));
if (Files.exists(modulePath)) {
return "[오류] 이미 존재하는 모듈입니다: " + moduleName;
@@ -166,8 +188,11 @@ public class PodScaffolder {
TESTER-DEV: ALL
""".formatted(portStr, moduleName, moduleName.replace("dap-", ""));
writeUtf8(resPath.resolve("application.yml"), applicationYml);
String manifest = toolServiceManifest == null || toolServiceManifest.isBlank()
? defaultToolServiceManifest(moduleName) : toolServiceManifest.trim() + System.lineSeparator();
String manifest = normalizeToolServiceManifest(
toolServiceManifest == null || toolServiceManifest.isBlank()
? defaultToolServiceManifest(moduleName)
: toolServiceManifest,
rootDir, moduleName, shortName, targetModules);
writeUtf8(resPath.resolve("tool-service-manifest.yml"), manifest);
String applicationLocalYml = """
@@ -177,6 +202,7 @@ public class PodScaffolder {
activate:
on-profile: local
import:
- classpath:application-core-local.yml
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-local.yml
datasource:
@@ -227,6 +253,7 @@ public class PodScaffolder {
activate:
on-profile: dev
import:
- classpath:application-core-dev.yml
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-dev.yml
@@ -276,6 +303,7 @@ public class PodScaffolder {
activate:
on-profile: test
import:
- classpath:application-core-test.yml
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-test.yml
@@ -296,6 +324,7 @@ public class PodScaffolder {
activate:
on-profile: prod
import:
- classpath:application-core-prod.yml
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-prod.yml
@@ -307,6 +336,11 @@ public class PodScaffolder {
""".formatted(portStr);
writeUtf8(resPath.resolve("application-prod.yml"), applicationProdYml);
// 환경별 파일은 Core/Glow 프로필 import만 유지한다. 업무 연동·인프라 주소는 각 환경의 Core/Glow 설정에서 관리한다.
for (String profile : List.of("local", "dev", "test", "prod")) {
writeUtf8(resPath.resolve("application-" + profile + ".yml"), applicationProfileYml(profile, portStr));
}
String logbackXml = """
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
@@ -348,7 +382,7 @@ public class PodScaffolder {
Path dockerComposePath = rootDir.resolve(Paths.get("docker-compose.yml"));
if (Files.exists(dockerComposePath)) {
String compose = Files.readString(dockerComposePath, StandardCharsets.UTF_8);
String serviceName = moduleName.replace("dap-", ""); // e.g. tool-payment
String serviceName = moduleName.replaceFirst("^dat-was-", "was-");
if (!compose.contains(" " + serviceName + ":")) {
String newService = """
%s:
@@ -368,11 +402,18 @@ public class PodScaffolder {
- GLOW_COMMUNICATION_EXTMCI_PORT=8080
- GLOW_COMMUNICATION_EAI_HOST=http://mci-mock
- GLOW_COMMUNICATION_EAI_PORT=8080
- SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local}
""".formatted(serviceName, moduleName, portStr, portStr, serviceName, portStr);
Files.writeString(dockerComposePath, System.lineSeparator() + newService, StandardCharsets.UTF_8, StandardOpenOption.APPEND);
}
}
List<String> updatedManifests = updateReciprocalConfusableServers(rootDir, moduleName, manifest);
if (!updatedManifests.isEmpty()) {
log.append("[추가] 기존 Pod confusable-servers 갱신: ")
.append(String.join(", ", updatedManifests)).append("\n");
}
log.append("\n=========================================\n");
log.append(" Pod Scaffolding Complete! \n");
log.append("=========================================\n");
@@ -408,6 +449,155 @@ public class PodScaffolder {
""".formatted(moduleName, key, key, moduleName, key, key, key, key);
}
private static String applicationProfileYml(String profile, String port) {
return """
server:
port: ${PORT:%s}
spring:
config:
activate:
on-profile: %s
import:
- classpath:application-core-%s.yml
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-%s.yml
""".formatted(port, profile, profile, profile);
}
@SuppressWarnings("unchecked")
public static String normalizeToolServiceManifest(String source, String moduleName, List<String> targetModules)
throws IOException {
return normalizeToolServiceManifest(source, null, moduleName,
moduleName.replaceFirst("^dat-was-", ""), targetModules);
}
private static String normalizeToolServiceManifest(String source, Path rootDir, String moduleName, String categoryKey,
List<String> targetModules)
throws IOException {
Map<String, Object> root = YAML_MAPPER.readValue(source, Map.class);
if (!(root.get("mcp") instanceof Map<?, ?> mcp)
|| !(mcp.get("manifest") instanceof Map<?, ?> manifest)) {
throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다.");
}
Object routingFunctions = manifest.get("routing-functions");
Map<String, Object> routingFunction = routingFunction(routingFunctions);
if (routingFunction == null) {
throw new IOException("tool-service-manifest.yml의 routing-functions에 라우팅 함수가 없습니다.");
}
routingFunction.put("name", "route_to_" + moduleName);
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));
return YAML_MAPPER.writeValueAsString(root);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> routingFunction(Object routingFunctions) {
if (routingFunctions instanceof List<?> functions
&& !functions.isEmpty()
&& functions.getFirst() instanceof Map<?, ?> function) {
return new java.util.LinkedHashMap<>((Map<String, Object>) function);
}
if (routingFunctions instanceof Map<?, ?> functionMap) {
if (functionMap.containsKey("name")) {
return new java.util.LinkedHashMap<>((Map<String, Object>) functionMap);
}
for (Object value : functionMap.values()) {
if (value instanceof Map<?, ?> function) {
return new java.util.LinkedHashMap<>((Map<String, Object>) function);
}
}
}
return null;
}
private static List<String> existingPodModules(Path rootDir, String moduleName) throws IOException {
try (Stream<Path> paths = Files.list(rootDir)) {
return paths.filter(Files::isDirectory)
.map(path -> path.getFileName().toString())
.filter(name -> name.matches("^dat-was-[a-z0-9-]+$"))
.filter(name -> !name.equals("dat-was-lib"))
.filter(name -> !name.equals(moduleName))
.sorted()
.toList();
}
}
private static List<String> allowedTargetModules(Path rootDir, String moduleName, List<String> targetModules)
throws IOException {
List<String> candidates = targetModules == null || targetModules.isEmpty()
? existingPodModules(rootDir, moduleName)
: targetModules;
LinkedHashSet<String> allowed = new LinkedHashSet<>();
for (String candidate : candidates) {
String normalized = candidate == null ? "" : candidate.trim();
if (normalized.matches("^dat-was-[a-z0-9-]+$") && !normalized.equals(moduleName)) {
allowed.add(normalized);
}
}
return List.copyOf(allowed);
}
private static List<String> updateReciprocalConfusableServers(Path rootDir, String moduleName,
String newManifest) throws IOException {
List<String> updated = new ArrayList<>();
for (String target : extractConfusableServers(newManifest)) {
if (target.equals(moduleName) || !target.matches("^dat-was-[a-z0-9-]+$")) continue;
Path path = rootDir.resolve(target).resolve("src/main/resources/tool-service-manifest.yml");
if (!Files.isRegularFile(path)) continue;
String before = Files.readString(path, StandardCharsets.UTF_8);
String after = addConfusableServer(before, moduleName);
if (!before.equals(after)) {
writeUtf8(path, after);
updated.add(rootDir.relativize(path).toString().replace('\\', '/'));
}
}
return updated;
}
@SuppressWarnings("unchecked")
private static List<String> extractConfusableServers(String manifest) throws IOException {
Map<String, Object> root = YAML_MAPPER.readValue(manifest, Map.class);
Object mcpValue = root.get("mcp");
if (!(mcpValue instanceof Map<?, ?> mcp)) return List.of();
Object manifestValue = mcp.get("manifest");
if (!(manifestValue instanceof Map<?, ?> manifestMap)) return List.of();
Object functionsValue = manifestMap.get("routing-functions");
if (!(functionsValue instanceof List<?> functions) || functions.isEmpty()
|| !(functions.getFirst() instanceof Map<?, ?> function)) return List.of();
Object serversValue = function.get("confusable-servers");
if (!(serversValue instanceof Collection<?> servers)) return List.of();
LinkedHashSet<String> values = new LinkedHashSet<>();
for (Object value : servers) {
String normalized = value == null ? "" : value.toString().trim();
if (!normalized.isBlank()) values.add(normalized);
}
return List.copyOf(values);
}
private static String addConfusableServer(String manifest, String moduleName) {
Pattern pattern = Pattern.compile("(?m)^(\\s*)confusable-servers:\\s*\\[([^]]*)]\\s*$");
Matcher matcher = pattern.matcher(manifest);
if (matcher.find()) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (String value : matcher.group(2).split(",")) {
String normalized = value.trim();
if (!normalized.isBlank()) values.add(normalized);
}
if (!values.add(moduleName)) return manifest;
String replacement = matcher.group(1) + "confusable-servers: [" + String.join(", ", values) + "]";
return matcher.replaceFirst(Matcher.quoteReplacement(replacement));
}
Matcher category = Pattern.compile("(?m)^(\\s*)category-key:[^\\r\\n]*$").matcher(manifest);
if (!category.find()) return manifest;
String replacement = category.group() + System.lineSeparator() + category.group(1)
+ "confusable-servers: [" + moduleName + "]";
return category.replaceFirst(Matcher.quoteReplacement(replacement));
}
private static String capitalize(String str) {
if (str == null || str.isEmpty()) return str;
return str.substring(0, 1).toUpperCase() + str.substring(1);

View File

@@ -110,13 +110,11 @@ public class ToolScaffolder {
Path dtoDir = sourceRoot.resolve(Paths.get("biz", group, "dto"));
Path converterDir = sourceRoot.resolve(Paths.get("biz", group, "converter"));
Path definitionDir = moduleRoot.resolve(Paths.get("src", "main", "resources", "tool-definitions", group));
Path mockDir = moduleRoot.resolve(Paths.get("src", "main", "resources", "mock-responses"));
Files.createDirectories(useCaseDir);
Files.createDirectories(implDir);
Files.createDirectories(dtoDir);
Files.createDirectories(converterDir);
Files.createDirectories(definitionDir);
Files.createDirectories(mockDir);
String bizPackage = BASE_PACKAGE + ".biz." + group;
Path useCaseFile = useCaseDir.resolve(useCaseBaseName + "UseCase.java");
@@ -139,7 +137,7 @@ public class ToolScaffolder {
.append("[Usecase Interface] ").append(useCaseFile).append("\n")
.append("[Usecase Impl] ").append(useCaseImplFile).append("\n");
for (ToolMethodDefinition tool : tools) {
writeGroupedToolFiles(moduleRoot, sourceRoot, dtoDir, definitionDir, mockDir, bizPackage, tool, moduleName, log);
writeGroupedToolFiles(moduleRoot, sourceRoot, dtoDir, definitionDir, bizPackage, tool, moduleName, log);
if ("HTTP".equalsIgnoreCase(tool.routingType())) {
ensureLocalHttpApiConfiguration(moduleRoot, tool.httpApiName(),
toToolName(moduleName, tool.group(), toPascalCase(tool.baseName())));
@@ -189,8 +187,22 @@ public class ToolScaffolder {
return code.toLowerCase(Locale.ROOT);
}
private static String mciClientPrefix(String clientSystemCode) {
String normalized = clientSystemCode == null ? "" : clientSystemCode.trim().toLowerCase(Locale.ROOT);
return normalized.length() == 9 ? normalized.substring(1, 5) : normalized;
}
private static String mciClientClassName(String clientSystemCode) {
return "Mci" + toPascalCase(mciClientPrefix(clientSystemCode)) + "Client";
}
private static String mciClientVariable(String clientSystemCode) {
String prefix = toPascalCase(mciClientPrefix(clientSystemCode));
return "mci" + prefix + "Client";
}
private static void writeGroupedToolFiles(Path moduleRoot, Path sourceRoot, Path dtoDir, Path definitionDir,
Path mockDir, String bizPackage, ToolMethodDefinition tool,
String bizPackage, ToolMethodDefinition tool,
String moduleName, StringBuilder log) throws IOException {
String baseName = toPascalCase(tool.baseName());
boolean mci = "MCI".equalsIgnoreCase(tool.routingType());
@@ -215,8 +227,7 @@ public class ToolScaffolder {
writeStructuredFieldTypes(ioDir, ioPackage + ".io", ioPrefix + "_O", tool.outputFields());
String sysCode = tool.clientSystemCode();
if (sysCode != null && (sysCode.length() == 4 || sysCode.length() == 9)) {
String clientPrefix = (sysCode.length() == 9 ? sysCode.substring(1, 5) : sysCode).toLowerCase(java.util.Locale.ROOT);
String clientCap = toPascalCase(clientPrefix);
String clientCap = toPascalCase(mciClientPrefix(sysCode));
writeUtf8(clientDir.resolve("Mci" + clientCap + "Client.java"),
"package " + ioPackage + ";\n\nimport io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;\nimport io.shinhanlife.glow.communication.dto.Transfer;\nimport lombok.RequiredArgsConstructor;\nimport org.springframework.stereotype.Component;\n\n@Component\n@RequiredArgsConstructor\npublic class Mci" + clientCap + "Client {\n private final AxhubMciComponent mci;\n\n public <O> Transfer<O> callTo(String interfaceId, String dummy, Object mciReq, Class<O> resType) throws Exception {\n return mci.callTo(interfaceId, dummy, mciReq, resType);\n }\n}\n");
} else {
@@ -238,7 +249,6 @@ public class ToolScaffolder {
}
String toolName = toToolName(moduleName, tool.group(), baseName);
writeUtf8(mockDir.resolve(toolName + ".json"), mockResponseContent(tool.outputFields()));
log.append("[Tool] ").append(toolName).append(" -> ").append(clientDir.resolve(baseName + "Client.java")).append("\n");
}
@@ -310,11 +320,8 @@ public class ToolScaffolder {
boolean mci = "MCI".equalsIgnoreCase(tool.routingType());
String ioPrefix = (tool.clientSystemCode() != null && !tool.clientSystemCode().isBlank()) ? tool.clientSystemCode().toUpperCase() : tool.interfaceId();
if (mci) {
String sysCode = tool.clientSystemCode();
String clientPrefix = (sysCode != null && sysCode.length() == 9) ? sysCode.substring(1, 5) : sysCode;
clientPrefix = clientPrefix.toLowerCase(Locale.ROOT);
clientClassName = "Mci" + clientPrefix.substring(0, 1).toUpperCase(Locale.ROOT) + clientPrefix.substring(1) + "Client";
clientVariable = "mci" + clientPrefix.substring(0, 1).toUpperCase(Locale.ROOT) + clientPrefix.substring(1) + "Client";
clientClassName = mciClientClassName(tool.clientSystemCode());
clientVariable = mciClientVariable(tool.clientSystemCode());
} else {
clientClassName = baseName + "Client";
clientVariable = Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1) + "Client";
@@ -328,19 +335,12 @@ public class ToolScaffolder {
.append("import ").append(integrationPackage).append(".").append(clientClassName).append(";\n")
.append("import ").append(integrationPackage).append(".io.").append(mci ? ioPrefix + "_I;\n" : baseName + "HttpRequest;\n")
.append("import ").append(integrationPackage).append(".io.").append(mci ? ioPrefix + "_O;\n" : baseName + "HttpResponse;\n");
if (mci) imports.append("import io.shinhanlife.glow.communication.dto.Transfer;\n");
if (!fields.toString().contains(" " + clientVariable + ";")) {
fields.append(" private final ").append(clientClassName).append(" ").append(clientVariable).append(";\n");
}
fields.append(" private final ").append(baseName).append("Converter ").append(converterVariable).append(";\n");
methods.append(" @Override\n public ").append(baseName).append("Response ").append(tool.methodName())
.append("(").append(baseName).append("Request req) {\n")
.append(" ").append(mci ? ioPrefix + "_I" : baseName + "HttpRequest").append(" request = ").append(converterVariable).append(".toRequest(req);\n")
.append(" ").append(mci ? ioPrefix + "_O" : baseName + "HttpResponse").append(" response = ").append(clientVariable)
.append(mci ? ".callTo(\"" + tool.interfaceId() + "\", null, request, " + ioPrefix + "_O.class).getBody();\n" : ".call(request, " + baseName + "HttpResponse.class);\n")
.append(" ").append(baseName).append("Response toolResponse = ").append(converterVariable).append(".toResponse(response);\n")
.append(" if (toolResponse == null) toolResponse = new ").append(baseName).append("Response();\n")
.append(" toolResponse.setResultCode(\"SUCCESS\");\n")
.append(" return toolResponse;\n }\n\n");
methods.append(groupedToolMethodContent(tool, baseName, converterVariable, clientVariable, ioPrefix, mci));
}
return "package " + bizPackage + ".usecase.impl;\n\n"
+ "import " + bizPackage + ".usecase." + useCaseBaseName + "UseCase;\n"
@@ -349,6 +349,56 @@ public class ToolScaffolder {
+ fields + "\n" + methods + "}\n";
}
private static String groupedToolMethodContent(ToolMethodDefinition tool, String baseName,
String converterVariable, String clientVariable,
String ioPrefix, boolean mci) {
if (mci) {
return """
@Override
public %sResponse %s(%sRequest req) {
%s_I request = %s.toRequest(req);
try {
Transfer<%s_O> transfer = %s.callTo("%s", null, request, %s_O.class);
if (transfer == null || transfer.getBody() == null) {
%sResponse errorResponse = new %sResponse();
errorResponse.setResultCode("ERROR");
return errorResponse;
}
%sResponse toolResponse = %s.toResponse(transfer.getBody());
if (toolResponse == null) {
toolResponse = new %sResponse();
toolResponse.setResultCode("ERROR");
return toolResponse;
}
toolResponse.setResultCode("SUCCESS");
return toolResponse;
} catch (Exception e) {
%sResponse errorResponse = new %sResponse();
errorResponse.setResultCode("ERROR");
errorResponse.setResultMessage("MCI call failed.");
return errorResponse;
}
}
""".formatted(baseName, tool.methodName(), baseName, ioPrefix, converterVariable, ioPrefix,
clientVariable, tool.interfaceId(), ioPrefix, baseName, baseName, baseName, converterVariable, baseName,
baseName, baseName);
}
return """
@Override
public %sResponse %s(%sRequest req) {
%sHttpRequest request = %s.toRequest(req);
%sHttpResponse response = %s.call(request, %sHttpResponse.class);
%sResponse toolResponse = %s.toResponse(response);
if (toolResponse == null) toolResponse = new %sResponse();
toolResponse.setResultCode("SUCCESS");
return toolResponse;
}
""".formatted(baseName, tool.methodName(), baseName, baseName, converterVariable, baseName,
clientVariable, baseName, baseName, converterVariable, baseName);
}
private static String groupedConverterContent(String bizPackage, String useCaseBaseName,
List<ToolMethodDefinition> tools) {
return "package " + bizPackage + ".converter;\n\n/** Per-Tool converters are generated beside this compatibility marker. */\n"
@@ -456,9 +506,8 @@ public class ToolScaffolder {
String clientClassName;
String clientVariable;
if (mci) {
String sysCode = tool.clientSystemCode().toLowerCase(Locale.ROOT);
clientClassName = "Mci" + sysCode.substring(0, 1).toUpperCase(Locale.ROOT) + sysCode.substring(1) + "Client";
clientVariable = "mci" + sysCode.substring(0, 1).toUpperCase(Locale.ROOT) + sysCode.substring(1) + "Client";
clientClassName = mciClientClassName(tool.clientSystemCode());
clientVariable = mciClientVariable(tool.clientSystemCode());
} else {
clientClassName = baseName + "Client";
clientVariable = Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1) + "Client";
@@ -471,6 +520,7 @@ public class ToolScaffolder {
implementation = addImport(implementation, "import " + integrationPackage + "." + clientClassName + ";");
implementation = addImport(implementation, "import " + integrationPackage + ".io." + requestIo + ";");
implementation = addImport(implementation, "import " + integrationPackage + ".io." + responseIo + ";");
if (mci) implementation = addImport(implementation, "import io.shinhanlife.glow.communication.dto.Transfer;");
implementation = addImport(implementation, "import lombok.RequiredArgsConstructor;");
if (!implementation.contains("@RequiredArgsConstructor")) {
implementation = implementation.replaceFirst("public class ", "@RequiredArgsConstructor\npublic class ");
@@ -479,16 +529,7 @@ public class ToolScaffolder {
implementation = insertConstructorField(implementation, " private final " + clientClassName + " " + clientVariable + ";");
}
implementation = insertConstructorField(implementation, " private final " + baseName + "Converter " + converterVariable + ";");
String call = mci
? clientVariable + ".callTo(\"" + tool.interfaceId() + "\", null, request, " + responseIo + ".class).getBody()"
: clientVariable + ".call(request, " + responseIo + ".class)";
String method = "\n @Override\n public " + responseType + " " + methodName + "(" + requestType + " req) {\n"
+ " " + requestIo + " request = " + converterVariable + ".toRequest(req);\n"
+ " " + responseIo + " response = " + call + ";\n"
+ " " + responseType + " toolResponse = " + converterVariable + ".toResponse(response);\n"
+ " if (toolResponse == null) toolResponse = new " + responseType + "();\n"
+ " toolResponse.setResultCode(\"SUCCESS\");\n"
+ " return toolResponse;\n }\n";
String method = groupedToolMethodContent(tool, baseName, converterVariable, clientVariable, ioPrefix, mci);
implementation = insertBeforeLastBrace(implementation, method);
}
writeUtf8(useCaseFile, useCase);
@@ -568,7 +609,7 @@ public class ToolScaffolder {
if (group.isEmpty()) group = "COMMON";
String routingType = getOrAsk(args, 5, scanner, "6. Routing type (HTTP, TCP, MCI, EAI): ");
if (routingType.trim().isEmpty()) {
routingType = "HTTP";
routingType = "MCI";
}
String moduleName = getOrAsk(args, 6, scanner, "7. Target module (default dat-was-cus): ");
if (moduleName.trim().isEmpty()) {
@@ -651,6 +692,9 @@ public class ToolScaffolder {
baseName = toPascalCase(baseName);
title = title == null || title.isBlank() ? baseName : title.trim();
description = description == null ? "" : description.trim();
definitionOptions = definitionOptions == null
? new ToolDefinitionOptions(null, null, null, null, null, List.of(), List.of(), null)
: definitionOptions;
httpApiName = httpApiName == null || httpApiName.isBlank() ? toKebabCase(baseName) : httpApiName.trim();
String envSourceDir = System.getProperty("AXHUB_SOURCE_DIR");
if (envSourceDir == null) {
@@ -1433,28 +1477,11 @@ public class ToolScaffolder {
log.append("[Output Schema] ").append(schemaDir.resolve(outputSchemaFileName)).append("\n");
}
String mockResponse = mockResponseContent(outputFields);
// Response JSON mock files are intentionally not generated. Runtime response contracts are represented by DTOs.
if (isHttp) {
Path moduleRoot = rootDir.resolve(moduleName).toAbsolutePath().normalize();
Path projectRoot = moduleRoot.getParent();
Path wireMockBodyPath = projectRoot.resolve(Paths.get("mci-mock", "__files", toolName + ".json"));
Path wireMockMappingPath = projectRoot.resolve(Paths.get("mci-mock", "mappings", toolName + ".json"));
Path podMockResponsePath = moduleRoot.resolve(Paths.get("src/main/resources/mock-responses", toolName + ".json"));
Files.createDirectories(wireMockBodyPath.getParent());
Files.createDirectories(wireMockMappingPath.getParent());
Files.createDirectories(podMockResponsePath.getParent());
writeUtf8(wireMockBodyPath, mockResponse);
writeUtf8(wireMockMappingPath, wireMockMappingContent(interfaceId, wireMockBodyPath.getFileName().toString()));
writeUtf8(podMockResponsePath, mockResponse);
ensureLocalHttpApiConfiguration(projectRoot, httpApiName, toolName);
log.append("[WireMock Response] ").append(wireMockBodyPath).append("\n");
log.append("[WireMock Mapping] ").append(wireMockMappingPath).append("\n");
log.append("[Pod Mock Response] ").append(podMockResponsePath).append("\n");
} else {
Path mockResponsePath = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mock-responses", toolName + ".json"));
Files.createDirectories(mockResponsePath.getParent());
writeUtf8(mockResponsePath, mockResponse);
log.append("[Mock Response] ").append(mockResponsePath).append("\n");
}
Path generatedTestDir = rootDir.resolve(Paths.get(moduleName, "src/test/java/io/shinhanlife/dat/mcc/biz", group.toLowerCase(), "usecase"));
@@ -2039,10 +2066,10 @@ public class ToolScaffolder {
continue;
}
String type = javaFieldType(field, ownerClass);
String description = richDescription(field).replace("\"", "\\\"");
String description = javaText(richDescription(field));
String example = "";
if (field.examples() != null && !field.examples().isEmpty()) {
example = field.examples().get(0).replace("\"", "\\\"");
example = javaText(field.examples().get(0));
}
source.append(" @Schema(description = \"").append(description).append("\"");
if (!example.isEmpty()) {
@@ -2053,7 +2080,7 @@ public class ToolScaffolder {
}
source.append(")\n");
if (field.pattern() != null && !field.pattern().isBlank()) {
source.append(" @Pattern(regexp = \"").append(field.pattern().replace("\"", "\\\"")).append("\")\n");
source.append(" @Pattern(regexp = \"").append(javaText(field.pattern())).append("\")\n");
}
source.append(" private ").append(type).append(' ').append(fieldName).append(";\n\n");
}
@@ -2065,9 +2092,6 @@ public class ToolScaffolder {
if (field.pattern() != null && !field.pattern().isBlank()) {
desc += " (형식: " + field.pattern() + ")";
}
if (field.examples() != null && !field.examples().isEmpty()) {
desc += " (예시: " + String.join(", ", field.examples()) + ")";
}
return desc.trim();
}

View File

@@ -1,10 +1,15 @@
package io.shinhanlife.dat.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.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -37,18 +42,76 @@ class PodScaffolderTest {
assertTrue(dockerfile.contains("EXPOSE 8099"));
assertTrue(Files.exists(resources.resolve("application-test.yml")));
assertTrue(Files.exists(resources.resolve("application-prod.yml")));
assertTrue(Files.readString(resources.resolve("application-test.yml"))
.contains("${AXHUB_GATEWAY_URL}"));
assertTrue(Files.readString(resources.resolve("application-prod.yml"))
.contains("${AXHUB_TOOL_URL}"));
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"));
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
.contains("classpath:glow/application-glow-dev.yml"));
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
.contains("classpath:application-core-dev.yml"));
assertTrue(Files.readString(resources.resolve("application-test.yml"))
.contains("classpath:glow/application-glow-test.yml"));
assertTrue(Files.readString(resources.resolve("application-test.yml"))
.contains("classpath:application-core-test.yml"));
assertTrue(Files.readString(resources.resolve("application-prod.yml"))
.contains("classpath:glow/application-glow-prod.yml"));
assertTrue(Files.readString(resources.resolve("application-prod.yml"))
.contains("classpath:application-core-prod.yml"));
}
@Test
void generatesOnlyProfileImportsInEnvironmentFiles() throws Exception {
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")) {
String content = Files.readString(resources.resolve("application-" + profile + ".yml"));
assertTrue(content.contains("port: ${PORT:8087}"));
assertTrue(content.contains("on-profile: " + profile));
assertTrue(content.contains("classpath:application-core-" + profile + ".yml"));
assertTrue(content.contains("classpath:glow/application-glow.yml"));
assertTrue(content.contains("classpath:glow/application-glow-" + profile + ".yml"));
assertFalse(content.contains("axhub:"));
assertFalse(content.contains("eims:"));
assertFalse(content.contains("shinhan:"));
}
}
@Test
void canonicalizesEhrManifestAndUsesAllExistingPodsAsConfusableServers() throws Exception {
Files.writeString(root.resolve("docker-compose.yml"), "services:\n");
Files.createDirectories(root.resolve("dat-was-sal"));
Files.createDirectories(root.resolve("dat-was-pro"));
Files.createDirectories(root.resolve("dat-was-sys"));
String requestedManifest = """
mcp:
manifest:
routing-functions:
route_to_ehr:
name: route_to_ehr
server-id: ehr-service
category-key: ehr
select-if: 청구 또는 지급 업무 요청인 경우
reject-if: 고객 상담이 주된 요청인 경우
confusable-servers: [ehr-backend, ehr-core]
""";
PodScaffolder.scaffoldPod(root, "dat-was-ehr", "8087", "ehr", "tester", "2026.09.03", requestedManifest);
String manifest = Files.readString(root.resolve("dat-was-ehr/src/main/resources/tool-service-manifest.yml"));
Map<String, Object> routingFunction = routingFunction(manifest);
assertEquals("route_to_dat-was-ehr", routingFunction.get("name"));
assertEquals("dat-was-ehr", routingFunction.get("server-id"));
assertEquals("ehr", routingFunction.get("category-key"));
assertEquals("청구 또는 지급 업무 요청인 경우", routingFunction.get("select-if"));
assertEquals("고객 상담이 주된 요청인 경우", routingFunction.get("reject-if"));
assertEquals(List.of("dat-was-pro", "dat-was-sal", "dat-was-sys"), routingFunction.get("confusable-servers"));
String compose = Files.readString(root.resolve("docker-compose.yml"));
assertTrue(compose.contains(" was-ehr:"));
assertFalse(compose.contains(" dat-was-ehr:"));
assertTrue(compose.contains("SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local}"));
}
@Test
@@ -63,4 +126,55 @@ class PodScaffolderTest {
assertFalse(compose.contains("depends_on:\n - redis"));
assertFalse(compose.contains("SPRING_REDIS_HOST=redis"));
}
@Test
void addsNewPodToReferencedExistingConfusableServerManifests() throws Exception {
Path proManifest = existingManifest("dat-was-pro", "dat-was-cus");
Path cusManifest = existingManifest("dat-was-cus", "dat-was-pro");
String newManifest = """
mcp:
manifest:
routing-functions:
- name: route_to_dat-was-cla
server-id: dat-was-cla
category-key: cla
confusable-servers:
- dat-was-pro
- dat-was-cus
- dat-was-missing
- dat-was-cla
""";
String result = PodScaffolder.scaffoldPod(root, "dat-was-cla", "8098", "cla", "tester",
"2026.09.02", newManifest);
assertTrue(Files.readString(proManifest).contains("confusable-servers: [dat-was-cus, dat-was-cla]"));
assertTrue(Files.readString(cusManifest).contains("confusable-servers: [dat-was-pro, dat-was-cla]"));
assertFalse(Files.exists(root.resolve("dat-was-missing")));
assertTrue(result.contains("dat-was-pro/src/main/resources/tool-service-manifest.yml"), result);
assertTrue(result.contains("dat-was-cus/src/main/resources/tool-service-manifest.yml"), result);
}
private Path existingManifest(String moduleName, String confusableServer) throws Exception {
Path path = root.resolve(moduleName + "/src/main/resources/tool-service-manifest.yml");
Files.createDirectories(path.getParent());
Files.writeString(path, """
mcp:
manifest:
routing-functions:
- name: route_to_%s
server-id: %s
category-key: %s
confusable-servers: [%s]
""".formatted(moduleName, moduleName, moduleName.replace("dat-was-", ""), confusableServer));
return path;
}
@SuppressWarnings("unchecked")
private Map<String, Object> routingFunction(String manifest) throws Exception {
Map<String, Object> root = new ObjectMapper(new YAMLFactory()).readValue(manifest, Map.class);
Map<String, Object> mcp = (Map<String, Object>) root.get("mcp");
Map<String, Object> manifestNode = (Map<String, Object>) mcp.get("manifest");
return (Map<String, Object>) ((List<?>) manifestNode.get("routing-functions")).getFirst();
}
}

View File

@@ -67,6 +67,34 @@ class ToolScaffolderTest {
assertTrue(Files.readString(glowConfig).contains("- name: employee-search"));
}
@Test
void generatesExceptionSafeMciUseCaseWithoutResponseJsonOrExamplesInDescriptions() throws Exception {
String moduleName = root.resolve("dat-was-pro").toString();
List<ToolScaffolder.FieldDefinition> inputFields = List.of(
new ToolScaffolder.FieldDefinition("residentNumber", "String", "주민등록번호", List.of("900101-1234567"), "^\\d{6}-\\d{7}$", true));
List<ToolScaffolder.FieldDefinition> outputFields = List.of(
new ToolScaffolder.FieldDefinition("customerName", "String", "고객명", List.of("홍길동"), "", true));
ToolScaffolder.scaffoldUseCase("IndividualCustomer", moduleName, "tester", "2026.09.03", List.of(
new ToolScaffolder.ToolMethodDefinition(
"IndividualCustomerDetailInquiry", "individualCustomerDetailInquiry", "ONCSA1341",
"개인고객상세조회", "개인 고객 상세 정보를 조회합니다.", "pro", "MCI", false, "ONCSA1341", null,
inputFields, outputFields, null)));
Path sourceRoot = root.resolve("dat-was-pro/src/main/java/io/shinhanlife/dat/mcc");
String implementation = Files.readString(sourceRoot.resolve("biz/pro/usecase/impl/IndividualCustomerUseCaseImpl.java"));
String request = Files.readString(sourceRoot.resolve("biz/pro/dto/IndividualCustomerDetailInquiryRequest.java"));
Path mockResponse = root.resolve("dat-was-pro/src/main/resources/mock-responses/pro_individual_customer_detail_inquiry.json");
assertTrue(implementation.contains("try {"), implementation);
assertTrue(implementation.contains("} catch (Exception e) {"), implementation);
assertTrue(implementation.contains("errorResponse.setResultCode(\"ERROR\")"), implementation);
assertTrue(request.contains("@Pattern(regexp = \"^\\\\d{6}-\\\\d{7}$\")"), request);
assertTrue(request.contains("@Schema(description = \"주민등록번호 (형식: ^\\\\d{6}-\\\\d{7}$)\", example = \"900101-1234567\""), request);
assertFalse(request.contains("(예시:"), request);
assertFalse(Files.exists(mockResponse), mockResponse.toString());
}
@Test
void generatesEnumAndListFieldsInDtoSchemaAndMockResponse() throws Exception {
String moduleName = root.resolve("dat-was-claim").toString();
@@ -487,4 +515,28 @@ class ToolScaffolderTest {
assertTrue(impl.contains("private final MciCstmClient mciCstmClient;"), impl);
assertTrue(impl.contains("private final CustomerNoticeClient customerNoticeClient;"), impl);
}
@Test
void appendsMciToolWithSystemPrefixClientAndNullSafeResponseHandling() throws Exception {
String moduleName = root.resolve("dat-was-pro").toString();
ToolScaffolder.scaffoldUseCase("IndividualCustomer", moduleName, "tester", "2026.09.03", List.of(
new ToolScaffolder.ToolMethodDefinition("Initial", "initial", null, "초기 도구", "초기 도구", "pro", "HTTP",
false, null, "initial", List.of(), List.of(), null)));
ToolScaffolder.scaffoldUseCase("IndividualCustomer", moduleName, "tester", "2026.09.03", List.of(
new ToolScaffolder.ToolMethodDefinition("IndividualCustomerDetailInquiry", "inquiry", "LCHITP00001",
"개인 고객 상세 조회", "개인 고객 상세 정보를 조회합니다.", "pro", "MCI", false,
"ONCSG1341", null, List.of(), List.of(), null)));
Path implementationPath = root.resolve("dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/usecase/impl/IndividualCustomerUseCaseImpl.java");
String implementation = Files.readString(implementationPath);
assertTrue(implementation.contains("import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.g.MciNcsgClient;"), implementation);
assertTrue(implementation.contains("private final MciNcsgClient mciNcsgClient;"), implementation);
assertFalse(implementation.contains("MciOncsg1341Client"), implementation);
assertTrue(implementation.contains("Transfer<ONCSG1341_O> transfer"), implementation);
assertTrue(implementation.contains("transfer == null || transfer.getBody() == null"), implementation);
assertTrue(implementation.contains("setResultCode(\"ERROR\")"), implementation);
assertFalse(implementation.contains("LCHITP00001\", null, request, ONCSG1341_O.class).getBody()"), implementation);
}
}