feat: tool report 카테고리 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m29s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m29s
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Gateway 내부 MCP 목록 조회와 Report 서비스의 Excel API 프록시를 담당한다. */
|
||||
@RestController
|
||||
public class ToolReportProxyController {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String reportServiceUrl;
|
||||
private final McpRouterController mcpRouterController;
|
||||
|
||||
public ToolReportProxyController(
|
||||
RestClient.Builder restClientBuilder,
|
||||
McpRouterController mcpRouterController,
|
||||
@Value("${report.service-url:http://127.0.0.1:8092}") String reportServiceUrl) {
|
||||
this.restClient = restClientBuilder.build();
|
||||
this.mcpRouterController = mcpRouterController;
|
||||
this.reportServiceUrl = reportServiceUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
/** Tool 목록은 Report 서비스나 로컬 소스가 아니라 Gateway의 MCP Registry에서만 조회한다. */
|
||||
@GetMapping(value = "/report/api/report-tools", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<ReportToolSummary> tools() {
|
||||
return activeTools().stream().map(ReportToolSummary::from).toList();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/report/api/tool-reports/excel",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> excel(@RequestBody Map<String, Object> request) {
|
||||
validateMcpSelection(request);
|
||||
ResponseEntity<byte[]> response = restClient.post()
|
||||
.uri(reportServiceUrl + "/api/tool-reports/excel")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(byte[].class);
|
||||
return copy(response);
|
||||
}
|
||||
|
||||
private void validateMcpSelection(Map<String, Object> request) {
|
||||
Object rawNames = request.get("toolNames");
|
||||
if (!(rawNames instanceof List<?> names) || names.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "toolNames must contain at least one tool");
|
||||
}
|
||||
Set<String> registeredNames = activeTools().stream()
|
||||
.map(ToolMetadata::getName)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> unknown = new LinkedHashSet<>();
|
||||
names.stream().map(String::valueOf)
|
||||
.filter(name -> !registeredNames.contains(name))
|
||||
.forEach(unknown::add);
|
||||
if (!unknown.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"MCP에 등록되지 않은 툴이 포함되어 있습니다: " + unknown);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ToolMetadata> activeTools() {
|
||||
ResponseEntity<JsonRpcResponse> response = mcpRouterController.listTools(null);
|
||||
JsonRpcResponse body = response.getBody();
|
||||
if (body == null || !(body.getResult() instanceof Map<?, ?> result)
|
||||
|| !(result.get("tools") instanceof List<?> tools)) {
|
||||
throw new IllegalStateException("MCP tools/list response does not contain result.tools");
|
||||
}
|
||||
return tools.stream().map(item -> {
|
||||
if (!(item instanceof ToolMetadata metadata)) {
|
||||
throw new IllegalStateException("MCP tools/list contains an invalid tool entry");
|
||||
}
|
||||
return metadata;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> copy(ResponseEntity<byte[]> response) {
|
||||
ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode());
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
if (contentType != null) builder.contentType(contentType);
|
||||
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
if (disposition != null) builder.header(HttpHeaders.CONTENT_DISPOSITION, disposition);
|
||||
return builder.body(response.getBody());
|
||||
}
|
||||
|
||||
public record ReportToolSummary(
|
||||
String name,
|
||||
String title,
|
||||
String description,
|
||||
String categoryKey,
|
||||
String mappingId,
|
||||
boolean register,
|
||||
boolean requiresApproval,
|
||||
boolean readOnlyHint,
|
||||
boolean destructiveHint,
|
||||
boolean idempotentHint,
|
||||
boolean openWorldHint,
|
||||
String requestType,
|
||||
String responseType,
|
||||
String useCaseClass,
|
||||
String sourceFile,
|
||||
String inputSchemaResource,
|
||||
String outputSchemaResource) {
|
||||
|
||||
static ReportToolSummary from(ToolMetadata tool) {
|
||||
String name = tool.getName() == null || tool.getName().isBlank() ? tool.getUid() : tool.getName();
|
||||
String title = tool.getDisplayName() == null || tool.getDisplayName().isBlank()
|
||||
? name : tool.getDisplayName();
|
||||
return new ReportToolSummary(
|
||||
name, title, value(tool.getDescription()), value(tool.getCategoryKey()),
|
||||
value(tool.getMciServiceId()), Boolean.TRUE.equals(tool.getIsRegistered()),
|
||||
Boolean.TRUE.equals(tool.getRequiresApproval()), Boolean.TRUE.equals(tool.getReadOnlyHint()),
|
||||
Boolean.TRUE.equals(tool.getDestructiveHint()), Boolean.TRUE.equals(tool.getIdempotentHint()),
|
||||
Boolean.TRUE.equals(tool.getOpenWorldHint()), "", "", "", "", "", "");
|
||||
}
|
||||
|
||||
private static String value(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<a href="/chat.html" style="color:#ffffff;" class="font-semibold">Chat</a>
|
||||
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<a href="/chat.html" class="text-zinc-400 hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" class="text-zinc-400 hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" class="text-zinc-400 hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" style="color:#ffffff;" class="font-semibold">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
199
dap-gateway/src/main/resources/static/tool-report.html
Normal file
199
dap-gateway/src/main/resources/static/tool-report.html
Normal file
@@ -0,0 +1,199 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AXHUB Tool Report</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Geist", "Pretendard", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #09090b;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: #09090b; color: #f4f4f5; overflow-y: scroll; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid #27272a; background: rgba(9,9,11,.86); backdrop-filter: blur(16px); }
|
||||
.nav-shell { max-width: 1152px; height: 56px; margin: 0 auto; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.nav-left, nav { display: flex; align-items: center; }
|
||||
.brand { display: flex; align-items: center; font-size: 14px; font-weight: 600; letter-spacing: -.01em; }
|
||||
.brand-dot { width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,.8); }
|
||||
.divider { width: 1px; height: 16px; margin: 0 20px; background: #27272a; }
|
||||
nav { gap: 20px; font-size: 13px; font-weight: 500; color: #a1a1aa; }
|
||||
nav a { transition: color .15s ease; white-space: nowrap; }
|
||||
nav a:hover { color: #fff; }
|
||||
nav .active { color: #fff; font-weight: 600; }
|
||||
.version { padding: 4px 8px; border: 1px solid rgba(59,130,246,.2); border-radius: 5px; background: rgba(59,130,246,.1); color: #60a5fa; font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
main { max-width: 1152px; margin: 0 auto; padding: 40px 24px 72px; }
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
|
||||
h1 { margin: 0 0 10px; font-size: 30px; line-height: 1.2; letter-spacing: -.035em; }
|
||||
.subtitle { margin: 0; color: #a1a1aa; font-size: 14px; line-height: 1.65; }
|
||||
.count-badge { padding: 7px 11px; border: 1px solid #27272a; border-radius: 8px; background: #18181b; color: #a1a1aa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; white-space: nowrap; }
|
||||
.panel { overflow: hidden; border: 1px solid #27272a; border-radius: 12px; background: #111113; box-shadow: 0 18px 50px rgba(0,0,0,.22); }
|
||||
.toolbar { display: grid; grid-template-columns: minmax(260px,1fr) 190px auto auto; gap: 10px; padding: 16px; border-bottom: 1px solid #27272a; background: #111113; }
|
||||
input, select, button { min-height: 40px; border: 1px solid #3f3f46; border-radius: 7px; background: #18181b; color: #e4e4e7; padding: 8px 12px; font: inherit; font-size: 13px; outline: none; }
|
||||
input::placeholder { color: #71717a; }
|
||||
input:focus, select:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
|
||||
button { cursor: pointer; font-weight: 600; transition: border-color .15s, background .15s, color .15s; }
|
||||
button:hover { border-color: #52525b; background: #27272a; color: #fff; }
|
||||
button.primary { min-width: 174px; border-color: #2563eb; background: #2563eb; color: #fff; }
|
||||
button.primary:hover { background: #1d4ed8; }
|
||||
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.table-wrap { max-height: 570px; overflow: auto; }
|
||||
table { width: 100%; min-width: 980px; border-collapse: collapse; }
|
||||
th, td { padding: 13px 15px; border-bottom: 1px solid #27272a; text-align: left; vertical-align: top; }
|
||||
th { position: sticky; top: 0; z-index: 2; background: #18181b; color: #a1a1aa; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||
td { color: #d4d4d8; font-size: 13px; line-height: 1.55; }
|
||||
tbody tr { transition: background .12s ease; }
|
||||
tbody tr:hover { background: rgba(39,39,42,.5); }
|
||||
td:first-child, th:first-child { width: 58px; text-align: center; }
|
||||
input[type="checkbox"] { width: 16px; height: 16px; min-height: 0; accent-color: #3b82f6; }
|
||||
code { color: #60a5fa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; }
|
||||
.muted { color: #71717a; }
|
||||
.panel-footer { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 16px; border-top: 1px solid #27272a; background: #111113; }
|
||||
#message { color: #a1a1aa; font-size: 13px; }
|
||||
#message.error { color: #fca5a5; }
|
||||
.loading-row { padding: 42px !important; color: #71717a; text-align: center !important; }
|
||||
@media (max-width: 900px) {
|
||||
.nav-shell { overflow-x: auto; }
|
||||
.version { display: none; }
|
||||
nav { gap: 14px; }
|
||||
.toolbar { grid-template-columns: 1fr 1fr; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="nav-shell">
|
||||
<div class="nav-left">
|
||||
<a id="gatewayHome" class="brand" href="#"><span class="brand-dot"></span><span>AXHUB Gateway</span></a>
|
||||
<div class="divider"></div>
|
||||
<nav>
|
||||
<a data-gateway-path="/admin/scaffold.html" href="#">Scaffold</a>
|
||||
<a data-gateway-path="/catalog.html" href="#">Catalog</a>
|
||||
<a data-gateway-path="/playground.html" href="#">Playground</a>
|
||||
<a data-gateway-path="/chat.html" href="#">Chat</a>
|
||||
<a data-gateway-path="/tester.html" href="#">Tester</a>
|
||||
<a data-gateway-path="/tool-test-console.html" href="#">Console</a>
|
||||
<a class="active" href="/tool-report.html">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<span class="version">v0.0.1</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>Tool Report</h1>
|
||||
<p class="subtitle">보고서에 포함할 MCP 툴을 선택하고, 소스 어노테이션과 전문 필드를 동일한 Excel 양식으로 생성합니다.</p>
|
||||
</div>
|
||||
<span id="summaryBadge" class="count-badge">Loading tools...</span>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
<input id="keyword" type="search" autocomplete="off" placeholder="툴명, 제목 또는 설명 검색">
|
||||
<select id="category"><option value="">전체 카테고리</option></select>
|
||||
<button id="selectAll" type="button">전체 선택</button>
|
||||
<button id="clearAll" type="button">전체 해제</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>선택</th><th>툴명</th><th>제목</th><th>카테고리</th><th>연계 ID</th><th>설명</th></tr></thead>
|
||||
<tbody id="tools"><tr><td class="loading-row" colspan="6">툴 목록을 불러오는 중입니다.</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<span id="message">소스 인덱스를 준비하고 있습니다.</span>
|
||||
<button id="download" type="button" class="primary" disabled>Excel 보고서 생성</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const state = { tools: [], selected: new Set() };
|
||||
const el = id => document.getElementById(id);
|
||||
|
||||
const gateway = location.origin;
|
||||
el('gatewayHome').href = gateway + '/index.html';
|
||||
document.querySelectorAll('[data-gateway-path]').forEach(link => link.href = gateway + link.dataset.gatewayPath);
|
||||
|
||||
function filtered() {
|
||||
const keyword = el('keyword').value.trim().toLowerCase();
|
||||
const category = el('category').value;
|
||||
return state.tools.filter(tool => (!category || tool.categoryKey === category) &&
|
||||
(!keyword || [tool.name, tool.title, tool.description].join(' ').toLowerCase().includes(keyword)));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const visible = filtered();
|
||||
el('tools').innerHTML = visible.length ? visible.map(tool => `<tr>
|
||||
<td><input type="checkbox" aria-label="${escapeHtml(tool.name)} 선택" data-name="${escapeHtml(tool.name)}" ${state.selected.has(tool.name) ? 'checked' : ''}></td>
|
||||
<td><code>${escapeHtml(tool.name)}</code></td><td>${escapeHtml(tool.title)}</td>
|
||||
<td>${escapeHtml(tool.categoryKey)}</td><td>${escapeHtml(tool.mappingId || '-')}</td><td class="muted">${escapeHtml(tool.description)}</td>
|
||||
</tr>`).join('') : '<tr><td class="loading-row" colspan="6">조건에 맞는 툴이 없습니다.</td></tr>';
|
||||
el('tools').querySelectorAll('input[type=checkbox]').forEach(box => box.addEventListener('change', event => {
|
||||
event.target.checked ? state.selected.add(event.target.dataset.name) : state.selected.delete(event.target.dataset.name);
|
||||
updateStatus();
|
||||
}));
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function updateStatus(text) {
|
||||
el('message').textContent = text || `전체 ${state.tools.length}개 중 ${state.selected.size}개 선택`;
|
||||
el('message').classList.remove('error');
|
||||
el('summaryBadge').textContent = `${state.tools.length} tools · ${state.selected.size} selected`;
|
||||
el('download').disabled = state.selected.size === 0;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch('/report/api/report-tools');
|
||||
if (!response.ok) throw new Error('툴 목록 조회에 실패했습니다.');
|
||||
state.tools = await response.json();
|
||||
[...new Set(state.tools.map(tool => tool.categoryKey))].sort().forEach(category => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category; option.textContent = category; el('category').appendChild(option);
|
||||
});
|
||||
render();
|
||||
} catch (error) {
|
||||
el('tools').innerHTML = '<tr><td class="loading-row" colspan="6">툴 목록을 표시할 수 없습니다.</td></tr>';
|
||||
el('message').textContent = error.message; el('message').classList.add('error');
|
||||
el('summaryBadge').textContent = 'Load failed';
|
||||
}
|
||||
}
|
||||
|
||||
el('keyword').addEventListener('input', render);
|
||||
el('category').addEventListener('change', render);
|
||||
el('selectAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.add(tool.name)); render(); });
|
||||
el('clearAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.delete(tool.name)); render(); });
|
||||
el('download').addEventListener('click', async () => {
|
||||
el('download').disabled = true; updateStatus('소스를 분석하고 Excel을 생성하는 중입니다.');
|
||||
try {
|
||||
const response = await fetch('/report/api/tool-reports/excel', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({toolNames: [...state.selected]})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || '보고서 생성에 실패했습니다.');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
|
||||
anchor.href = url; anchor.download = 'tool-report.xlsx'; anchor.click(); URL.revokeObjectURL(url);
|
||||
updateStatus(`선택한 ${state.selected.size}개 툴의 보고서를 생성했습니다.`);
|
||||
} catch (error) {
|
||||
el('message').textContent = error.message; el('message').classList.add('error');
|
||||
} finally { el('download').disabled = state.selected.size === 0; }
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
dap-tool-report/Dockerfile
Normal file
14
dap-tool-report/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
WORKDIR /workspace
|
||||
COPY . .
|
||||
RUN chmod +x gradlew \
|
||||
&& ./gradlew :dap-tool-report:bootJar -x test --no-daemon \
|
||||
&& find dap-tool-report/build/libs -name '*-SNAPSHOT.jar' ! -name '*-plain.jar' -exec cp {} /workspace/app.jar \;
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ=Asia/Seoul
|
||||
COPY --from=builder /workspace/app.jar app.jar
|
||||
EXPOSE 8092
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
16
dap-tool-report/build.gradle
Normal file
16
dap-tool-report/build.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
id 'org.springframework.boot'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
||||
implementation 'com.github.javaparser:javaparser-core:3.26.3'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.3.0'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.dap.report;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.report
|
||||
* @className ToolReportApplication
|
||||
* @description 툴 소스를 읽기 전용으로 분석하여 Excel 보고서를 생성하는 독립 애플리케이션
|
||||
* @author 0986406
|
||||
* @create 2026.08.07
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.08.07 0986406 최초생성
|
||||
* </pre>
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class ToolReportApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ToolReportApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.shinhanlife.dap.report.application;
|
||||
|
||||
import io.shinhanlife.dap.report.excel.ToolReportExcelWriter;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import io.shinhanlife.dap.report.source.ToolDetailAnalyzer;
|
||||
import io.shinhanlife.dap.report.source.ToolSourceDiscovery;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Gateway가 선택해 전달한 툴의 원천 분석과 Excel 출력을 담당한다. */
|
||||
@Service
|
||||
public class ToolReportService {
|
||||
|
||||
private final ToolSourceDiscovery discovery;
|
||||
private final ToolDetailAnalyzer analyzer;
|
||||
private final ToolReportExcelWriter excelWriter;
|
||||
|
||||
public ToolReportService(ToolSourceDiscovery discovery,
|
||||
ToolDetailAnalyzer analyzer,
|
||||
ToolReportExcelWriter excelWriter) {
|
||||
this.discovery = discovery;
|
||||
this.analyzer = analyzer;
|
||||
this.excelWriter = excelWriter;
|
||||
}
|
||||
|
||||
public byte[] createExcel(List<String> selectedNames) {
|
||||
if (selectedNames == null || selectedNames.isEmpty()) {
|
||||
throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다.");
|
||||
}
|
||||
|
||||
Map<String, ToolSummary> sourceTools = new LinkedHashMap<>();
|
||||
discovery.discover().forEach(tool -> sourceTools.put(tool.name(), tool));
|
||||
List<String> missingSources = selectedNames.stream()
|
||||
.filter(name -> !sourceTools.containsKey(name)).distinct().toList();
|
||||
if (!missingSources.isEmpty()) {
|
||||
throw new IllegalArgumentException("분석할 원천 소스가 없는 툴입니다: " + missingSources);
|
||||
}
|
||||
|
||||
List<ToolReportModel> reports = selectedNames.stream().distinct()
|
||||
.map(sourceTools::get)
|
||||
.map(analyzer::analyze)
|
||||
.toList();
|
||||
return excelWriter.write(reports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package io.shinhanlife.dap.report.config;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** 보고서 모듈 설정. */
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(ReportProperties.class)
|
||||
public class ReportConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package io.shinhanlife.dap.report.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** 보고서 소스 위치와 출력 정책 설정. */
|
||||
@ConfigurationProperties(prefix = "report")
|
||||
public record ReportProperties(String sourceRoot, String outputFilenamePrefix) {
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package io.shinhanlife.dap.report.excel;
|
||||
|
||||
import io.shinhanlife.dap.report.model.FieldDefinition;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.Font;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap;
|
||||
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
|
||||
import org.apache.poi.xssf.usermodel.XSSFColor;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 모든 보고서 시트에 동일한 전문 I/O 문서 디자인을 적용한다. */
|
||||
@Component
|
||||
public class ToolReportExcelWriter {
|
||||
|
||||
private static final int HEADER_ROW = 7;
|
||||
private static final int FIRST_DATA_ROW = 8;
|
||||
|
||||
public byte[] write(List<ToolReportModel> reports) {
|
||||
try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
Styles styles = new Styles(workbook);
|
||||
writeSummary(workbook, reports, styles);
|
||||
writeFields(workbook, reports, styles);
|
||||
writeDiagnostics(workbook, reports, styles);
|
||||
workbook.write(output);
|
||||
return output.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Excel 보고서 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeSummary(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
|
||||
String[] headers = {"No.", "툴명", "제목", "설명", "카테고리", "연계 ID", "등록", "승인 필요",
|
||||
"Read Only", "Destructive", "Idempotent", "Open World", "Request", "Response", "UseCase", "원천 파일"};
|
||||
Sheet sheet = workbook.createSheet("툴 기본정보");
|
||||
decorateSheet(sheet, "MCP Tool Catalog Report", "선택 툴 기본정보", reports, headers.length, styles);
|
||||
header(sheet, headers, styles);
|
||||
int rowIndex = FIRST_DATA_ROW;
|
||||
int sequence = 1;
|
||||
for (ToolReportModel report : reports) {
|
||||
ToolSummary tool = report.tool();
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
Object[] values = {sequence++, tool.name(), tool.title(), tool.description(), tool.categoryKey(),
|
||||
tool.mappingId(), yn(tool.register()), yn(tool.requiresApproval()), yn(tool.readOnlyHint()),
|
||||
yn(tool.destructiveHint()), yn(tool.idempotentHint()), yn(tool.openWorldHint()),
|
||||
tool.requestType(), tool.responseType(), tool.useCaseClass(), tool.sourceFile()};
|
||||
values(row, values, styles.body);
|
||||
row.getCell(0).setCellStyle(styles.bodyCenter);
|
||||
}
|
||||
finishTable(sheet, rowIndex, headers.length,
|
||||
new int[]{7, 30, 24, 45, 12, 18, 10, 12, 12, 12, 12, 12, 20, 20, 25, 55});
|
||||
}
|
||||
|
||||
private void writeFields(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
|
||||
String[] headers = {"No.", "툴명", "원천 종류", "방향", "소유 타입", "필드명", "데이터 타입",
|
||||
"필수", "설명", "제약조건 / 어노테이션", "원천 파일"};
|
||||
Sheet sheet = workbook.createSheet("수집 필드");
|
||||
decorateSheet(sheet, "Tool Source I/O Report", "어노테이션 · DTO · 전문 필드", reports, headers.length, styles);
|
||||
header(sheet, headers, styles);
|
||||
int rowIndex = FIRST_DATA_ROW;
|
||||
int sequence = 1;
|
||||
for (ToolReportModel report : reports) {
|
||||
for (FieldDefinition field : report.fields()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
Object[] values = {sequence++, field.toolName(), field.sourceKind(), field.direction(),
|
||||
field.ownerType(), field.fieldName(), field.dataType(),
|
||||
field.required() == null ? "" : yn(field.required()), field.description(),
|
||||
field.constraints(), field.sourceFile()};
|
||||
values(row, values, styles.body);
|
||||
row.getCell(0).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(2).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(3).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(7).setCellStyle(styles.bodyCenter);
|
||||
}
|
||||
}
|
||||
finishTable(sheet, rowIndex, headers.length,
|
||||
new int[]{7, 30, 16, 11, 24, 24, 18, 9, 45, 55, 60});
|
||||
}
|
||||
|
||||
private void writeDiagnostics(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
|
||||
String[] headers = {"No.", "툴명", "수준", "진단 내용"};
|
||||
Sheet sheet = workbook.createSheet("분석 결과");
|
||||
decorateSheet(sheet, "Tool Analysis Report", "소스 분석 및 정합성 진단", reports, headers.length, styles);
|
||||
header(sheet, headers, styles);
|
||||
int rowIndex = FIRST_DATA_ROW;
|
||||
int sequence = 1;
|
||||
for (ToolReportModel report : reports) {
|
||||
if (report.diagnostics().isEmpty()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
values(row, new Object[]{sequence++, report.tool().name(), "정상", "분석 경고 없음"}, styles.body);
|
||||
row.getCell(0).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(2).setCellStyle(styles.bodyCenter);
|
||||
} else {
|
||||
for (String diagnostic : report.diagnostics()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
values(row, new Object[]{sequence++, report.tool().name(), "경고", diagnostic}, styles.warning);
|
||||
row.getCell(0).setCellStyle(styles.warningCenter);
|
||||
row.getCell(2).setCellStyle(styles.warningCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
finishTable(sheet, rowIndex, headers.length, new int[]{7, 30, 12, 80});
|
||||
}
|
||||
|
||||
private void decorateSheet(Sheet sheet, String titleText, String reportType,
|
||||
List<ToolReportModel> reports, int columnCount, Styles styles) {
|
||||
sheet.setDisplayGridlines(false);
|
||||
sheet.setAutobreaks(true);
|
||||
sheet.getPrintSetup().setLandscape(true);
|
||||
sheet.getPrintSetup().setFitWidth((short) 1);
|
||||
sheet.getPrintSetup().setFitHeight((short) 0);
|
||||
sheet.setFitToPage(true);
|
||||
|
||||
Row title = sheet.createRow(0);
|
||||
title.setHeightInPoints(24);
|
||||
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, columnCount - 1));
|
||||
Cell titleCell = title.createCell(0);
|
||||
titleCell.setCellValue(titleText);
|
||||
titleCell.setCellStyle(styles.title);
|
||||
|
||||
metadataRow(sheet, 2, "보고서 구분", reportType, columnCount, styles);
|
||||
metadataRow(sheet, 3, "선택 툴 수", reports.size(), columnCount, styles);
|
||||
metadataRow(sheet, 4, "생성 일시",
|
||||
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), columnCount, styles);
|
||||
}
|
||||
|
||||
private void metadataRow(Sheet sheet, int rowIndex, String label, Object value,
|
||||
int columnCount, Styles styles) {
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
row.setHeightInPoints(19);
|
||||
int labelEnd = Math.min(1, columnCount - 1);
|
||||
if (labelEnd > 0) sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, labelEnd));
|
||||
Cell labelCell = row.createCell(0);
|
||||
labelCell.setCellValue(label);
|
||||
labelCell.setCellStyle(styles.metaLabel);
|
||||
int valueStart = labelEnd + 1;
|
||||
if (valueStart < columnCount - 1) {
|
||||
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, valueStart, columnCount - 1));
|
||||
}
|
||||
Cell valueCell = row.createCell(valueStart);
|
||||
if (value instanceof Number number) valueCell.setCellValue(number.doubleValue());
|
||||
else valueCell.setCellValue(value == null ? "" : String.valueOf(value));
|
||||
valueCell.setCellStyle(styles.metaValue);
|
||||
}
|
||||
|
||||
private void header(Sheet sheet, String[] headers, Styles styles) {
|
||||
Row row = sheet.createRow(HEADER_ROW);
|
||||
row.setHeightInPoints(30);
|
||||
for (int column = 0; column < headers.length; column++) {
|
||||
Cell cell = row.createCell(column);
|
||||
cell.setCellValue(headers[column]);
|
||||
cell.setCellStyle(styles.header);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishTable(Sheet sheet, int rowIndex, int columnCount, int[] characterWidths) {
|
||||
widths(sheet, characterWidths);
|
||||
sheet.createFreezePane(0, FIRST_DATA_ROW);
|
||||
sheet.setAutoFilter(new CellRangeAddress(HEADER_ROW, Math.max(HEADER_ROW, rowIndex - 1), 0, columnCount - 1));
|
||||
sheet.setRepeatingRows(new CellRangeAddress(HEADER_ROW, HEADER_ROW, -1, -1));
|
||||
}
|
||||
|
||||
private void values(Row row, Object[] values, CellStyle style) {
|
||||
row.setHeightInPoints(19);
|
||||
for (int column = 0; column < values.length; column++) {
|
||||
Cell cell = row.createCell(column);
|
||||
Object value = values[column];
|
||||
if (value instanceof Number number) cell.setCellValue(number.doubleValue());
|
||||
else cell.setCellValue(value == null ? "" : String.valueOf(value));
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
}
|
||||
|
||||
private void widths(Sheet sheet, int[] characterWidths) {
|
||||
for (int i = 0; i < characterWidths.length; i++) {
|
||||
sheet.setColumnWidth(i, Math.min(255, characterWidths[i]) * 256);
|
||||
}
|
||||
}
|
||||
|
||||
private String yn(boolean value) {
|
||||
return value ? "Y" : "N";
|
||||
}
|
||||
|
||||
private static final class Styles {
|
||||
private final CellStyle title;
|
||||
private final CellStyle metaLabel;
|
||||
private final CellStyle metaValue;
|
||||
private final CellStyle header;
|
||||
private final CellStyle body;
|
||||
private final CellStyle bodyCenter;
|
||||
private final CellStyle warning;
|
||||
private final CellStyle warningCenter;
|
||||
|
||||
private Styles(Workbook workbook) {
|
||||
title = style(workbook, "#262626", "#FFFFFF", 12, true, HorizontalAlignment.CENTER, false);
|
||||
metaLabel = style(workbook, "#333333", "#FFFFFF", 10, true, HorizontalAlignment.CENTER, false);
|
||||
metaValue = style(workbook, "#FFFFFF", "#222222", 10, false, HorizontalAlignment.LEFT, false);
|
||||
header = style(workbook, "#404040", "#FFFFFF", 9, true, HorizontalAlignment.CENTER, true);
|
||||
body = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.LEFT, true);
|
||||
bodyCenter = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.CENTER, true);
|
||||
warning = style(workbook, "#FFF2CC", "#7F6000", 9, false, HorizontalAlignment.LEFT, true);
|
||||
warningCenter = style(workbook, "#FFF2CC", "#7F6000", 9, true, HorizontalAlignment.CENTER, true);
|
||||
}
|
||||
|
||||
private CellStyle style(Workbook workbook, String fill, String fontColor, int size, boolean bold,
|
||||
HorizontalAlignment alignment, boolean borders) {
|
||||
XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle();
|
||||
style.setFillForegroundColor(color(fill));
|
||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
style.setAlignment(alignment);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
style.setWrapText(true);
|
||||
Font font = workbook.createFont();
|
||||
font.setFontName("Carlito");
|
||||
font.setFontHeightInPoints((short) size);
|
||||
font.setBold(bold);
|
||||
((org.apache.poi.xssf.usermodel.XSSFFont) font).setColor(color(fontColor));
|
||||
style.setFont(font);
|
||||
if (borders) applyBorders(style);
|
||||
return style;
|
||||
}
|
||||
|
||||
private void applyBorders(CellStyle style) {
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
short borderColor = org.apache.poi.ss.usermodel.IndexedColors.GREY_25_PERCENT.getIndex();
|
||||
style.setTopBorderColor(borderColor);
|
||||
style.setBottomBorderColor(borderColor);
|
||||
style.setLeftBorderColor(borderColor);
|
||||
style.setRightBorderColor(borderColor);
|
||||
}
|
||||
|
||||
private XSSFColor color(String hex) {
|
||||
return new XSSFColor(Color.decode(hex), new DefaultIndexedColorMap());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
/** DTO, JSON Schema 또는 원천 전문에서 수집한 필드 정의. */
|
||||
public record FieldDefinition(
|
||||
String toolName,
|
||||
String sourceKind,
|
||||
String direction,
|
||||
String ownerType,
|
||||
String fieldName,
|
||||
String dataType,
|
||||
Boolean required,
|
||||
String description,
|
||||
String constraints,
|
||||
String sourceFile) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 수집 단계와 Excel 출력 단계를 분리하는 표준 중간 모델. */
|
||||
public record ToolReportModel(ToolSummary tool, List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 사용자가 선택한 툴 보고서 생성 요청. */
|
||||
public record ToolReportRequest(List<String> toolNames) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
/** UI 선택 목록과 보고서 기본정보에 사용하는 툴 요약. */
|
||||
public record ToolSummary(
|
||||
String name,
|
||||
String title,
|
||||
String description,
|
||||
String categoryKey,
|
||||
String mappingId,
|
||||
boolean register,
|
||||
boolean requiresApproval,
|
||||
boolean readOnlyHint,
|
||||
boolean destructiveHint,
|
||||
boolean idempotentHint,
|
||||
boolean openWorldHint,
|
||||
String requestType,
|
||||
String responseType,
|
||||
String useCaseClass,
|
||||
String sourceFile,
|
||||
String inputSchemaResource,
|
||||
String outputSchemaResource) {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package io.shinhanlife.dap.report.presentation;
|
||||
|
||||
import io.shinhanlife.dap.report.application.ToolReportService;
|
||||
import io.shinhanlife.dap.report.config.ReportProperties;
|
||||
import io.shinhanlife.dap.report.model.ToolReportRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 툴 선택 목록과 Excel 다운로드 API. */
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ToolReportController {
|
||||
|
||||
private final ToolReportService service;
|
||||
private final ReportProperties properties;
|
||||
|
||||
public ToolReportController(ToolReportService service, ReportProperties properties) {
|
||||
this.service = service;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/tool-reports/excel",
|
||||
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> excel(@RequestBody ToolReportRequest request) {
|
||||
byte[] content = service.createExcel(request.toolNames());
|
||||
String prefix = properties.outputFilenamePrefix() == null || properties.outputFilenamePrefix().isBlank()
|
||||
? "tool-report" : properties.outputFilenamePrefix();
|
||||
String filename = prefix + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
|
||||
ContentDisposition disposition = ContentDisposition.attachment()
|
||||
.filename(filename, StandardCharsets.UTF_8)
|
||||
.build();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
|
||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.contentLength(content.length)
|
||||
.body(content);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> badRequest(IllegalArgumentException exception) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import com.github.javaparser.ast.NodeList;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.expr.MemberValuePair;
|
||||
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.StringLiteralExpr;
|
||||
import java.util.Optional;
|
||||
|
||||
/** JavaParser AST에서 어노테이션 값을 안전하게 읽는 도우미. */
|
||||
final class JavaAnnotationReader {
|
||||
|
||||
private JavaAnnotationReader() {
|
||||
}
|
||||
|
||||
static Optional<AnnotationExpr> find(NodeList<AnnotationExpr> annotations, String simpleName) {
|
||||
return annotations.stream().filter(a -> a.getName().getIdentifier().equals(simpleName)).findFirst();
|
||||
}
|
||||
|
||||
static Optional<Expression> value(AnnotationExpr annotation, String key) {
|
||||
if (annotation instanceof NormalAnnotationExpr normal) {
|
||||
return normal.getPairs().stream()
|
||||
.filter(pair -> pair.getNameAsString().equals(key))
|
||||
.map(MemberValuePair::getValue)
|
||||
.findFirst();
|
||||
}
|
||||
if (annotation instanceof SingleMemberAnnotationExpr single && "value".equals(key)) {
|
||||
return Optional.of(single.getMemberValue());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static String string(AnnotationExpr annotation, String key, String fallback) {
|
||||
return value(annotation, key)
|
||||
.filter(StringLiteralExpr.class::isInstance)
|
||||
.map(StringLiteralExpr.class::cast)
|
||||
.map(StringLiteralExpr::asString)
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
static boolean bool(AnnotationExpr annotation, String key, boolean fallback) {
|
||||
return value(annotation, key)
|
||||
.filter(BooleanLiteralExpr.class::isInstance)
|
||||
.map(BooleanLiteralExpr.class::cast)
|
||||
.map(BooleanLiteralExpr::getValue)
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
static Optional<AnnotationExpr> nested(AnnotationExpr annotation, String key) {
|
||||
return value(annotation, key).filter(AnnotationExpr.class::isInstance).map(AnnotationExpr.class::cast);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import io.shinhanlife.dap.report.model.FieldDefinition;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 선택된 툴의 DTO, JSON Schema와 원천 전문 필드를 수집한다. */
|
||||
@Component
|
||||
public class ToolDetailAnalyzer {
|
||||
|
||||
private final ToolSourceDiscovery discovery;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ToolDetailAnalyzer(ToolSourceDiscovery discovery) {
|
||||
this.discovery = discovery;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public ToolReportModel analyze(ToolSummary tool) {
|
||||
List<FieldDefinition> fields = new ArrayList<>();
|
||||
List<String> diagnostics = new ArrayList<>();
|
||||
Map<String, Path> javaFiles = indexJavaFiles();
|
||||
|
||||
collectSchema(tool, tool.inputSchemaResource(), "INPUT", fields, diagnostics);
|
||||
collectSchema(tool, tool.outputSchemaResource(), "OUTPUT", fields, diagnostics);
|
||||
if (tool.inputSchemaResource().isBlank()) {
|
||||
collectJavaType(tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
|
||||
}
|
||||
if (tool.outputSchemaResource().isBlank()) {
|
||||
collectJavaType(tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
|
||||
}
|
||||
if (!tool.mappingId().isBlank()) {
|
||||
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_I"), "TELEGRAM", "INPUT", fields, diagnostics);
|
||||
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_O"), "TELEGRAM", "OUTPUT", fields, diagnostics);
|
||||
}
|
||||
return new ToolReportModel(tool, List.copyOf(fields), List.copyOf(diagnostics));
|
||||
}
|
||||
|
||||
private Map<String, Path> indexJavaFiles() {
|
||||
Map<String, Path> result = new LinkedHashMap<>();
|
||||
try (var paths = Files.walk(discovery.sourceRoot())) {
|
||||
paths.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".java"))
|
||||
.forEach(path -> result.putIfAbsent(path.getFileName().toString().replace(".java", ""), path));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to index Java sources", exception);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void collectSchema(ToolSummary tool, String resource, String direction,
|
||||
List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
if (resource == null || resource.isBlank()) return;
|
||||
String relative = resource.replaceFirst("^classpath:", "").replaceFirst("^/", "");
|
||||
List<Path> matches = new ArrayList<>();
|
||||
try (var paths = Files.walk(discovery.sourceRoot())) {
|
||||
paths.filter(path -> Files.isRegularFile(path))
|
||||
.filter(path -> path.toString().replace('\\', '/').endsWith("/src/main/resources/" + relative))
|
||||
.forEach(matches::add);
|
||||
} catch (IOException exception) {
|
||||
diagnostics.add("Schema 검색 실패: " + resource);
|
||||
return;
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
diagnostics.add("Schema를 찾을 수 없음: " + resource);
|
||||
return;
|
||||
}
|
||||
Path path = matches.get(0);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(path.toFile());
|
||||
JsonNode properties = root.path("properties");
|
||||
List<String> required = new ArrayList<>();
|
||||
root.path("required").forEach(node -> required.add(node.asText()));
|
||||
Iterator<Map.Entry<String, JsonNode>> iterator = properties.fields();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = iterator.next();
|
||||
JsonNode definition = entry.getValue();
|
||||
fields.add(new FieldDefinition(tool.name(), "JSON_SCHEMA", direction, "",
|
||||
entry.getKey(), definition.path("type").asText("object"),
|
||||
required.contains(entry.getKey()), definition.path("description").asText(""),
|
||||
constraints(definition), relative(path)));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
diagnostics.add("Schema 분석 실패: " + relative(path));
|
||||
}
|
||||
}
|
||||
|
||||
private void collectJavaType(ToolSummary tool, Path path, String sourceKind, String direction,
|
||||
List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
if (path == null) {
|
||||
diagnostics.add(sourceKind + " " + direction + " 타입을 찾을 수 없음");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CompilationUnit unit = StaticJavaParser.parse(path);
|
||||
for (TypeDeclaration<?> type : unit.getTypes()) {
|
||||
collectFields(tool, path, type, sourceKind, direction, fields);
|
||||
type.getMembers().stream().filter(TypeDeclaration.class::isInstance)
|
||||
.map(TypeDeclaration.class::cast)
|
||||
.forEach(nested -> collectFields(tool, path, nested, sourceKind, direction, fields));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
diagnostics.add(sourceKind + " 분석 실패: " + relative(path));
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFields(ToolSummary tool, Path path, TypeDeclaration<?> owner, String sourceKind,
|
||||
String direction, List<FieldDefinition> fields) {
|
||||
for (FieldDeclaration declaration : owner.getFields()) {
|
||||
AnnotationExpr param = JavaAnnotationReader.find(declaration.getAnnotations(), "McpToolParam").orElse(null);
|
||||
AnnotationExpr schema = JavaAnnotationReader.find(declaration.getAnnotations(), "Schema").orElse(null);
|
||||
AnnotationExpr telegram = JavaAnnotationReader.find(declaration.getAnnotations(), "GlowTrgmField").orElse(null);
|
||||
declaration.getVariables().forEach(variable -> fields.add(new FieldDefinition(
|
||||
tool.name(), sourceKind, direction, owner.getNameAsString(), variable.getNameAsString(),
|
||||
variable.getTypeAsString(),
|
||||
param == null ? null : JavaAnnotationReader.bool(param, "required", false),
|
||||
param == null ? "" : JavaAnnotationReader.string(param, "description", ""),
|
||||
annotationConstraints(schema, telegram), relative(path))));
|
||||
}
|
||||
}
|
||||
|
||||
private String annotationConstraints(AnnotationExpr schema, AnnotationExpr telegram) {
|
||||
List<String> values = new ArrayList<>();
|
||||
if (schema != null) values.add("Schema=" + schema);
|
||||
if (telegram != null) values.add("GlowTrgmField=" + telegram);
|
||||
return String.join("; ", values);
|
||||
}
|
||||
|
||||
private String constraints(JsonNode node) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (String key : List.of("format", "pattern", "minimum", "maximum", "minLength", "maxLength", "example")) {
|
||||
if (node.has(key)) values.add(key + "=" + node.get(key).asText());
|
||||
}
|
||||
if (node.has("enum")) values.add("enum=" + node.get("enum"));
|
||||
return String.join("; ", values);
|
||||
}
|
||||
|
||||
private String relative(Path path) {
|
||||
return discovery.sourceRoot().relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import io.shinhanlife.dap.report.config.ReportProperties;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 프로젝트의 @McpTool 선언을 읽기 전용으로 탐색한다. */
|
||||
@Component
|
||||
public class ToolSourceDiscovery {
|
||||
|
||||
private final Path sourceRoot;
|
||||
|
||||
public ToolSourceDiscovery(ReportProperties properties) {
|
||||
String configuredRoot = properties.sourceRoot();
|
||||
if (configuredRoot == null || configuredRoot.isBlank()) {
|
||||
throw new IllegalArgumentException("report.source-root must not be blank");
|
||||
}
|
||||
this.sourceRoot = resolveProjectRoot(Path.of(configuredRoot).toAbsolutePath().normalize());
|
||||
}
|
||||
|
||||
public Path sourceRoot() {
|
||||
return sourceRoot;
|
||||
}
|
||||
|
||||
public List<ToolSummary> discover() {
|
||||
if (!Files.isDirectory(sourceRoot)) {
|
||||
throw new IllegalStateException("Report source root does not exist: " + sourceRoot);
|
||||
}
|
||||
List<ToolSummary> tools = new ArrayList<>();
|
||||
try (var paths = Files.walk(sourceRoot)) {
|
||||
paths.filter(this::isUseCaseSource).forEach(path -> parse(path, tools));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to scan tool sources: " + sourceRoot, exception);
|
||||
}
|
||||
return tools.stream()
|
||||
.sorted(Comparator.comparing(ToolSummary::sourceFile))
|
||||
.collect(Collectors.toMap(ToolSummary::name, Function.identity(),
|
||||
(first, duplicate) -> first, LinkedHashMap::new))
|
||||
.values().stream()
|
||||
.sorted(Comparator.comparing(ToolSummary::name))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Path resolveProjectRoot(Path configuredPath) {
|
||||
Path candidate = configuredPath;
|
||||
while (candidate != null) {
|
||||
if (Files.isRegularFile(candidate.resolve("settings.gradle"))
|
||||
&& Files.isDirectory(candidate.resolve("dap-was-lib"))) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = candidate.getParent();
|
||||
}
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
private boolean isUseCaseSource(Path path) {
|
||||
String normalized = path.toString().replace('\\', '/');
|
||||
return Files.isRegularFile(path)
|
||||
&& path.getFileName().toString().endsWith("UseCase.java")
|
||||
&& normalized.contains("/src/main/java/")
|
||||
&& !normalized.contains("/dap-tool-report/");
|
||||
}
|
||||
|
||||
private void parse(Path path, List<ToolSummary> tools) {
|
||||
try {
|
||||
CompilationUnit unit = StaticJavaParser.parse(path);
|
||||
String owner = unit.getPrimaryTypeName().orElse(path.getFileName().toString().replace(".java", ""));
|
||||
for (MethodDeclaration method : unit.findAll(MethodDeclaration.class)) {
|
||||
JavaAnnotationReader.find(method.getAnnotations(), "McpTool")
|
||||
.ifPresent(annotation -> tools.add(toSummary(path, owner, method, annotation)));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to parse tool source: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private ToolSummary toSummary(Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
|
||||
AnnotationExpr hint = JavaAnnotationReader.find(method.getAnnotations(), "ToolHint").orElse(null);
|
||||
AnnotationExpr annotations = JavaAnnotationReader.nested(tool, "annotations").orElse(null);
|
||||
String name = JavaAnnotationReader.string(tool, "name", method.getNameAsString());
|
||||
String title = JavaAnnotationReader.string(tool, "title", name);
|
||||
String description = JavaAnnotationReader.string(tool, "description", "");
|
||||
String requestType = method.getParameters().isEmpty() ? "" : method.getParameter(0).getTypeAsString();
|
||||
return new ToolSummary(
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
hint == null ? "common" : JavaAnnotationReader.string(hint, "categoryKey", "com"),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "mappingId", ""),
|
||||
hint != null && JavaAnnotationReader.bool(hint, "register", false),
|
||||
hint != null && JavaAnnotationReader.bool(hint, "requiresApproval", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "readOnlyHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "destructiveHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "idempotentHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "openWorldHint", false),
|
||||
requestType,
|
||||
method.getTypeAsString(),
|
||||
owner,
|
||||
sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "inputSchemaResource", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "outputSchemaResource", ""));
|
||||
}
|
||||
}
|
||||
11
dap-tool-report/src/main/resources/application.yml
Normal file
11
dap-tool-report/src/main/resources/application.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
server:
|
||||
address: ${REPORT_SERVER_ADDRESS:127.0.0.1}
|
||||
port: ${REPORT_SERVER_PORT:8092}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: dap-tool-report
|
||||
|
||||
report:
|
||||
source-root: ${REPORT_SOURCE_ROOT:.}
|
||||
output-filename-prefix: tool-report
|
||||
@@ -0,0 +1,31 @@
|
||||
package io.shinhanlife.dap.report.excel;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.shinhanlife.dap.report.model.FieldDefinition;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.List;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolReportExcelWriterTest {
|
||||
|
||||
@Test
|
||||
void writesFixedWorkbookStructure() throws Exception {
|
||||
ToolSummary tool = new ToolSummary("oth.cst.customer.detail", "고객 상세", "설명", "cst", "ONCSC1340",
|
||||
false, false, true, false, false, true, "Request", "Response", "UseCase", "UseCase.java", "", "");
|
||||
FieldDefinition field = new FieldDefinition(tool.name(), "TELEGRAM", "INPUT", "ONCSC1340_I",
|
||||
"customerNo", "String", true, "고객번호", "length=12", "ONCSC1340_I.java");
|
||||
byte[] content = new ToolReportExcelWriter().write(List.of(new ToolReportModel(tool, List.of(field), List.of())));
|
||||
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(3);
|
||||
assertThat(workbook.getSheet("툴 기본정보").getRow(8).getCell(1).getStringCellValue()).isEqualTo(tool.name());
|
||||
assertThat(workbook.getSheet("수집 필드").getRow(8).getCell(5).getStringCellValue()).isEqualTo("customerNo");
|
||||
assertThat(workbook.getSheet("툴 기본정보").getRow(0).getCell(0).getCellStyle().getFillForegroundColorColor().getARGBHex())
|
||||
.endsWith("262626");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.shinhanlife.dap.report.config.ReportProperties;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolSourceDiscoveryTest {
|
||||
|
||||
@Test
|
||||
void discoversMcpToolsFromProjectSources() {
|
||||
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
|
||||
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
|
||||
new ReportProperties(projectRoot.toString(), "tool-report"));
|
||||
|
||||
assertThat(discovery.discover())
|
||||
.extracting(tool -> tool.name())
|
||||
.contains("oth.cmm.customer.detail", "sms.sms.msg.send");
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ include 'dap-gateway'
|
||||
include 'dap-was-lib'
|
||||
include 'dap-was-sms'
|
||||
include 'dap-was-oth'
|
||||
include 'dap-tool-report'
|
||||
|
||||
Reference in New Issue
Block a user