feat: add AI scaffold drafts
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled

This commit is contained in:
jade
2026-08-11 22:54:02 +09:00
parent 11f94cf721
commit 45e4332adb
3 changed files with 447 additions and 19 deletions

View File

@@ -24,15 +24,37 @@ import java.io.File;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/scaffold")
public class ScaffoldingController {
private static final Set<String> SUPPORTED_FIELD_TYPES = Set.of(
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal");
private static final Set<String> SUPPORTED_AI_MODELS = Set.of(
"inclusionai/ling-3.0-flash:free",
"openai/gpt-oss-20b:free",
"google/gemma-4-31b-it:free",
"nvidia/nemotron-3-nano-30b-a3b:free",
"cohere/north-mini-code:free");
private final ChatClient.Builder chatClientBuilder;
private final ObjectMapper objectMapper;
public ScaffoldingController(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) {
this.chatClientBuilder = chatClientBuilder;
this.objectMapper = objectMapper;
}
@PostMapping("/pod")
public String scaffoldPod(@RequestBody Map<String, String> req) {
try {
@@ -83,6 +105,78 @@ public class ScaffoldingController {
}
}
@PostMapping("/field-draft")
public ResponseEntity<?> generateFieldDraft(@RequestBody Map<String, String> req) {
String description = req.getOrDefault("description", "").trim();
String target = req.getOrDefault("target", "inputFields");
if (description.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "설명을 입력해주세요."));
}
if (!"inputFields".equals(target) && !"outputFields".equals(target)) {
return ResponseEntity.badRequest().body(Map.of("error", "지원하지 않는 필드 대상입니다."));
}
try {
String targetLabel = "inputFields".equals(target) ? "INPUT request DTO" : "OUTPUT response DTO";
String prompt = """
Generate Java DTO fields for an MCP tool.
Return JSON only. Do not add Markdown, explanations, or code fences.
The response must have this exact shape:
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true}]}
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal.
Generate fields only for the requested target: %s.
For OUTPUT fields, include resultCode and resultMessage when appropriate.
Keep field names valid Java camelCase identifiers. Generate at most 10 fields.
User description: %s
""".formatted(targetLabel, description);
String response = generateAiContent(prompt, req.get("model"));
FieldDraft draft = objectMapper.readValue(stripCodeFence(response), FieldDraft.class);
List<ToolScaffolder.FieldDefinition> fields = validateFields(draft.fields());
return ResponseEntity.ok(Map.of("fields", fields));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", "AI 초안 생성 실패: " + safeMessage(e)));
}
}
@PostMapping("/tool-draft")
public ResponseEntity<?> generateToolDraft(@RequestBody Map<String, String> req) {
String description = req.getOrDefault("description", "").trim();
if (description.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Tool 설명을 입력해주세요."));
}
try {
String prompt = """
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","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true}]}
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.
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal.
Keep all field names valid Java camelCase identifiers. Generate at most 10 fields per list.
Do not generate interfaceId or clientSystemCode; those must come from a real integration contract.
User request: %s
""".formatted(description);
String response = generateAiContent(prompt, req.get("model"));
ToolDraft draft = objectMapper.readValue(stripCodeFence(response), ToolDraft.class);
ToolDraft validatedDraft = validateToolDraft(draft);
return ResponseEntity.ok(Map.of(
"baseName", validatedDraft.baseName(),
"title", validatedDraft.title(),
"description", validatedDraft.description(),
"categoryKey", validatedDraft.categoryKey(),
"routingType", validatedDraft.routingType(),
"httpApiName", validatedDraft.httpApiName(),
"inputFields", validatedDraft.inputFields(),
"outputFields", validatedDraft.outputFields()));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", "AI Tool 초안 생성 실패: " + safeMessage(e)));
}
}
@PostMapping("/tool/update")
public String updateTool(@RequestBody Map<String, String> req) {
try {
@@ -122,6 +216,97 @@ public class ScaffoldingController {
if (source == null || source.isBlank()) {
return List.of();
}
return new ObjectMapper().readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
return objectMapper.readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
}
private List<ToolScaffolder.FieldDefinition> validateFields(List<ToolScaffolder.FieldDefinition> source) {
if (source == null || source.isEmpty()) {
throw new IllegalArgumentException("AI가 필드를 생성하지 않았습니다.");
}
Set<String> names = new LinkedHashSet<>();
return source.stream()
.limit(10)
.filter(field -> field != null && field.name() != null && !field.name().isBlank())
.map(field -> new ToolScaffolder.FieldDefinition(
field.name().trim(),
field.type() == null ? "String" : field.type().trim(),
field.description() == null ? "" : field.description().trim(),
field.example() == null ? "" : field.example().trim(),
field.required()))
.peek(field -> {
if (!field.name().matches("^[A-Za-z_$][A-Za-z0-9_$]*$")) {
throw new IllegalArgumentException("AI가 올바르지 않은 필드명을 생성했습니다: " + field.name());
}
if (!SUPPORTED_FIELD_TYPES.contains(field.type())) {
throw new IllegalArgumentException("AI가 지원하지 않는 Type을 생성했습니다: " + field.type());
}
if (!names.add(field.name())) {
throw new IllegalArgumentException("AI가 중복 필드명을 생성했습니다: " + field.name());
}
})
.toList();
}
private ToolDraft validateToolDraft(ToolDraft draft) {
if (draft == null || draft.baseName() == null || !draft.baseName().trim().matches("^[A-Z][A-Za-z0-9]*$")) {
throw new IllegalArgumentException("AI가 올바르지 않은 Base Name을 생성했습니다.");
}
String categoryKey = draft.categoryKey() == null ? "" : draft.categoryKey().trim().toLowerCase(Locale.ROOT);
if (!categoryKey.matches("^[a-z0-9]{3}$")) {
throw new IllegalArgumentException("AI가 올바르지 않은 Category Key를 생성했습니다.");
}
String routingType = draft.routingType() == null ? "" : draft.routingType().trim().toUpperCase(Locale.ROOT);
if (!Set.of("HTTP", "MCI").contains(routingType)) {
throw new IllegalArgumentException("AI가 지원하지 않는 Protocol을 생성했습니다.");
}
String httpApiName = draft.httpApiName() == null ? "sample" : draft.httpApiName().trim();
if (!httpApiName.matches("^[A-Za-z0-9_-]+$")) {
throw new IllegalArgumentException("AI가 올바르지 않은 HTTP API Name을 생성했습니다.");
}
String title = draft.title() == null ? "" : draft.title().trim();
String description = draft.description() == null ? "" : draft.description().trim();
if (title.isBlank() || description.isBlank()) {
throw new IllegalArgumentException("AI가 Tool 제목 또는 설명을 생성하지 않았습니다.");
}
return new ToolDraft(draft.baseName().trim(), title, description, categoryKey, routingType, httpApiName,
validateFields(draft.inputFields()), validateFields(draft.outputFields()));
}
private String generateAiContent(String prompt, String requestedModel) {
return chatClientBuilder.build().prompt()
.user(prompt)
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
.model(resolveModel(requestedModel))
.build())
.call()
.content();
}
private String resolveModel(String requestedModel) {
String model = requestedModel == null || requestedModel.isBlank()
? "cohere/north-mini-code:free" : requestedModel.trim();
if (!SUPPORTED_AI_MODELS.contains(model)) {
throw new IllegalArgumentException("지원하지 않는 AI 모델입니다.");
}
return model;
}
private String stripCodeFence(String response) {
if (response == null) {
throw new IllegalArgumentException("AI 응답이 비어 있습니다.");
}
return response.trim().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```$", "").trim();
}
private String safeMessage(Exception e) {
return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
}
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
}
private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType,
String httpApiName, List<ToolScaffolder.FieldDefinition> inputFields,
List<ToolScaffolder.FieldDefinition> outputFields) {
}
}

View File

@@ -444,6 +444,27 @@
<!-- Tool Creation Form -->
<div class="tab-pane fade" id="tool" role="tabpanel">
<form id="toolForm">
<div class="mb-4 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
<label class="form-label">AI Tool 초안 만들기</label>
<div class="row g-2">
<div class="col-md-6">
<input id="toolNaturalLanguage" type="text" class="form-control" placeholder="예: 고객번호로 계약 상태를 조회하는 Tool">
</div>
<div class="col-md-3">
<select id="aiModelSelect" class="form-select" aria-label="AI 모델 선택">
<option value="cohere/north-mini-code:free" selected>Cohere North Mini Code</option>
<option value="inclusionai/ling-3.0-flash:free">InclusionAI Ling 3 Flash</option>
<option value="openai/gpt-oss-20b:free">OpenAI GPT-OSS 20B</option>
<option value="google/gemma-4-31b-it:free">Google Gemma 4 31B</option>
<option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron 3 Nano</option>
</select>
</div>
<div class="col-md-3 d-grid">
<button id="toolDraftButton" type="button" class="btn-action" onclick="createAiToolDraft()">AI로 Tool 채우기</button>
</div>
</div>
<div class="input-hint">선택한 AI가 Tool 기본 정보와 Input/Output Fields를 채웁니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 직접 입력하세요.</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Base Name (PascalCase)</label>
@@ -482,11 +503,10 @@
</div>
<div class="col-md-6 mt-3 mt-md-0">
<label class="form-label">Protocol</label>
<select class="form-select" name="routingType">
<option value="HTTP">HTTP (REST)</option>
<option value="TCP">TCP (Socket)</option>
<option value="MCI">MCI (Legacy)</option>
</select>
<select class="form-select" name="routingType">
<option value="HTTP">HTTP (REST)</option>
<option value="MCI">MCI (Legacy)</option>
</select>
</div>
</div>
@@ -627,6 +647,22 @@
</div>
<div class="modal-body">
<p class="input-hint mt-0 mb-3">행을 추가해 필드를 입력하면 저장할 때 JSON으로 자동 변환됩니다.</p>
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<span class="input-hint mt-0 mb-0">빠른 입력:</span>
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('customer')">고객조회</button>
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('list')">목록조회</button>
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('detail')">단건조회</button>
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('write')">등록/변경</button>
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('clear')">초기화</button>
</div>
<div class="mb-4">
<label class="form-label">자연어 초안 만들기</label>
<div class="input-group">
<input id="fieldNaturalLanguage" type="text" class="form-control" placeholder="예: 고객번호로 계약 상태를 조회">
<button id="fieldDraftButton" type="button" class="btn-action" onclick="createNaturalFieldDraft()">AI 초안 만들기</button>
</div>
<div class="input-hint">Chat 화면과 같은 AI 모델이 문장을 분석해 현재 Input 또는 Output 필드 초안을 만듭니다.</div>
</div>
<div class="table-responsive">
<table class="table align-middle" id="fieldEditorTable">
<thead>
@@ -643,6 +679,10 @@
</table>
</div>
<button type="button" class="btn-secondary-action" onclick="addFieldEditorRow()">+ 필드 추가</button>
<div class="mt-4">
<label class="form-label">JSON 미리보기</label>
<pre id="fieldEditorPreview" class="mb-0 p-3 rounded" style="background:#09090b; border:1px solid #27272a; color:#e4e4e7; font-size:0.78rem; max-height:190px; overflow:auto;"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary-action" data-bs-dismiss="modal" style="padding: 0.5rem 1rem; font-size: 0.875rem;">취소</button>
@@ -784,6 +824,53 @@
}
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal'];
const typeExamples = {
String: 'example',
Integer: '1',
Long: '1',
Double: '1.0',
Boolean: 'true',
BigDecimal: '1000.00'
};
const fieldTemplates = {
customer: {
inputFields: [
{ name: 'customerId', type: 'String', description: 'Customer identifier', example: 'CUST00001', required: true }
],
outputFields: [
{ name: 'customerId', type: 'String', description: 'Customer identifier', example: 'CUST00001', required: true },
{ name: 'customerName', type: 'String', description: 'Customer name', example: 'Hong Gildong', required: false }
]
},
list: {
inputFields: [
{ name: 'page', type: 'Integer', description: 'Page number', example: '1', required: false },
{ name: 'pageSize', type: 'Integer', description: 'Items per page', example: '20', required: false }
],
outputFields: [
{ name: 'totalCount', type: 'Long', description: 'Total result count', example: '100', required: false }
]
},
detail: {
inputFields: [
{ name: 'id', type: 'String', description: 'Item identifier', example: 'ID00001', required: true }
],
outputFields: [
{ name: 'resultCode', type: 'String', description: 'Result code', example: 'SUCCESS', required: true },
{ name: 'resultMessage', type: 'String', description: 'Result message', example: 'Completed', required: false }
]
},
write: {
inputFields: [
{ name: 'requestId', type: 'String', description: 'Request identifier', example: 'REQ00001', required: true },
{ name: 'requestType', type: 'String', description: 'Registration or update type', example: 'CREATE', required: true }
],
outputFields: [
{ name: 'resultCode', type: 'String', description: 'Result code', example: 'SUCCESS', required: true },
{ name: 'resultMessage', type: 'String', description: 'Result message', example: 'Completed', required: false }
]
}
};
let fieldEditorTargetId = null;
let fieldEditorModalInstance = null;
@@ -807,6 +894,7 @@
const body = document.getElementById('fieldEditorBody');
body.replaceChildren();
(fields.length ? fields : [{}]).forEach(addFieldEditorRow);
updateFieldEditorPreview();
if (!fieldEditorModalInstance) {
fieldEditorModalInstance = new bootstrap.Modal(document.getElementById('fieldEditorModal'));
@@ -838,6 +926,12 @@
const option = new Option(type, type, false, (field.type || 'String') === type);
typeSelect.add(option);
});
typeSelect.addEventListener('change', () => {
if (!exampleInput.value.trim()) {
exampleInput.value = typeExamples[typeSelect.value];
}
updateFieldEditorPreview();
});
const requiredSelect = document.createElement('select');
requiredSelect.className = 'form-select form-select-sm';
@@ -845,6 +939,11 @@
requiredSelect.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
requiredSelect.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
[nameInput, descriptionInput, exampleInput, requiredSelect].forEach(control => {
control.addEventListener('input', updateFieldEditorPreview);
control.addEventListener('change', updateFieldEditorPreview);
});
[nameInput, typeSelect, descriptionInput, exampleInput, requiredSelect].forEach(control => {
const cell = document.createElement('td');
cell.appendChild(control);
@@ -855,19 +954,122 @@
deleteButton.type = 'button';
deleteButton.className = 'btn-secondary-action';
deleteButton.textContent = '삭제';
deleteButton.addEventListener('click', () => row.remove());
deleteButton.addEventListener('click', () => {
row.remove();
updateFieldEditorPreview();
});
deleteCell.appendChild(deleteButton);
row.appendChild(deleteCell);
document.getElementById('fieldEditorBody').appendChild(row);
updateFieldEditorPreview();
}
function currentEditorFields() {
return [...document.querySelectorAll('#fieldEditorBody tr')]
.map(row => ({
name: row.querySelector('[data-field="name"]').value.trim(),
type: row.querySelector('[data-field="type"]').value,
description: row.querySelector('[data-field="description"]').value.trim(),
example: row.querySelector('[data-field="example"]').value.trim(),
required: row.querySelector('[data-field="required"]').value === 'true'
}))
.filter(field => field.name);
}
function updateFieldEditorPreview() {
const preview = document.getElementById('fieldEditorPreview');
if (preview) preview.textContent = JSON.stringify(currentEditorFields(), null, 2);
}
function applyFieldTemplate(templateName) {
const body = document.getElementById('fieldEditorBody');
body.replaceChildren();
const fields = templateName === 'clear'
? [{}]
: (fieldTemplates[templateName]?.[fieldEditorTargetId] || []);
(fields.length ? fields : [{}]).forEach(addFieldEditorRow);
updateFieldEditorPreview();
}
async function createNaturalFieldDraft() {
const source = document.getElementById('fieldNaturalLanguage').value.trim();
if (!source) {
alert('필드 초안을 만들 문장을 입력해주세요.');
return;
}
const button = document.getElementById('fieldDraftButton');
const originalLabel = button.textContent;
button.textContent = 'AI 생성 중...';
button.disabled = true;
try {
const response = await fetch('/api/v1/scaffold/field-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: source, target: fieldEditorTargetId, model: selectedAiModel() })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'AI 초안 생성에 실패했습니다.');
if (!Array.isArray(result.fields) || result.fields.length === 0) {
throw new Error('AI가 사용할 수 있는 필드를 만들지 못했습니다.');
}
const body = document.getElementById('fieldEditorBody');
body.replaceChildren();
result.fields.forEach(addFieldEditorRow);
updateFieldEditorPreview();
} catch (error) {
alert(`AI 초안 생성 실패: ${error.message}`);
} finally {
button.textContent = originalLabel;
button.disabled = false;
}
}
function selectedAiModel() {
return document.getElementById('aiModelSelect').value;
}
async function createAiToolDraft() {
const source = document.getElementById('toolNaturalLanguage').value.trim();
if (!source) {
alert('AI가 만들 Tool 설명을 입력해주세요.');
return;
}
const button = document.getElementById('toolDraftButton');
const originalLabel = button.textContent;
button.textContent = 'AI 생성 중...';
button.disabled = true;
try {
const response = await fetch('/api/v1/scaffold/tool-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: source, model: selectedAiModel() })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'AI Tool 초안 생성에 실패했습니다.');
const form = document.getElementById('toolForm');
form.elements.baseName.value = result.baseName || '';
form.elements.title.value = result.title || '';
form.elements.description.value = result.description || '';
form.elements.categoryKey.value = result.categoryKey || '';
form.elements.routingType.value = result.routingType || 'HTTP';
form.elements.httpApiName.value = result.httpApiName || 'sample';
document.getElementById('inputFields').value = JSON.stringify(result.inputFields || [], null, 2);
document.getElementById('outputFields').value = JSON.stringify(result.outputFields || [], null, 2);
alert('Tool 초안을 채웠습니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 입력해주세요.');
} catch (error) {
alert(`AI Tool 초안 생성 실패: ${error.message}`);
} finally {
button.textContent = originalLabel;
button.disabled = false;
}
}
function applyFieldEditor() {
const rows = [...document.querySelectorAll('#fieldEditorBody tr')];
const names = new Set();
const fields = [];
for (const row of rows) {
const name = row.querySelector('[data-field="name"]').value.trim();
if (!name) continue;
const fields = currentEditorFields();
for (const field of fields) {
const name = field.name;
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
alert(`필드명 "${name}"은(는) Java 필드명 형식으로 입력해주세요.`);
return;
@@ -877,13 +1079,6 @@
return;
}
names.add(name);
fields.push({
name,
type: row.querySelector('[data-field="type"]').value,
description: row.querySelector('[data-field="description"]').value.trim(),
example: row.querySelector('[data-field="example"]').value.trim(),
required: row.querySelector('[data-field="required"]').value === 'true'
});
}
document.getElementById(fieldEditorTargetId).value = JSON.stringify(fields, null, 2);
fieldEditorModalInstance.hide();

View File

@@ -0,0 +1,48 @@
package io.shinhanlife.dap.mcg.presentation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class ScaffoldingControllerToolDraftTest {
@Test
void toolDraftEndpointIsAvailable() throws Exception {
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("""
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","inputFields":[{"name":"customerId","type":"String","description":"고객번호","example":"C123","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","example":"SUCCESS","required":true}]}
""");
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ScaffoldingController(builder, new ObjectMapper()))
.setMessageConverters(new MappingJackson2HttpMessageConverter())
.build();
mockMvc.perform(post("/api/v1/scaffold/tool-draft")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"description\":\"고객번호로 계약 상태를 조회\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.baseName").value("CustomerContractStatus"))
.andExpect(jsonPath("$.inputFields[0].name").value("customerId"));
}
}