feat: add standalone pod module scaffolder
This commit is contained in:
15
README.md
15
README.md
@@ -125,6 +125,21 @@ docker compose up -d --build
|
|||||||
|
|
||||||
Docker Compose로 실행한 경우 Gateway 화면은 포트 `8281`을 사용합니다.
|
Docker Compose로 실행한 경우 Gateway 화면은 포트 `8281`을 사용합니다.
|
||||||
|
|
||||||
|
### Pod Module New: 독립 프로젝트 생성
|
||||||
|
|
||||||
|
Scaffold 화면의 `Pod Module New` 탭은 기존 Pod Module 생성 흐름과 별도로 동작합니다. 새 모듈명과 포트, workspace 경로를 입력하면 해당 workspace 바로 아래에 독립 Tool Pod 프로젝트를 생성합니다.
|
||||||
|
|
||||||
|
기본 workspace는 `C:\eGovFrameDev-4.3.1-64bit\workspace`이며, 예를 들어 모듈명으로 `dat-was-payment`를 입력하면 `C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-payment` 프로젝트가 만들어집니다. 모듈명은 `dat-was-`로 시작해야 하며, 이미 같은 폴더가 있으면 덮어쓰지 않고 생성 요청을 거절합니다.
|
||||||
|
|
||||||
|
생성된 프로젝트는 같은 workspace의 `dat-lib-datmt`를 Gradle composite build로 참조합니다. 생성 직후 프로젝트 폴더에서 다음 명령으로 컴파일할 수 있습니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-payment
|
||||||
|
.\gradlew.bat compileJava
|
||||||
|
```
|
||||||
|
|
||||||
|
이 기능은 기존 AX HUB 멀티 모듈 Pod나 기존 `Pod Module` 탭의 구성·소스를 수정하지 않습니다.
|
||||||
|
|
||||||
### Tool Pod Test Console
|
### Tool Pod Test Console
|
||||||
|
|
||||||
공통 정적 화면인 `tool-test-console.html`은 Tool Pod의 `/tool-manifest`를 읽어, 해당 Pod에 등록된 Tool과 `inputSchema`를 기준으로 요청 JSON을 만들어 직접 실행합니다.
|
공통 정적 화면인 `tool-test-console.html`은 Tool Pod의 `/tool-manifest`를 읽어, 해당 Pod에 등록된 Tool과 `inputSchema`를 기준으로 요청 JSON을 만들어 직접 실행합니다.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package io.shinhanlife.dat.mcg.presentation;
|
|||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
|
import io.shinhanlife.dat.lib.util.NewPodProjectScaffolder;
|
||||||
import io.shinhanlife.dat.lib.util.PodScaffolder;
|
import io.shinhanlife.dat.lib.util.PodScaffolder;
|
||||||
import io.shinhanlife.dat.lib.util.MciResponseScaffolder;
|
import io.shinhanlife.dat.lib.util.MciResponseScaffolder;
|
||||||
import io.shinhanlife.dat.lib.util.ToolScaffolder;
|
import io.shinhanlife.dat.lib.util.ToolScaffolder;
|
||||||
@@ -22,6 +23,8 @@ import io.shinhanlife.dat.lib.util.ToolSourceUpdater;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -51,6 +54,7 @@ public class ScaffoldingController {
|
|||||||
"cohere/north-mini-code:free");
|
"cohere/north-mini-code:free");
|
||||||
|
|
||||||
static final String DEFAULT_WORKSPACE = "C:\\eGovFrameDev-4.3.1-64bit\\workspace-egov\\dat-was-dasmt";
|
static final String DEFAULT_WORKSPACE = "C:\\eGovFrameDev-4.3.1-64bit\\workspace-egov\\dat-was-dasmt";
|
||||||
|
static final String DEFAULT_NEW_POD_WORKSPACE = "C:\\eGovFrameDev-4.3.1-64bit\\workspace";
|
||||||
|
|
||||||
private final ChatClient.Builder chatClientBuilder;
|
private final ChatClient.Builder chatClientBuilder;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@@ -95,6 +99,34 @@ public class ScaffoldingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an independent Pod project below the shared workspace.
|
||||||
|
* This deliberately does not use PodScaffolder, which changes the legacy AX Hub multi-module project.
|
||||||
|
*/
|
||||||
|
@PostMapping("/pod-new")
|
||||||
|
public ResponseEntity<?> scaffoldNewPod(@RequestBody NewPodProjectRequest request) {
|
||||||
|
try {
|
||||||
|
if (request == null || request.moduleName() == null || request.moduleName().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Pod Module New 이름을 입력해주세요."));
|
||||||
|
}
|
||||||
|
if (request.port() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "서비스 포트를 입력해주세요."));
|
||||||
|
}
|
||||||
|
String workspacePath = request.workspacePath() == null || request.workspacePath().isBlank()
|
||||||
|
? DEFAULT_NEW_POD_WORKSPACE : request.workspacePath().trim();
|
||||||
|
String author = request.author() == null || request.author().isBlank()
|
||||||
|
? System.getProperty("user.name") : request.author().trim();
|
||||||
|
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
|
||||||
|
String result = NewPodProjectScaffolder.scaffold(
|
||||||
|
Path.of(workspacePath), request.moduleName().trim(), request.port(), author, date);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(e)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", safeMessage(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/pod-draft")
|
@PostMapping("/pod-draft")
|
||||||
public ResponseEntity<?> generatePodManifestDraft(@RequestBody Map<String, String> req) {
|
public ResponseEntity<?> generatePodManifestDraft(@RequestBody Map<String, String> req) {
|
||||||
String description = req.getOrDefault("description", "").trim();
|
String description = req.getOrDefault("description", "").trim();
|
||||||
@@ -775,4 +807,7 @@ public class ScaffoldingController {
|
|||||||
private record ToolGroupRequest(String useCaseName, String moduleName, String author, String date, String workspacePath,
|
private record ToolGroupRequest(String useCaseName, String moduleName, String author, String date, String workspacePath,
|
||||||
List<ToolScaffolder.ToolMethodDefinition> tools) {
|
List<ToolScaffolder.ToolMethodDefinition> tools) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record NewPodProjectRequest(String moduleName, Integer port, String author, String workspacePath) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -784,6 +784,9 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link active" id="pod-tab" data-bs-toggle="tab" data-bs-target="#pod" type="button" role="tab">Pod Module</button>
|
<button class="nav-link active" id="pod-tab" data-bs-toggle="tab" data-bs-target="#pod" type="button" role="tab">Pod Module</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="pod-new-tab" data-bs-toggle="tab" data-bs-target="#pod-new" type="button" role="tab">Pod Module New</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button>
|
<button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -876,6 +879,41 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Standalone Pod Creation Form: intentionally separate from the legacy Pod Module flow. -->
|
||||||
|
<div class="tab-pane fade" id="pod-new" role="tabpanel">
|
||||||
|
<form id="newPodForm">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Workspace Path</label>
|
||||||
|
<input type="text" class="form-control" name="workspacePath"
|
||||||
|
value="C:\eGovFrameDev-4.3.1-64bit\workspace"
|
||||||
|
placeholder="새 Pod 프로젝트를 만들 workspace 경로" required>
|
||||||
|
<div class="input-hint">입력한 workspace 바로 아래에 새 프로젝트 폴더가 생성됩니다.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">New Module Name</label>
|
||||||
|
<input type="text" class="form-control" name="moduleName"
|
||||||
|
pattern="^dat-was-[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||||
|
placeholder="예: dat-was-payment" required>
|
||||||
|
<div class="input-hint">`dat-was-`로 시작하는 영문 소문자, 숫자, 하이픈 이름을 입력하세요.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Service Port</label>
|
||||||
|
<input type="number" class="form-control" name="port" min="1" max="65535"
|
||||||
|
placeholder="예: 8099" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Author</label>
|
||||||
|
<input type="text" class="form-control" name="author" placeholder="미입력 시 OS 사용자명">
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-secondary" role="alert">
|
||||||
|
기존 Pod Module과 별도로 생성됩니다. 새 프로젝트는 `../dat-lib-datmt`를 composite build로 참조합니다.
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end mt-4">
|
||||||
|
<button type="submit" class="btn-action">Generate New Pod Project</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tool Creation Form -->
|
<!-- Tool Creation Form -->
|
||||||
<div class="tab-pane fade" id="tool" role="tabpanel">
|
<div class="tab-pane fade" id="tool" role="tabpanel">
|
||||||
<form id="toolForm">
|
<form id="toolForm">
|
||||||
@@ -1781,6 +1819,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
handleFormSubmit('podForm', '/api/v1/scaffold/pod');
|
handleFormSubmit('podForm', '/api/v1/scaffold/pod');
|
||||||
|
handleFormSubmit('newPodForm', '/api/v1/scaffold/pod-new');
|
||||||
|
|
||||||
function currentTargetModules() {
|
function currentTargetModules() {
|
||||||
return Array.from(document.querySelectorAll('#targetModuleSelect option'))
|
return Array.from(document.querySelectorAll('#targetModuleSelect option'))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -32,6 +33,66 @@ class ScaffoldingControllerToolDraftTest {
|
|||||||
@TempDir
|
@TempDir
|
||||||
Path root;
|
Path root;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsStandalonePodProjectWithoutChangingExistingPodScaffolder() throws Exception {
|
||||||
|
createLibraryProject();
|
||||||
|
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
new ScaffoldingController(mock(ChatClient.Builder.class), new ObjectMapper()))
|
||||||
|
.setMessageConverters(new MappingJackson2HttpMessageConverter())
|
||||||
|
.build();
|
||||||
|
String workspacePath = root.toString().replace("\\", "\\\\");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/scaffold/pod-new")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"moduleName":"dat-was-payment","port":8099,
|
||||||
|
"author":"tester","workspacePath":"%s"}
|
||||||
|
""".formatted(workspacePath)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("dat-was-payment")));
|
||||||
|
|
||||||
|
assertTrue(Files.exists(root.resolve("dat-was-payment/settings.gradle")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createLibraryProject() throws Exception {
|
||||||
|
Path library = root.resolve("dat-lib-datmt");
|
||||||
|
Files.createDirectories(library.resolve("gradle/wrapper"));
|
||||||
|
Files.writeString(library.resolve("gradlew"), "#!/bin/sh\n");
|
||||||
|
Files.writeString(library.resolve("gradlew.bat"), "@echo off\n");
|
||||||
|
Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.jar"), "wrapper");
|
||||||
|
Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.properties"), "distributionUrl=test\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNewPodProjectWhenTheTargetModuleAlreadyExists() throws Exception {
|
||||||
|
Files.createDirectories(root.resolve("dat-was-payment"));
|
||||||
|
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
new ScaffoldingController(mock(ChatClient.Builder.class), new ObjectMapper()))
|
||||||
|
.setMessageConverters(new MappingJackson2HttpMessageConverter())
|
||||||
|
.build();
|
||||||
|
String workspacePath = root.toString().replace("\\", "\\\\");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/scaffold/pod-new")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"moduleName":"dat-was-payment","port":8099,
|
||||||
|
"workspacePath":"%s"}
|
||||||
|
""".formatted(workspacePath)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.error").value(org.hamcrest.Matchers.containsString("already exists")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scaffoldPageKeepsTheNewPodFormSeparateFromTheLegacyPodForm() throws Exception {
|
||||||
|
try (var pageStream = getClass().getResourceAsStream("/static/admin/scaffold.html")) {
|
||||||
|
String page = new String(java.util.Objects.requireNonNull(pageStream).readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(page.contains("id=\"podForm\""));
|
||||||
|
assertTrue(page.contains("id=\"newPodForm\""));
|
||||||
|
assertTrue(page.contains("/api/v1/scaffold/pod-new"));
|
||||||
|
assertTrue(page.contains("Pod Module New"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void usesRenamedDasmtWorkspaceByDefault() {
|
void usesRenamedDasmtWorkspaceByDefault() {
|
||||||
assertTrue(ScaffoldingController.DEFAULT_WORKSPACE.endsWith("dat-was-dasmt"));
|
assertTrue(ScaffoldingController.DEFAULT_WORKSPACE.endsWith("dat-was-dasmt"));
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package io.shinhanlife.dat.lib.util;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Creates a standalone Tool Pod repository without changing the legacy PodScaffolder flow. */
|
||||||
|
public final class NewPodProjectScaffolder {
|
||||||
|
|
||||||
|
private static final Pattern MODULE_NAME = Pattern.compile("^dat-was-[a-z0-9]+(?:-[a-z0-9]+)*$");
|
||||||
|
|
||||||
|
private NewPodProjectScaffolder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String scaffold(Path workspaceRoot, String moduleName, int port, String author, String createdDate)
|
||||||
|
throws IOException {
|
||||||
|
validateModuleName(moduleName);
|
||||||
|
validatePort(port);
|
||||||
|
if (workspaceRoot == null) {
|
||||||
|
throw new IllegalArgumentException("Workspace directory does not exist: null");
|
||||||
|
}
|
||||||
|
Path normalizedWorkspace = workspaceRoot.toAbsolutePath().normalize();
|
||||||
|
if (!Files.isDirectory(normalizedWorkspace)) {
|
||||||
|
throw new IllegalArgumentException("Workspace directory does not exist: " + workspaceRoot);
|
||||||
|
}
|
||||||
|
Path target = normalizedWorkspace.resolve(moduleName).normalize();
|
||||||
|
if (!target.getParent().equals(normalizedWorkspace)) {
|
||||||
|
throw new IllegalArgumentException("Module path must be directly below the workspace");
|
||||||
|
}
|
||||||
|
if (Files.exists(target)) {
|
||||||
|
throw new IllegalStateException("Project already exists: " + moduleName);
|
||||||
|
}
|
||||||
|
Path libraryProject = normalizedWorkspace.resolve("dat-lib-datmt");
|
||||||
|
if (!Files.isDirectory(libraryProject)) {
|
||||||
|
throw new IllegalArgumentException("dat-lib-datmt project does not exist below the workspace: " + libraryProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path temporary = Files.createTempDirectory(normalizedWorkspace, ".new-pod-");
|
||||||
|
try {
|
||||||
|
writeProject(temporary, moduleName, port, blankToDefault(author, System.getProperty("user.name")),
|
||||||
|
blankToDefault(createdDate, "unknown"), libraryProject);
|
||||||
|
moveIntoPlace(temporary, target);
|
||||||
|
return "독립 Pod 프로젝트 생성 완료: " + target;
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
deleteRecursively(temporary);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateModuleName(String moduleName) {
|
||||||
|
if (moduleName == null || !MODULE_NAME.matcher(moduleName).matches() || "dat-was-lib".equals(moduleName)) {
|
||||||
|
throw new IllegalArgumentException("Module name must match dat-was-<lowercase-name>: " + moduleName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validatePort(int port) {
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
throw new IllegalArgumentException("Port must be between 1 and 65535: " + port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeProject(Path root, String moduleName, int port, String author, String createdDate,
|
||||||
|
Path libraryProject)
|
||||||
|
throws IOException {
|
||||||
|
String shortName = moduleName.substring("dat-was-".length());
|
||||||
|
String packageName = shortName.replace("-", "");
|
||||||
|
String className = "DatWas" + pascalCase(shortName) + "Application";
|
||||||
|
String serviceName = "was-" + shortName;
|
||||||
|
write(root.resolve("settings.gradle"), """
|
||||||
|
rootProject.name = '%s'
|
||||||
|
|
||||||
|
includeBuild('../dat-lib-datmt') {
|
||||||
|
dependencySubstitution {
|
||||||
|
substitute module('io.shinhanlife:dat-lib-datmt') using project(':dat-was-lib')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(moduleName));
|
||||||
|
write(root.resolve("build.gradle"), """
|
||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id 'org.springframework.boot' version '3.5.11'
|
||||||
|
id 'io.spring.dependency-management' version '1.1.7'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'io.shinhanlife'
|
||||||
|
version = '0.0.1-SNAPSHOT'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain { languageVersion = JavaLanguageVersion.of(21) }
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories { mavenCentral() }
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(Test).configureEach { useJUnitPlatform() }
|
||||||
|
""");
|
||||||
|
copyGradleWrapper(libraryProject, root);
|
||||||
|
write(root.resolve("src/main/java/io/shinhanlife/dat/mcc/" + packageName + "/" + className + ".java"), """
|
||||||
|
package io.shinhanlife.dat.mcc.%s;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
|
|
||||||
|
/** Generated by Pod Module New for %s on %s. */
|
||||||
|
@SpringBootApplication(scanBasePackages = {"io.shinhanlife.dat.mcc", "io.shinhanlife.dat.lib"})
|
||||||
|
@ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dat.mcc", "io.shinhanlife.dat.lib"})
|
||||||
|
public class %s {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(%s.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(packageName, author, createdDate, className, className));
|
||||||
|
write(root.resolve("src/main/resources/application.yml"), """
|
||||||
|
server:
|
||||||
|
port: ${PORT:%d}
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: %s
|
||||||
|
config:
|
||||||
|
import: optional:classpath:tool-service-manifest.yml
|
||||||
|
profiles:
|
||||||
|
active: local
|
||||||
|
mcp:
|
||||||
|
namespace: %s
|
||||||
|
manifest:
|
||||||
|
bundle-id: %s
|
||||||
|
name-prefix: ""
|
||||||
|
security:
|
||||||
|
api-key: ${TOOL_SERVER_API_KEY:tool-server-key}
|
||||||
|
tenant-domains:
|
||||||
|
TESTER-DEV: ALL
|
||||||
|
""".formatted(port, moduleName, shortName, serviceName));
|
||||||
|
for (String profile : new String[] {"local", "dev", "test", "prod"}) {
|
||||||
|
write(root.resolve("src/main/resources/application-" + profile + ".yml"), """
|
||||||
|
spring:
|
||||||
|
config:
|
||||||
|
activate:
|
||||||
|
on-profile: %s
|
||||||
|
""".formatted(profile));
|
||||||
|
}
|
||||||
|
write(root.resolve("src/main/resources/tool-service-manifest.yml"), """
|
||||||
|
mcp:
|
||||||
|
manifest:
|
||||||
|
routing-functions:
|
||||||
|
- name: route_to_%s
|
||||||
|
server-id: %s
|
||||||
|
category-key: %s
|
||||||
|
product-boundary: "업무 범위를 입력하세요."
|
||||||
|
business-domain: "업무 도메인을 입력하세요."
|
||||||
|
business-outcome: "업무 결과를 입력하세요."
|
||||||
|
primary-entities: []
|
||||||
|
capabilities: []
|
||||||
|
select-if: "이 Pod의 업무 요청인 경우"
|
||||||
|
reject-if: "다른 업무 Pod 요청인 경우"
|
||||||
|
confusable-servers: []
|
||||||
|
decision-policy: "업무 도메인을 기준으로 선택합니다."
|
||||||
|
""".formatted(moduleName, moduleName, shortName));
|
||||||
|
write(root.resolve("Dockerfile"), """
|
||||||
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY build/libs/*-SNAPSHOT.jar app.jar
|
||||||
|
EXPOSE %d
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
""".formatted(port));
|
||||||
|
write(root.resolve("docker-compose.yml"), """
|
||||||
|
services:
|
||||||
|
%s:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:%d}:${PORT:%d}"
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:%d}
|
||||||
|
- AXHUB_TOOL_URL=http://%s:${PORT:%d}
|
||||||
|
- SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local}
|
||||||
|
""".formatted(serviceName, port, port, port, serviceName, port));
|
||||||
|
write(root.resolve("k8s/base/deployment.yaml"), """
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: %s
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels: { app: %s }
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: { app: %s }
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: %s
|
||||||
|
image: %s:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: %d
|
||||||
|
""".formatted(serviceName, serviceName, serviceName, serviceName, serviceName, port));
|
||||||
|
write(root.resolve("k8s/base/service.yaml"), """
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: %s
|
||||||
|
spec:
|
||||||
|
selector: { app: %s }
|
||||||
|
ports:
|
||||||
|
- port: %d
|
||||||
|
targetPort: %d
|
||||||
|
""".formatted(serviceName, serviceName, port, port));
|
||||||
|
write(root.resolve("README.md"), """
|
||||||
|
# %s
|
||||||
|
|
||||||
|
Pod Module New로 생성된 독립 DATMT Tool Pod입니다.
|
||||||
|
|
||||||
|
## 공통 라이브러리
|
||||||
|
|
||||||
|
상위 workspace의 `dat-lib-datmt`를 Gradle composite build로 참조합니다.
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\\gradlew.bat compileJava
|
||||||
|
```
|
||||||
|
|
||||||
|
기본 포트는 %d이며, 실행 시 `PORT` 환경변수로 변경할 수 있습니다.
|
||||||
|
""".formatted(moduleName, port));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copyGradleWrapper(Path libraryProject, Path targetProject) throws IOException {
|
||||||
|
for (String relativePath : new String[] {
|
||||||
|
"gradlew", "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar", "gradle/wrapper/gradle-wrapper.properties"}) {
|
||||||
|
Path source = libraryProject.resolve(relativePath);
|
||||||
|
if (!Files.isRegularFile(source)) {
|
||||||
|
throw new IllegalArgumentException("dat-lib-datmt Gradle wrapper file is missing: " + source);
|
||||||
|
}
|
||||||
|
Path target = targetProject.resolve(relativePath);
|
||||||
|
Files.createDirectories(target.getParent());
|
||||||
|
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(Path path, String content) throws IOException {
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pascalCase(String value) {
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
for (String part : value.split("-")) {
|
||||||
|
result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
|
||||||
|
}
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToDefault(String value, String fallback) {
|
||||||
|
return value == null || value.isBlank() ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void moveIntoPlace(Path source, Path target) throws IOException {
|
||||||
|
try {
|
||||||
|
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (AtomicMoveNotSupportedException ignored) {
|
||||||
|
Files.move(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteRecursively(Path root) throws IOException {
|
||||||
|
if (!Files.exists(root)) return;
|
||||||
|
try (var paths = Files.walk(root)) {
|
||||||
|
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new IllegalStateException(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package io.shinhanlife.dat.lib.util;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class NewPodProjectScaffolderTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path workspace;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsStandaloneProjectWithCompositeBuild() throws Exception {
|
||||||
|
createLibraryProject();
|
||||||
|
String result = NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099,
|
||||||
|
"tester", "2026.09.09");
|
||||||
|
|
||||||
|
Path project = workspace.resolve("dat-was-payment");
|
||||||
|
assertTrue(Files.exists(project.resolve("settings.gradle")));
|
||||||
|
assertTrue(Files.exists(project.resolve("build.gradle")));
|
||||||
|
assertTrue(Files.exists(project.resolve("src/main/resources/application.yml")));
|
||||||
|
assertTrue(Files.exists(project.resolve("docker-compose.yml")));
|
||||||
|
assertTrue(Files.exists(project.resolve("k8s/base/deployment.yaml")));
|
||||||
|
assertTrue(Files.exists(project.resolve("gradlew.bat")));
|
||||||
|
assertTrue(Files.exists(project.resolve("gradle/wrapper/gradle-wrapper.jar")));
|
||||||
|
assertTrue(Files.readString(project.resolve("settings.gradle"))
|
||||||
|
.contains("includeBuild('../dat-lib-datmt')"));
|
||||||
|
assertTrue(Files.readString(project.resolve("build.gradle"))
|
||||||
|
.contains("io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT"));
|
||||||
|
assertTrue(Files.readString(project.resolve("src/main/resources/application.yml"))
|
||||||
|
.contains("port: ${PORT:8099}"));
|
||||||
|
assertTrue(Files.readString(project.resolve("src/main/resources/application.yml"))
|
||||||
|
.contains("import: optional:classpath:tool-service-manifest.yml"));
|
||||||
|
assertTrue(result.contains("dat-was-payment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createLibraryProject() throws Exception {
|
||||||
|
Path library = workspace.resolve("dat-lib-datmt");
|
||||||
|
Files.createDirectories(library.resolve("gradle/wrapper"));
|
||||||
|
Files.writeString(library.resolve("gradlew"), "#!/bin/sh\n");
|
||||||
|
Files.writeString(library.resolve("gradlew.bat"), "@echo off\n");
|
||||||
|
Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.jar"), "wrapper");
|
||||||
|
Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.properties"), "distributionUrl=test\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidNamesPortsAndExistingProjects() throws Exception {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "payment", 8099, "tester", "2026.09.09"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-lib", 8099, "tester", "2026.09.09"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 0, "tester", "2026.09.09"));
|
||||||
|
Files.createDirectories(workspace.resolve("dat-was-payment"));
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099, "tester", "2026.09.09"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Pod Module New Workspace Scaffolder Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 기존 Pod Scaffold를 변경하지 않고, Scaffold 화면에서 독립 Tool Pod 프로젝트를 `C:\eGovFrameDev-4.3.1-64bit\workspace`에 생성한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 `PodScaffolder`와 `/api/v1/scaffold/pod`는 유지한다. `NewPodProjectScaffolder`가 독립 Gradle 프로젝트 파일을 생성하고, 별도 `/api/v1/scaffold/pod-new` endpoint와 `Pod Module New` UI가 이를 호출한다. 생성 프로젝트는 `../dat-lib-datmt` composite build로 공통 라이브러리를 해석한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 3.5.11, Gradle composite build, JUnit 5, MockMvc, static HTML/JavaScript.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-09-09-new-pod-module-workspace-scaffolder-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `PodScaffolder` 및 기존 `/api/v1/scaffold/pod`의 코드와 동작을 변경하지 않는다.
|
||||||
|
- 기존 Pod 모듈 `dat-was-cus`, `dat-was-pro`, `dat-was-sal`, `dat-was-sys`를 수정하지 않는다.
|
||||||
|
- 신규 생성 대상은 사용자 입력 workspace의 `<workspace>/<moduleName>`이며, 기본 workspace는 `C:\eGovFrameDev-4.3.1-64bit\workspace`이다.
|
||||||
|
- moduleName은 `dat-was-` 접두사가 붙은 소문자·숫자·하이픈 식별자만 허용하고 `dat-was-lib`는 거부한다.
|
||||||
|
- port는 1부터 65535까지의 정수만 허용한다.
|
||||||
|
- 생성 프로젝트는 `../dat-lib-datmt`를 포함하고 `io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT`를 `:dat-was-lib`로 치환한다.
|
||||||
|
- 사용자 요청이 없는 한 commit 또는 push하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 독립 Pod 프로젝트 생성기
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolder.java`
|
||||||
|
- Create: `dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolderTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `NewPodProjectScaffolder.scaffold(Path workspaceRoot, String moduleName, int port, String author, String createdDate)` returning a human-readable generation summary.
|
||||||
|
- Produces: `NewPodProjectScaffolder.validateModuleName(String moduleName)` and `validatePort(int port)` throwing `IllegalArgumentException` for invalid input.
|
||||||
|
- Consumes: a writable workspace root; creates no files outside it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing generator test**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void createsStandaloneProjectWithCompositeBuild() throws Exception {
|
||||||
|
String result = NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099,
|
||||||
|
"tester", "2026.09.09");
|
||||||
|
|
||||||
|
Path project = workspace.resolve("dat-was-payment");
|
||||||
|
assertTrue(Files.exists(project.resolve("settings.gradle")));
|
||||||
|
assertTrue(Files.exists(project.resolve("build.gradle")));
|
||||||
|
assertTrue(Files.exists(project.resolve("src/main/resources/application.yml")));
|
||||||
|
assertTrue(Files.exists(project.resolve("docker-compose.yml")));
|
||||||
|
assertTrue(Files.exists(project.resolve("k8s/base/deployment.yaml")));
|
||||||
|
assertTrue(Files.readString(project.resolve("settings.gradle"))
|
||||||
|
.contains("includeBuild('../dat-lib-datmt')"));
|
||||||
|
assertTrue(Files.readString(project.resolve("build.gradle"))
|
||||||
|
.contains("io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT"));
|
||||||
|
assertTrue(Files.readString(project.resolve("src/main/resources/application.yml"))
|
||||||
|
.contains("port: ${PORT:8099}"));
|
||||||
|
assertTrue(result.contains("dat-was-payment"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new test and verify the expected failure**
|
||||||
|
|
||||||
|
Run: `.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest.createsStandaloneProjectWithCompositeBuild" --no-daemon`
|
||||||
|
|
||||||
|
Expected: compilation failure because `NewPodProjectScaffolder` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write failing validation and non-overwrite tests**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidNamesPortsAndExistingProjects() throws Exception {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "payment", 8099, "tester", "2026.09.09"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-lib", 8099, "tester", "2026.09.09"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 0, "tester", "2026.09.09"));
|
||||||
|
Files.createDirectories(workspace.resolve("dat-was-payment"));
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099, "tester", "2026.09.09"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run validation test and verify the expected failure**
|
||||||
|
|
||||||
|
Run: `.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest.rejectsInvalidNamesPortsAndExistingProjects" --no-daemon`
|
||||||
|
|
||||||
|
Expected: compilation failure because the new generator is not implemented.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement the generator**
|
||||||
|
|
||||||
|
Create only the requested standalone project root. Generate these files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<workspace>/<moduleName>/settings.gradle
|
||||||
|
<workspace>/<moduleName>/build.gradle
|
||||||
|
<workspace>/<moduleName>/README.md
|
||||||
|
<workspace>/<moduleName>/Dockerfile
|
||||||
|
<workspace>/<moduleName>/docker-compose.yml
|
||||||
|
<workspace>/<moduleName>/k8s/base/deployment.yaml
|
||||||
|
<workspace>/<moduleName>/k8s/base/service.yaml
|
||||||
|
<workspace>/<moduleName>/src/main/java/io/shinhanlife/dat/mcc/<shortName>/DatWas<PascalName>Application.java
|
||||||
|
<workspace>/<moduleName>/src/main/resources/application.yml
|
||||||
|
<workspace>/<moduleName>/src/main/resources/application-local.yml
|
||||||
|
<workspace>/<moduleName>/src/main/resources/application-dev.yml
|
||||||
|
<workspace>/<moduleName>/src/main/resources/application-test.yml
|
||||||
|
<workspace>/<moduleName>/src/main/resources/application-prod.yml
|
||||||
|
<workspace>/<moduleName>/src/main/resources/tool-service-manifest.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this generated `settings.gradle` content:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
rootProject.name = '<moduleName>'
|
||||||
|
|
||||||
|
includeBuild('../dat-lib-datmt') {
|
||||||
|
dependencySubstitution {
|
||||||
|
substitute module('io.shinhanlife:dat-lib-datmt') using project(':dat-was-lib')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this generated dependency declaration:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
dependencies {
|
||||||
|
implementation 'io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Make all writes UTF-8 and use `Files.createDirectories` only after validation. Build a temporary project directory next to the final target and move it into place atomically after all writes succeed; remove that temporary directory if any write fails.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run generator tests and verify they pass**
|
||||||
|
|
||||||
|
Run: `.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest" --no-daemon`
|
||||||
|
|
||||||
|
Expected: all new generator tests pass.
|
||||||
|
|
||||||
|
### Task 2: 신규 Scaffold API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java`
|
||||||
|
- Modify: `dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: JSON `{"moduleName":"dat-was-payment","port":8099,"author":"tester","workspacePath":"C:\\temp\\workspace"}`.
|
||||||
|
- Produces: `POST /api/v1/scaffold/pod-new` response with HTTP 200 and generated-project summary, or HTTP 400 with a validation message.
|
||||||
|
- Consumes: `NewPodProjectScaffolder.scaffold(Path, String, int, String, String)` from Task 1.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing MockMvc success test**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void createsStandalonePodProjectWithoutCallingExistingPodScaffolder() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/v1/scaffold/pod-new")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"moduleName":"dat-was-payment","port":8099,
|
||||||
|
"author":"tester","workspacePath":"%s"}
|
||||||
|
""".formatted(root.toString().replace("\\", "\\\\"))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("dat-was-payment")));
|
||||||
|
|
||||||
|
assertTrue(Files.exists(root.resolve("dat-was-payment/settings.gradle")));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the API test and verify the expected failure**
|
||||||
|
|
||||||
|
Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest.createsStandalonePodProjectWithoutCallingExistingPodScaffolder" --no-daemon`
|
||||||
|
|
||||||
|
Expected: HTTP 404 because `/api/v1/scaffold/pod-new` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the isolated endpoint and request validation**
|
||||||
|
|
||||||
|
Add `@PostMapping("/pod-new")` without altering `scaffoldPod`. Define a `NewPodProjectRequest` record in `ScaffoldingController` with `moduleName`, `Integer port`, `author`, and `workspacePath`. Default a missing or blank workspace path only for this new endpoint to `C:\eGovFrameDev-4.3.1-64bit\workspace`. Return `ResponseEntity.badRequest()` for invalid names, missing ports, invalid ports, or a pre-existing target path. Do not set `AXHUB_SOURCE_DIR` in this endpoint.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write and run the duplicate-target API test**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void rejectsExistingStandalonePodProject() throws Exception {
|
||||||
|
Files.createDirectories(root.resolve("dat-was-payment"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/scaffold/pod-new")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"moduleName":"dat-was-payment","port":8099,
|
||||||
|
"workspacePath":"%s"}
|
||||||
|
""".formatted(root.toString().replace("\\", "\\\\"))))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest" --no-daemon`
|
||||||
|
|
||||||
|
Expected: all controller scaffolding tests pass, including existing `/pod` behavior.
|
||||||
|
|
||||||
|
### Task 3: Pod Module New 화면
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `dat-gateway/src/main/resources/static/admin/scaffold.html`
|
||||||
|
- Test: `dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `/api/v1/scaffold/pod-new` from Task 2.
|
||||||
|
- Produces: a separate `newPodForm`; no existing `podForm` markup, `handleFormSubmit('podForm', ...)`, or existing manifest draft request changes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a failing static-page assertion**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void scaffoldPageContainsSeparateNewPodForm() throws Exception {
|
||||||
|
String page = Files.readString(Path.of("src/main/resources/static/admin/scaffold.html"));
|
||||||
|
assertTrue(page.contains("Pod Module New"));
|
||||||
|
assertTrue(page.contains("id=\"newPodForm\""));
|
||||||
|
assertTrue(page.contains("/api/v1/scaffold/pod-new"));
|
||||||
|
assertTrue(page.contains("C:\\eGovFrameDev-4.3.1-64bit\\workspace"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the assertion and verify the expected failure**
|
||||||
|
|
||||||
|
Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest.scaffoldPageContainsSeparateNewPodForm" --no-daemon`
|
||||||
|
|
||||||
|
Expected: assertion failure because the new tab and form do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add a separate tab and form**
|
||||||
|
|
||||||
|
Add a new `Pod Module New` tab after the existing Pod Module tab. Its `newPodForm` must have only workspace path, module name, service port, author, and date inputs. Pre-fill only this new form's workspace path with `C:\eGovFrameDev-4.3.1-64bit\workspace`. Bind it with `handleFormSubmit('newPodForm', '/api/v1/scaffold/pod-new')`. Keep `podForm`, its workspace default, and its `/pod` handler unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the static-page assertion and controller tests**
|
||||||
|
|
||||||
|
Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest" --no-daemon`
|
||||||
|
|
||||||
|
Expected: all scaffold controller tests pass.
|
||||||
|
|
||||||
|
### Task 4: End-to-end generated project verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Documents: `Pod Module New` creates an independent repository under the selected workspace, while existing Pod Module continues to create modules under its selected root.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a Korean README section**
|
||||||
|
|
||||||
|
Document the two Scaffold paths with this behavior table:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 기능 | 생성 위치 | 기존 Pod 영향 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pod Module | 선택한 기존 프로젝트 root | 기존 동작 유지 |
|
||||||
|
| Pod Module New | `C:\eGovFrameDev-4.3.1-64bit\workspace\<moduleName>` | 기존 Pod/Scaffolder 미수정 |
|
||||||
|
```
|
||||||
|
|
||||||
|
State that `Pod Module New` requires `../dat-lib-datmt` to exist beside the generated project and that the generated port comes from the form input, overridable by `PORT` at runtime.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create one temporary project through the new endpoint**
|
||||||
|
|
||||||
|
Use a unique throwaway module name such as `dat-was-scaffoldverify` and workspace `C:\eGovFrameDev-4.3.1-64bit\workspace`. Confirm the generated directory contains the settings file, composite dependency substitution, application source, and Docker/Kubernetes files.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Compile the generated project**
|
||||||
|
|
||||||
|
Run from the generated project:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat compileJava --no-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Gradle resolves `../dat-lib-datmt/:dat-was-lib` and exits successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove only the verified throwaway project**
|
||||||
|
|
||||||
|
Verify the absolute path is exactly `C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-scaffoldverify`, then remove that one generated verification project. Do not alter any user-created project under `workspace`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run final targeted verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest" --no-daemon
|
||||||
|
.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest" --no-daemon
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both focused test classes pass and `git diff --check` reports no whitespace errors.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Pod Module New Workspace Scaffolder 설계
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
기존 Pod Module Scaffold(`PodScaffolder`, `/api/v1/scaffold/pod`, 기존 화면)을 변경하지 않는다.
|
||||||
|
Scaffold 화면에 별도 `Pod Module New` 기능을 추가하여, 사용자가 입력한 Pod 모듈명을 기준으로 독립 Tool Pod 프로젝트를 `C:\eGovFrameDev-4.3.1-64bit\workspace` 아래에 생성한다.
|
||||||
|
|
||||||
|
## 생성 구조
|
||||||
|
|
||||||
|
`dat-was-payment` 입력 시 다음 독립 프로젝트를 생성한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-payment\
|
||||||
|
settings.gradle
|
||||||
|
build.gradle
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
k8s\
|
||||||
|
src\main\java\...
|
||||||
|
src\main\resources\...
|
||||||
|
```
|
||||||
|
|
||||||
|
입력 모듈명은 저장소 루트명, Gradle 루트 프로젝트명, Spring application name에 동일하게 사용한다.
|
||||||
|
생성 프로젝트는 `../dat-lib-datmt`를 Gradle composite build로 포함하고, `io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT` 좌표를 `:dat-was-lib`로 치환한다.
|
||||||
|
|
||||||
|
## 새 생성 경로
|
||||||
|
|
||||||
|
- 신규 `NewPodProjectScaffolder`가 독립 프로젝트 파일을 생성한다.
|
||||||
|
- 신규 Controller endpoint가 module name과 사용자가 입력한 서비스 포트를 전달한다.
|
||||||
|
- Scaffold 화면에는 기존 Pod Module 탭과 별개인 `Pod Module New` 영역을 둔다.
|
||||||
|
- workspace 경로는 사용자 입력값을 사용하며 기본값은 `C:\eGovFrameDev-4.3.1-64bit\workspace`이다.
|
||||||
|
- 존재하는 프로젝트 경로에는 생성하지 않고 명확한 오류를 반환한다.
|
||||||
|
|
||||||
|
## 기존 기능 보호
|
||||||
|
|
||||||
|
다음 기존 파일과 동작은 변경하지 않는다.
|
||||||
|
|
||||||
|
- `PodScaffolder`
|
||||||
|
- `/api/v1/scaffold/pod`
|
||||||
|
- 기존 Pod Module 화면과 기존 Pod 모듈
|
||||||
|
|
||||||
|
## 생성 파일의 주요 설정
|
||||||
|
|
||||||
|
- Spring Boot 3.5.11, Java 21
|
||||||
|
- 공통 라이브러리 composite build 연결
|
||||||
|
- `server.port`는 사용자가 입력한 서비스 포트를 기본값으로 사용하고, 환경변수 `PORT`로 덮어쓸 수 있다.
|
||||||
|
- Docker Compose 서비스명은 모듈명의 `dat-was-` 접두사를 `was-`로 치환한다.
|
||||||
|
- Tool manifest는 새 Pod의 라우팅 함수만 포함한다.
|
||||||
|
- README는 공통 라이브러리 위치, 실행 방법, 입력 포트 설정을 안내한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
1. 신규 Scaffolder 단위 테스트로 생성 파일, composite build 설정, 기존 경로 거부를 검증한다.
|
||||||
|
2. Controller 테스트로 신규 endpoint의 정상 생성과 중복 경로 오류를 검증한다.
|
||||||
|
3. 임시 workspace에 생성한 Pod의 `compileJava`를 실행해 `../dat-lib-datmt` 의존성 해석을 확인한다.
|
||||||
|
4. 기존 `PodScaffolder` 관련 테스트를 실행해 기존 생성 경로가 바뀌지 않았음을 확인한다.
|
||||||
Reference in New Issue
Block a user