From 206095a7a86b2d826ef2468fd93f677f446d471a Mon Sep 17 00:00:00 2001 From: juheelee Date: Thu, 13 Aug 2026 16:13:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20tool=20report=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ToolReportProxyController.java | 136 ++++++++++ .../src/main/resources/static/catalog.html | 1 + .../src/main/resources/static/chat.html | 1 + .../src/main/resources/static/index.html | 1 + .../src/main/resources/static/playground.html | 1 + .../src/main/resources/static/tester.html | 1 + .../main/resources/static/tool-report.html | 199 ++++++++++++++ dap-tool-report/Dockerfile | 14 + dap-tool-report/build.gradle | 16 ++ .../dap/report/ToolReportApplication.java | 25 ++ .../report/application/ToolReportService.java | 48 ++++ .../report/config/ReportConfiguration.java | 10 + .../dap/report/config/ReportProperties.java | 8 + .../report/excel/ToolReportExcelWriter.java | 255 ++++++++++++++++++ .../dap/report/model/FieldDefinition.java | 15 ++ .../dap/report/model/ToolReportModel.java | 7 + .../dap/report/model/ToolReportRequest.java | 7 + .../dap/report/model/ToolSummary.java | 22 ++ .../presentation/ToolReportController.java | 54 ++++ .../report/source/JavaAnnotationReader.java | 55 ++++ .../dap/report/source/ToolDetailAnalyzer.java | 156 +++++++++++ .../report/source/ToolSourceDiscovery.java | 116 ++++++++ .../src/main/resources/application.yml | 11 + .../excel/ToolReportExcelWriterTest.java | 31 +++ .../source/ToolSourceDiscoveryTest.java | 21 ++ settings.gradle | 1 + 26 files changed, 1212 insertions(+) create mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ToolReportProxyController.java create mode 100644 dap-gateway/src/main/resources/static/tool-report.html create mode 100644 dap-tool-report/Dockerfile create mode 100644 dap-tool-report/build.gradle create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/ToolReportApplication.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/application/ToolReportService.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportConfiguration.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportProperties.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriter.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/FieldDefinition.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportModel.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportRequest.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolSummary.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/presentation/ToolReportController.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/JavaAnnotationReader.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolDetailAnalyzer.java create mode 100644 dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolSourceDiscovery.java create mode 100644 dap-tool-report/src/main/resources/application.yml create mode 100644 dap-tool-report/src/test/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriterTest.java create mode 100644 dap-tool-report/src/test/java/io/shinhanlife/dap/report/source/ToolSourceDiscoveryTest.java diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ToolReportProxyController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ToolReportProxyController.java new file mode 100644 index 00000000..f60c1707 --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ToolReportProxyController.java @@ -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 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 excel(@RequestBody Map request) { + validateMcpSelection(request); + ResponseEntity 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 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 registeredNames = activeTools().stream() + .map(ToolMetadata::getName) + .collect(java.util.stream.Collectors.toSet()); + Set 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 activeTools() { + ResponseEntity 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 copy(ResponseEntity 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; + } + } +} diff --git a/dap-gateway/src/main/resources/static/catalog.html b/dap-gateway/src/main/resources/static/catalog.html index b13da770..a8a6de37 100644 --- a/dap-gateway/src/main/resources/static/catalog.html +++ b/dap-gateway/src/main/resources/static/catalog.html @@ -72,6 +72,7 @@ Chat Tester Console + Report
diff --git a/dap-gateway/src/main/resources/static/chat.html b/dap-gateway/src/main/resources/static/chat.html index f082500c..74ed6359 100644 --- a/dap-gateway/src/main/resources/static/chat.html +++ b/dap-gateway/src/main/resources/static/chat.html @@ -59,6 +59,7 @@ Chat Tester Console + Report
diff --git a/dap-gateway/src/main/resources/static/index.html b/dap-gateway/src/main/resources/static/index.html index 60caadc8..b1e9e0e4 100644 --- a/dap-gateway/src/main/resources/static/index.html +++ b/dap-gateway/src/main/resources/static/index.html @@ -103,6 +103,7 @@ Chat Tester Console + Report
diff --git a/dap-gateway/src/main/resources/static/playground.html b/dap-gateway/src/main/resources/static/playground.html index 4c2a7efe..95b97f95 100644 --- a/dap-gateway/src/main/resources/static/playground.html +++ b/dap-gateway/src/main/resources/static/playground.html @@ -114,6 +114,7 @@ Chat Tester Console + Report
diff --git a/dap-gateway/src/main/resources/static/tester.html b/dap-gateway/src/main/resources/static/tester.html index 7565dcca..db00f667 100644 --- a/dap-gateway/src/main/resources/static/tester.html +++ b/dap-gateway/src/main/resources/static/tester.html @@ -77,6 +77,7 @@ Chat Tester Console + Report
diff --git a/dap-gateway/src/main/resources/static/tool-report.html b/dap-gateway/src/main/resources/static/tool-report.html new file mode 100644 index 00000000..9235858a --- /dev/null +++ b/dap-gateway/src/main/resources/static/tool-report.html @@ -0,0 +1,199 @@ + + + + + + AXHUB Tool Report + + + +
+ +
+ +
+
+
+

