fix: normalize scaffold pod and tool generation
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 35s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 35s
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user