feat: tool 파트 보고서 생성 로직
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m29s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m29s
This commit is contained in:
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;
|
||||
|
||||
/** 툴 조회, 선택 검증, 분석 및 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 List<ToolSummary> listTools() {
|
||||
return discovery.discover();
|
||||
}
|
||||
|
||||
public byte[] createExcel(List<String> selectedNames) {
|
||||
if (selectedNames == null || selectedNames.isEmpty()) {
|
||||
throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다.");
|
||||
}
|
||||
Map<String, ToolSummary> available = new LinkedHashMap<>();
|
||||
listTools().forEach(tool -> available.put(tool.name(), tool));
|
||||
List<String> unknown = selectedNames.stream().filter(name -> !available.containsKey(name)).distinct().toList();
|
||||
if (!unknown.isEmpty()) {
|
||||
throw new IllegalArgumentException("존재하지 않는 툴이 포함되어 있습니다: " + unknown);
|
||||
}
|
||||
List<ToolReportModel> reports = selectedNames.stream().distinct()
|
||||
.map(available::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,62 @@
|
||||
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 io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
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.GetMapping;
|
||||
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;
|
||||
}
|
||||
|
||||
@GetMapping("/report-tools")
|
||||
public List<ToolSummary> tools() {
|
||||
return service.listTools();
|
||||
}
|
||||
|
||||
@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,20 @@
|
||||
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.cst.customer.detail", "sms.sms.msg.send");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user