Tool Report

+

보고서에 포함할 MCP 툴을 선택하고, 소스 어노테이션과 전문 필드를 동일한 Excel 양식으로 생성합니다.

+
+ Loading tools... +
+ +
+
+ + + + +
+
+ + + +
선택툴명제목카테고리연계 ID설명
툴 목록을 불러오는 중입니다.
+
+ +
+
+ + + + diff --git a/dap-tool-report/Dockerfile b/dap-tool-report/Dockerfile new file mode 100644 index 00000000..d1069da9 --- /dev/null +++ b/dap-tool-report/Dockerfile @@ -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"] diff --git a/dap-tool-report/build.gradle b/dap-tool-report/build.gradle new file mode 100644 index 00000000..679e803e --- /dev/null +++ b/dap-tool-report/build.gradle @@ -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() +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/ToolReportApplication.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/ToolReportApplication.java new file mode 100644 index 00000000..80b797a8 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/ToolReportApplication.java @@ -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 + *
+ * ---------- 개정이력 ----------
+ * 수정일       수정자     수정내용
+ * ---------- -------- ---------------------------
+ * 2026.08.07  0986406    최초생성
+ * 
+ */ +@SpringBootApplication +public class ToolReportApplication { + + public static void main(String[] args) { + SpringApplication.run(ToolReportApplication.class, args); + } +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/application/ToolReportService.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/application/ToolReportService.java new file mode 100644 index 00000000..41a530bf --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/application/ToolReportService.java @@ -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 selectedNames) { + if (selectedNames == null || selectedNames.isEmpty()) { + throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다."); + } + + Map sourceTools = new LinkedHashMap<>(); + discovery.discover().forEach(tool -> sourceTools.put(tool.name(), tool)); + List missingSources = selectedNames.stream() + .filter(name -> !sourceTools.containsKey(name)).distinct().toList(); + if (!missingSources.isEmpty()) { + throw new IllegalArgumentException("분석할 원천 소스가 없는 툴입니다: " + missingSources); + } + + List reports = selectedNames.stream().distinct() + .map(sourceTools::get) + .map(analyzer::analyze) + .toList(); + return excelWriter.write(reports); + } +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportConfiguration.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportConfiguration.java new file mode 100644 index 00000000..4ec2986f --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportConfiguration.java @@ -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 { +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportProperties.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportProperties.java new file mode 100644 index 00000000..5641e204 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/config/ReportProperties.java @@ -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) { +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriter.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriter.java new file mode 100644 index 00000000..5a2356c9 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriter.java @@ -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 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 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 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 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 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()); + } + } +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/FieldDefinition.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/FieldDefinition.java new file mode 100644 index 00000000..be17e51e --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/FieldDefinition.java @@ -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) { +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportModel.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportModel.java new file mode 100644 index 00000000..e5fb6caa --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportModel.java @@ -0,0 +1,7 @@ +package io.shinhanlife.dap.report.model; + +import java.util.List; + +/** 수집 단계와 Excel 출력 단계를 분리하는 표준 중간 모델. */ +public record ToolReportModel(ToolSummary tool, List fields, List diagnostics) { +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportRequest.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportRequest.java new file mode 100644 index 00000000..1e073971 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolReportRequest.java @@ -0,0 +1,7 @@ +package io.shinhanlife.dap.report.model; + +import java.util.List; + +/** 사용자가 선택한 툴 보고서 생성 요청. */ +public record ToolReportRequest(List toolNames) { +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolSummary.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolSummary.java new file mode 100644 index 00000000..9d7a37ea --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/model/ToolSummary.java @@ -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) { +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/presentation/ToolReportController.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/presentation/ToolReportController.java new file mode 100644 index 00000000..2a338f84 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/presentation/ToolReportController.java @@ -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 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> badRequest(IllegalArgumentException exception) { + return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage())); + } +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/JavaAnnotationReader.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/JavaAnnotationReader.java new file mode 100644 index 00000000..53a506a3 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/JavaAnnotationReader.java @@ -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 find(NodeList annotations, String simpleName) { + return annotations.stream().filter(a -> a.getName().getIdentifier().equals(simpleName)).findFirst(); + } + + static Optional 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 nested(AnnotationExpr annotation, String key) { + return value(annotation, key).filter(AnnotationExpr.class::isInstance).map(AnnotationExpr.class::cast); + } +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolDetailAnalyzer.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolDetailAnalyzer.java new file mode 100644 index 00000000..272172a0 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolDetailAnalyzer.java @@ -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 fields = new ArrayList<>(); + List diagnostics = new ArrayList<>(); + Map 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 indexJavaFiles() { + Map 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 fields, List diagnostics) { + if (resource == null || resource.isBlank()) return; + String relative = resource.replaceFirst("^classpath:", "").replaceFirst("^/", ""); + List 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 required = new ArrayList<>(); + root.path("required").forEach(node -> required.add(node.asText())); + Iterator> iterator = properties.fields(); + while (iterator.hasNext()) { + Map.Entry 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 fields, List 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 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 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 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('\\', '/'); + } +} diff --git a/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolSourceDiscovery.java b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolSourceDiscovery.java new file mode 100644 index 00000000..c5dbbcc9 --- /dev/null +++ b/dap-tool-report/src/main/java/io/shinhanlife/dap/report/source/ToolSourceDiscovery.java @@ -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 discover() { + if (!Files.isDirectory(sourceRoot)) { + throw new IllegalStateException("Report source root does not exist: " + sourceRoot); + } + List 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 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", "")); + } +} diff --git a/dap-tool-report/src/main/resources/application.yml b/dap-tool-report/src/main/resources/application.yml new file mode 100644 index 00000000..62642200 --- /dev/null +++ b/dap-tool-report/src/main/resources/application.yml @@ -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 diff --git a/dap-tool-report/src/test/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriterTest.java b/dap-tool-report/src/test/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriterTest.java new file mode 100644 index 00000000..6bc98293 --- /dev/null +++ b/dap-tool-report/src/test/java/io/shinhanlife/dap/report/excel/ToolReportExcelWriterTest.java @@ -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"); + } + } +} diff --git a/dap-tool-report/src/test/java/io/shinhanlife/dap/report/source/ToolSourceDiscoveryTest.java b/dap-tool-report/src/test/java/io/shinhanlife/dap/report/source/ToolSourceDiscoveryTest.java new file mode 100644 index 00000000..daa2adaa --- /dev/null +++ b/dap-tool-report/src/test/java/io/shinhanlife/dap/report/source/ToolSourceDiscoveryTest.java @@ -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"); + } +} diff --git a/settings.gradle b/settings.gradle index d70ddd2d..e68aab4d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,3 +4,4 @@ include 'dap-gateway' include 'dap-was-lib' include 'dap-was-sms' include 'dap-was-oth' +include 'dap-tool-report'