feat: tool report 카테고리 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m29s

This commit is contained in:
juheelee
2026-08-13 16:13:10 +09:00
parent df03037d3f
commit 206095a7a8
26 changed files with 1212 additions and 0 deletions

View 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"]

View 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()
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {
}

View File

@@ -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) {
}

View File

@@ -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());
}
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -0,0 +1,7 @@
package io.shinhanlife.dap.report.model;
import java.util.List;
/** 사용자가 선택한 툴 보고서 생성 요청. */
public record ToolReportRequest(List<String> toolNames) {
}

View File

@@ -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) {
}

View File

@@ -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()));
}
}

View File

@@ -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);
}
}

View File

@@ -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('\\', '/');
}
}

View File

@@ -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", ""));
}
}

View 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

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}