This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
package com.aicheck.parser;
|
||||
|
||||
import com.aicheck.model.ApiEndpoint;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.NodeList;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.body.Parameter;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.github.javaparser.ast.type.Type;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 扫描 Controller / Feign 接口,提取 HTTP 方法、URI、入参/返回类型。
|
||||
*/
|
||||
public class EndpointParser {
|
||||
private static final Set<String> MAPPING_ANNOTATIONS = Set.of(
|
||||
"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
|
||||
);
|
||||
private static final Map<String, String> MAPPING_DEFAULT_METHOD = Map.of(
|
||||
"GetMapping", "GET",
|
||||
"PostMapping", "POST",
|
||||
"PutMapping", "PUT",
|
||||
"DeleteMapping", "DELETE",
|
||||
"PatchMapping", "PATCH"
|
||||
);
|
||||
|
||||
/** 扫描 @RestController / @Controller 目录 */
|
||||
public List<ApiEndpoint> scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException {
|
||||
return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER);
|
||||
}
|
||||
|
||||
/** 扫描 @FeignClient 接口目录 */
|
||||
public List<ApiEndpoint> scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException {
|
||||
return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN);
|
||||
}
|
||||
|
||||
/** 递归 walk 目录下 .java 并解析 */
|
||||
private List<ApiEndpoint> scanDirectory(Path rootDir, String relativePrefix, ScanMode mode) throws IOException {
|
||||
if (!Files.exists(rootDir)) {
|
||||
return List.of();
|
||||
}
|
||||
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||
paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
|
||||
try {
|
||||
String source = Files.readString(path, StandardCharsets.UTF_8);
|
||||
String relativePath = toRelativePath(relativePrefix, rootDir, path);
|
||||
endpoints.addAll(parseCompilationUnit(source, relativePath, mode));
|
||||
} catch (IOException ignored) {
|
||||
// 跳过无法读取的文件
|
||||
}
|
||||
});
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** 解析单个编译单元,过滤 Controller 或 Feign */
|
||||
private List<ApiEndpoint> parseCompilationUnit(String source, String relativePath, ScanMode mode) {
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (!(type instanceof ClassOrInterfaceDeclaration)) {
|
||||
continue;
|
||||
}
|
||||
ClassOrInterfaceDeclaration declaration = (ClassOrInterfaceDeclaration) type;
|
||||
if (mode == ScanMode.CONTROLLER && !isController(declaration)) {
|
||||
continue;
|
||||
}
|
||||
if (mode == ScanMode.FEIGN && !isFeignClient(declaration)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String basePath = mode == ScanMode.FEIGN
|
||||
? joinPaths(extractFeignBasePath(declaration), extractTypeLevelPath(declaration))
|
||||
: extractTypeLevelPath(declaration);
|
||||
for (MethodDeclaration method : declaration.getMethods()) {
|
||||
if (mode == ScanMode.FEIGN && declaration.isInterface()) {
|
||||
endpoints.addAll(parseMethod(method, basePath, relativePath));
|
||||
} else if (mode == ScanMode.CONTROLLER && !declaration.isInterface()) {
|
||||
endpoints.addAll(parseMethod(method, basePath, relativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** 解析方法上的 Mapping 注解,生成 ApiEndpoint */
|
||||
private List<ApiEndpoint> parseMethod(MethodDeclaration method, String basePath, String sourceFile) {
|
||||
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||
for (AnnotationExpr annotation : method.getAnnotations()) {
|
||||
String annName = annotation.getNameAsString();
|
||||
if (!MAPPING_ANNOTATIONS.contains(annName)) {
|
||||
continue;
|
||||
}
|
||||
List<String> subPaths = extractPaths(annotation);
|
||||
List<String> httpMethods = extractHttpMethods(annotation, annName);
|
||||
for (String httpMethod : httpMethods) {
|
||||
for (String subPath : subPaths) {
|
||||
String uri = joinPaths(basePath, subPath);
|
||||
Set<String> paramTypes = extractParamTypes(method);
|
||||
Set<String> returnTypes = TypeNameUtils.peelDirectTypeNames(method.getType());
|
||||
endpoints.add(new ApiEndpoint(httpMethod, uri, sourceFile, paramTypes, returnTypes));
|
||||
}
|
||||
}
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** 收集方法入参类型简单名 */
|
||||
private Set<String> extractParamTypes(MethodDeclaration method) {
|
||||
Set<String> paramTypes = new LinkedHashSet<>();
|
||||
for (Parameter parameter : method.getParameters()) {
|
||||
Type type = parameter.getType();
|
||||
paramTypes.add(TypeNameUtils.simpleName(TypeNameUtils.typeToString(type)));
|
||||
paramTypes.addAll(TypeNameUtils.peelDirectTypeNames(type));
|
||||
}
|
||||
return paramTypes;
|
||||
}
|
||||
|
||||
/** 是否 Spring Controller */
|
||||
private boolean isController(ClassOrInterfaceDeclaration declaration) {
|
||||
return declaration.getAnnotations().stream()
|
||||
.anyMatch(ann -> {
|
||||
String name = ann.getNameAsString();
|
||||
return "RestController".equals(name) || "Controller".equals(name);
|
||||
});
|
||||
}
|
||||
|
||||
/** 是否 Feign 客户端接口 */
|
||||
private boolean isFeignClient(ClassOrInterfaceDeclaration declaration) {
|
||||
return declaration.isInterface() && declaration.getAnnotations().stream()
|
||||
.anyMatch(ann -> "FeignClient".equals(ann.getNameAsString()));
|
||||
}
|
||||
|
||||
/** 类级 @RequestMapping 路径 */
|
||||
private String extractTypeLevelPath(ClassOrInterfaceDeclaration declaration) {
|
||||
for (AnnotationExpr annotation : declaration.getAnnotations()) {
|
||||
if ("RequestMapping".equals(annotation.getNameAsString())) {
|
||||
List<String> paths = extractPaths(annotation);
|
||||
if (!paths.isEmpty()) {
|
||||
return paths.get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @FeignClient(path=...) 基础路径 */
|
||||
private String extractFeignBasePath(ClassOrInterfaceDeclaration declaration) {
|
||||
for (AnnotationExpr annotation : declaration.getAnnotations()) {
|
||||
if ("FeignClient".equals(annotation.getNameAsString())) {
|
||||
List<String> paths = AnnotationValueReader.readStringArray(annotation, "path");
|
||||
if (!paths.isEmpty()) {
|
||||
return paths.get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 从 Mapping 注解读取 value/path */
|
||||
private List<String> extractPaths(AnnotationExpr annotation) {
|
||||
return AnnotationValueReader.readStringArray(annotation, "value", "path");
|
||||
}
|
||||
|
||||
/** 推断 HTTP 方法;RequestMapping 无 method 时默认 GET */
|
||||
private List<String> extractHttpMethods(AnnotationExpr annotation, String annName) {
|
||||
if (!"RequestMapping".equals(annName)) {
|
||||
return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
|
||||
}
|
||||
List<String> methods = AnnotationValueReader.readEnumArray(annotation, "method");
|
||||
if (methods.isEmpty()) {
|
||||
return List.of("GET");
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
/** 拼接类级与方法级路径 */
|
||||
private String joinPaths(String base, String sub) {
|
||||
String normalizedBase = normalizePath(base);
|
||||
String normalizedSub = normalizePath(sub);
|
||||
if (normalizedBase.isEmpty()) {
|
||||
return normalizedSub.isEmpty() ? "/" : normalizedSub;
|
||||
}
|
||||
if (normalizedSub.isEmpty()) {
|
||||
return normalizedBase;
|
||||
}
|
||||
String joined = normalizedBase + "/" + normalizedSub.substring(1);
|
||||
return joined.replaceAll("/+", "/");
|
||||
}
|
||||
|
||||
/** 规范化 URI 路径 */
|
||||
private String normalizePath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = path.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
trimmed = "/" + trimmed;
|
||||
}
|
||||
return trimmed.replaceAll("/+", "/");
|
||||
}
|
||||
|
||||
/** 生成相对仓库根的路径 */
|
||||
private String toRelativePath(String relativePrefix, Path rootDir, Path file) {
|
||||
String relative = rootDir.relativize(file).toString().replace("\\", "/");
|
||||
if (relativePrefix == null || relativePrefix.isBlank()) {
|
||||
return relative;
|
||||
}
|
||||
String prefix = relativePrefix.endsWith("/")
|
||||
? relativePrefix.substring(0, relativePrefix.length() - 1)
|
||||
: relativePrefix;
|
||||
return prefix + "/" + relative;
|
||||
}
|
||||
|
||||
private enum ScanMode {
|
||||
CONTROLLER, FEIGN
|
||||
}
|
||||
|
||||
/** 从注解 AST 读取字符串或枚举数组 */
|
||||
static final class AnnotationValueReader {
|
||||
private AnnotationValueReader() {
|
||||
}
|
||||
|
||||
static List<String> readStringArray(AnnotationExpr annotation, String... keys) {
|
||||
NodeList<?> values = readArrayValues(annotation, keys);
|
||||
List<String> result = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
String text = value.toString().replace("\"", "").trim();
|
||||
if (!text.isBlank()) {
|
||||
result.add(text);
|
||||
}
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
result.add("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<String> readEnumArray(AnnotationExpr annotation, String key) {
|
||||
NodeList<?> values = readArrayValues(annotation, key);
|
||||
List<String> result = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
String text = value.toString().trim();
|
||||
if (text.contains(".")) {
|
||||
text = text.substring(text.lastIndexOf('.') + 1);
|
||||
}
|
||||
result.add(text.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static NodeList<?> readArrayValues(AnnotationExpr annotation, String... keys) {
|
||||
if (annotation.isSingleMemberAnnotationExpr()) {
|
||||
Expression value = annotation.asSingleMemberAnnotationExpr().getMemberValue();
|
||||
if (value.isArrayInitializerExpr()) {
|
||||
return value.asArrayInitializerExpr().getValues();
|
||||
}
|
||||
return new NodeList<>(value);
|
||||
}
|
||||
if (annotation.isNormalAnnotationExpr()) {
|
||||
var pairs = annotation.asNormalAnnotationExpr().getPairs();
|
||||
for (var pair : pairs) {
|
||||
for (String key : keys) {
|
||||
if (pair.getNameAsString().equals(key)) {
|
||||
if (pair.getValue().isArrayInitializerExpr()) {
|
||||
return pair.getValue().asArrayInitializerExpr().getValues();
|
||||
}
|
||||
return new NodeList<>(pair.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var pair : pairs) {
|
||||
if ("value".equals(pair.getNameAsString())) {
|
||||
if (pair.getValue().isArrayInitializerExpr()) {
|
||||
return pair.getValue().asArrayInitializerExpr().getValues();
|
||||
}
|
||||
return new NodeList<>(pair.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new NodeList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user