From 693ddea18d597b22bb6c394994d79697a0dc9e6f Mon Sep 17 00:00:00 2001 From: jade Date: Thu, 3 Sep 2026 10:10:21 +0900 Subject: [PATCH] fix: normalize scaffold pod and tool generation --- .../presentation/ScaffoldingController.java | 30 ++- .../main/resources/static/admin/scaffold.html | 27 ++- .../ScaffoldingControllerToolDraftTest.java | 50 ++++- .../dat/lib/util/PodScaffolder.java | 198 +++++++++++++++++- .../dat/lib/util/ToolScaffolder.java | 142 +++++++------ .../dat/lib/util/PodScaffolderTest.java | 122 ++++++++++- .../dat/lib/util/ToolScaffolderTest.java | 52 +++++ 7 files changed, 544 insertions(+), 77 deletions(-) diff --git a/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java b/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java index 6444bdb1..8f2f6e22 100644 --- a/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java +++ b/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java @@ -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 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 targetModules(String rawTargetModules) throws Exception { + if (rawTargetModules == null || rawTargetModules.isBlank()) { + return List.of(); + } + List modules = objectMapper.readValue(rawTargetModules, new TypeReference>() { }); + 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 fields) { } diff --git a/dat-gateway/src/main/resources/static/admin/scaffold.html b/dat-gateway/src/main/resources/static/admin/scaffold.html index 8c8db1fc..b7a88a57 100644 --- a/dat-gateway/src/main/resources/static/admin/scaffold.html +++ b/dat-gateway/src/main/resources/static/admin/scaffold.html @@ -944,7 +944,7 @@
@@ -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 || ''; diff --git a/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java b/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java index b3d96dc9..8c3f8f1a 100644 --- a/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java +++ b/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java @@ -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 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 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); } } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java index 7cc310bd..0ea09574 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java @@ -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 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 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 = """ @@ -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 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 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 targetModules) + throws IOException { + Map 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 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) manifest).put("routing-functions", List.of(routingFunction)); + return YAML_MAPPER.writeValueAsString(root); + } + + @SuppressWarnings("unchecked") + private static Map routingFunction(Object routingFunctions) { + if (routingFunctions instanceof List functions + && !functions.isEmpty() + && functions.getFirst() instanceof Map function) { + return new java.util.LinkedHashMap<>((Map) function); + } + if (routingFunctions instanceof Map functionMap) { + if (functionMap.containsKey("name")) { + return new java.util.LinkedHashMap<>((Map) functionMap); + } + for (Object value : functionMap.values()) { + if (value instanceof Map function) { + return new java.util.LinkedHashMap<>((Map) function); + } + } + } + return null; + } + + private static List existingPodModules(Path rootDir, String moduleName) throws IOException { + try (Stream 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 allowedTargetModules(Path rootDir, String moduleName, List targetModules) + throws IOException { + List candidates = targetModules == null || targetModules.isEmpty() + ? existingPodModules(rootDir, moduleName) + : targetModules; + LinkedHashSet 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 updateReciprocalConfusableServers(Path rootDir, String moduleName, + String newManifest) throws IOException { + List 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 extractConfusableServers(String manifest) throws IOException { + Map 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 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 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); diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java index e801188e..ea3a9379 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java @@ -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 Transfer callTo(String interfaceId, String dummy, Object mciReq, Class 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 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(); } diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java index 1e163b2b..41086354 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java @@ -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 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 routingFunction(String manifest) throws Exception { + Map root = new ObjectMapper(new YAMLFactory()).readValue(manifest, Map.class); + Map mcp = (Map) root.get("mcp"); + Map manifestNode = (Map) mcp.get("manifest"); + return (Map) ((List) manifestNode.get("routing-functions")).getFirst(); + } } diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java index f5779540..5a636105 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java @@ -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 inputFields = List.of( + new ToolScaffolder.FieldDefinition("residentNumber", "String", "주민등록번호", List.of("900101-1234567"), "^\\d{6}-\\d{7}$", true)); + List 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 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); + } }