From cc3f76eebc8e2fccdc1007e56b5a76d8225d523d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 3 Aug 2025 21:35:55 -0300 Subject: [PATCH 1/9] WIP: openapi: javadoc for script/lambda routes - WIP --- .../internal/openapi/javadoc/ClassDoc.java | 9 +++++ .../openapi/javadoc/JavaDocParser.java | 36 ++++++++++++++++-- .../test/java/javadoc/JavaDocParserTest.java | 37 ++++++++++++++++++ .../src/test/java/javadoc/PrintAstTree.java | 4 +- .../test/java/javadoc/input/ScriptApp.java | 38 +++++++++++++++++++ 5 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java index e3fbecfa7a..ea7e9d6a22 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -24,6 +24,7 @@ public class ClassDoc extends JavaDocNode { private final Map fields = new LinkedHashMap<>(); private final Map methods = new LinkedHashMap<>(); + private final Map scripts = new LinkedHashMap<>(); private final List servers; private final List contact; private final List license; @@ -160,6 +161,10 @@ public void addMethod(MethodDoc method) { this.methods.put(toMethodSignature(method), method); } + public void addScript(String pattern, MethodDoc method) { + this.scripts.put(pattern, method); + } + public void addField(FieldDoc field) { this.fields.put(field.getName(), field); } @@ -172,6 +177,10 @@ public Optional getMethod(String name, List parameterNames) { return Optional.ofNullable(methods.get(toMethodSignature(name, parameterNames))); } + public Optional getScript(String pattern) { + return Optional.ofNullable(scripts.get(pattern)); + } + private String toMethodSignature(MethodDoc method) { return toMethodSignature(method.getName(), method.getParameterNames()); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index 1ef99d687e..810aca582c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -22,6 +22,8 @@ import com.puppycrawl.tools.checkstyle.JavaParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import com.puppycrawl.tools.checkstyle.utils.XpathUtil; +import io.jooby.Router; public class JavaDocParser { @@ -57,6 +59,7 @@ public Map traverse(DetailAST tree) { counter.addAndGet(comment == JavaDocNode.EMPTY_AST ? 0 : 1); var classDoc = new ClassDoc(this, scope, comment); + // MVC routes traverse( scope, tokens(TokenTypes.VARIABLE_DEF, TokenTypes.METHOD_DEF), @@ -72,10 +75,37 @@ public Map traverse(DetailAST tree) { } } }); - - if (classDoc.isRecord()) { - // complement with record parameter + // Script routes + for (var script : + tree(scope) + .filter(tokens(TokenTypes.METHOD_CALL)) + // Test for HTTP method name + .filter( + it -> + tree(it) + .filter(tokens(TokenTypes.IDENT)) + .anyMatch(e -> Router.METHODS.contains(e.getText().toUpperCase()))) + .toList()) { + var scriptComment = + children(script) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(null); + if (scriptComment != null) { + // ELIST -> EXPR -> STRING_LITERAL + children(script) + .filter(tokens(TokenTypes.ELIST)) + .findFirst() + .flatMap(it -> children(it).filter(tokens(TokenTypes.EXPR)).findFirst()) + .flatMap(it -> children(it).filter(tokens(TokenTypes.STRING_LITERAL)).findFirst()) + .map(XpathUtil::getTextAttributeValue) + .ifPresent( + pattern -> { + classDoc.addScript(pattern, new MethodDoc(this, script, scriptComment)); + }); + } } + if (counter.get() > 0) { classes.put(classDoc.getName(), classDoc); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 1d92be4ab3..4c1316befd 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -24,6 +24,37 @@ public class JavaDocParserTest { + @Test + public void scriptDoc() throws Exception { + withDoc( + javadoc.input.ScriptApp.class, + doc -> { + assertEquals("ScriptApp", doc.getSimpleName()); + assertEquals("javadoc.input.ScriptApp", doc.getName()); + assertEquals("Script App.", doc.getSummary()); + assertEquals("Some description.", doc.getDescription()); + + withScript( + doc, + "/static", + method -> { + assertEquals("This is a static path.", method.getSummary()); + assertEquals("No parameters", method.getDescription()); + assertEquals("Request Path.", method.getReturnDoc()); + }); + + withScript( + doc, + "/path/{id}", + method -> { + assertEquals("Path param.", method.getSummary()); + assertNull(method.getDescription()); + assertEquals("Some value.", method.getReturnDoc()); + assertEquals("Path ID.", method.getParameterDoc("id")); + }); + }); + } + @Test public void apiDoc() throws Exception { withDoc( @@ -258,6 +289,12 @@ private void withMethod( consumer.accept(method.get()); } + private void withScript(ClassDoc doc, String pattern, Consumer consumer) { + var method = doc.getScript(pattern); + assertTrue(method.isPresent()); + consumer.accept(method.get()); + } + private void withField(ClassDoc doc, String name, Consumer consumer) { var method = doc.getField(name); assertTrue(method.isPresent()); diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java index 4cb088c810..85507e93dd 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -9,7 +9,7 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import javadoc.input.ApiDoc; +import javadoc.input.ScriptApp; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; @@ -25,7 +25,7 @@ public static void main(String[] args) throws CheckstyleException, IOException { .resolve("java"); var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree( - baseDir.resolve(toPath(ApiDoc.class)).toFile()); + baseDir.resolve(toPath(ScriptApp.class)).toFile()); System.out.println(stringAst); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java b/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java new file mode 100644 index 0000000000..0096f94664 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java @@ -0,0 +1,38 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +import io.jooby.Context; +import io.jooby.Jooby; + +/** Script App. Some description. */ +public class ScriptApp extends Jooby { + { + // Ignored + /* + * This is a static path. No parameters + * + * @return Request Path. + */ + get( + "/static", + ctx -> { + // Ignored. + return ctx.getRequestPath(); + // Ignored + }); + + // Ignored + /* + * Path param. + * + * @param id Path ID. + * @return Some value. + */ + // Ignored + get("/path/{id}", Context::getRequestPath); + } +} From 2f710ec0b66449dccf5650d1e0a8116ee712820c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 4 Aug 2025 20:47:11 -0300 Subject: [PATCH 2/9] open-api - script/lambda java doc - support nested path - mimic MVC/controller API - add @operationId parsing --- .../internal/openapi/AnnotationParser.java | 49 +------- .../jooby/internal/openapi/JavaDocSetter.java | 85 ++++++++++++++ .../internal/openapi/RequestBodyExt.java | 4 + .../jooby/internal/openapi/RouteParser.java | 16 +++ .../internal/openapi/javadoc/ClassDoc.java | 18 ++- .../openapi/javadoc/JavaDocParser.java | 100 +++++++++++----- .../internal/openapi/javadoc/JavaDocTag.java | 13 ++ .../internal/openapi/javadoc/MethodDoc.java | 18 +++ .../internal/openapi/javadoc/PathDoc.java | 14 +++ .../internal/openapi/javadoc/ScriptDoc.java | 42 +++++++ .../io/jooby/openapi/OpenAPIYamlTest.java | 14 +++ .../src/test/java/issues/Issue1592.java | 1 + .../java/issues/i3729/api/ApiDocTest.java | 11 +- .../java/issues/i3729/api/ScriptLibrary.java | 111 ++++++++++++++++++ .../test/java/javadoc/JavaDocParserTest.java | 87 ++++++++++++-- .../test/java/javadoc/input/ScriptApp.java | 65 +++++++++- 16 files changed, 556 insertions(+), 92 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java index 40473debed..f2eab7a15f 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java @@ -277,12 +277,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty .parse(className) .ifPresent( doc -> { - operationExt.setPathDescription(doc.getDescription()); - operationExt.setPathSummary(doc.getSummary()); - doc.getTags().forEach(operationExt::addTag); - if (!doc.getExtensions().isEmpty()) { - operationExt.setPathExtensions(doc.getExtensions()); - } + JavaDocSetter.setPath(operationExt, doc); var parameterNames = Optional.ofNullable(operationExt.getNode().parameters) .orElse(List.of()) @@ -291,47 +286,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty .toList(); doc.getMethod(operationExt.getOperationId(), parameterNames) .ifPresent( - methodDoc -> { - operationExt.setSummary(methodDoc.getSummary()); - operationExt.setDescription(methodDoc.getDescription()); - if (!methodDoc.getExtensions().isEmpty()) { - operationExt.setExtensions(methodDoc.getExtensions()); - } - methodDoc.getTags().forEach(operationExt::addTag); - // Parameters - for (var parameterName : parameterNames) { - var paramExt = - operationExt.getParameters().stream() - .filter(p -> p.getName().equals(parameterName)) - .findFirst() - .map(ParameterExt.class::cast) - .orElse(null); - var paramDoc = methodDoc.getParameterDoc(parameterName); - if (paramDoc != null) { - if (paramExt == null) { - operationExt.getRequestBody().setDescription(paramDoc); - } else { - paramExt.setDescription(paramDoc); - } - } - } - // return types - var defaultResponse = operationExt.getDefaultResponse(); - if (defaultResponse != null) { - defaultResponse.setDescription(methodDoc.getReturnDoc()); - } - for (var throwsDoc : methodDoc.getThrows().values()) { - var response = - operationExt.getResponse( - Integer.toString(throwsDoc.getStatusCode().value())); - if (response == null) { - response = - operationExt.addResponse( - Integer.toString(throwsDoc.getStatusCode().value())); - } - response.setDescription(throwsDoc.getText()); - } - }); + methodDoc -> JavaDocSetter.set(operationExt, methodDoc, parameterNames)); }); } catch (Exception x) { throw SneakyThrows.propagate(x); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java new file mode 100644 index 0000000000..c37b22d37f --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java @@ -0,0 +1,85 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import io.jooby.internal.openapi.javadoc.JavaDocNode; +import io.jooby.internal.openapi.javadoc.MethodDoc; +import io.jooby.internal.openapi.javadoc.ScriptDoc; +import io.swagger.v3.oas.models.parameters.Parameter; + +public class JavaDocSetter { + + public static void setPath(OperationExt operation, JavaDocNode doc) { + operation.setPathDescription(doc.getDescription()); + operation.setPathSummary(doc.getSummary()); + doc.getTags().forEach(operation::addTag); + if (!doc.getExtensions().isEmpty()) { + operation.setPathExtensions(doc.getExtensions()); + } + } + + public static void set(OperationExt operation, ScriptDoc doc) { + var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); + var parameterNames = parameters.stream().map(Parameter::getName).collect(Collectors.toList()); + if (operation.getRequestBody() != null) { + var javaDocNames = new LinkedHashSet<>(doc.getJavadocParameterNames()); + javaDocNames.removeAll(parameterNames); + if (javaDocNames.size() == 1) { + // just add body name on lambda/script routes. + parameterNames.addAll(javaDocNames); + } + } + set(operation, doc, parameterNames); + } + + public static void set(OperationExt operation, MethodDoc doc, List parameterNames) { + operation.setOperationId( + Optional.ofNullable(operation.getOperationId()).orElse(doc.getOperationId())); + operation.setSummary(doc.getSummary()); + operation.setDescription(doc.getDescription()); + if (!doc.getExtensions().isEmpty()) { + operation.setExtensions(doc.getExtensions()); + } + doc.getTags().forEach(operation::addTag); + // Parameters + for (var parameterName : parameterNames) { + var paramExt = + operation.getParameters().stream() + .filter(p -> p.getName().equals(parameterName)) + .findFirst() + .map(ParameterExt.class::cast) + .orElse(null); + var paramDoc = doc.getParameterDoc(parameterName); + if (paramDoc != null) { + if (paramExt == null) { + var body = operation.getRequestBody(); + if (body != null) { + body.setDescription(paramDoc); + } + } else { + paramExt.setDescription(paramDoc); + } + } + } + // return types + var defaultResponse = operation.getDefaultResponse(); + if (defaultResponse != null) { + defaultResponse.setDescription(doc.getReturnDoc()); + } + for (var throwsDoc : doc.getThrows().values()) { + var response = operation.getResponse(Integer.toString(throwsDoc.getStatusCode().value())); + if (response == null) { + response = operation.addResponse(Integer.toString(throwsDoc.getStatusCode().value())); + } + response.setDescription(throwsDoc.getText()); + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RequestBodyExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RequestBodyExt.java index 7171650660..4bc36f2b3d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RequestBodyExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RequestBodyExt.java @@ -15,6 +15,10 @@ public class RequestBodyExt extends RequestBody { @JsonIgnore private String contentType = MediaType.JSON; + { + setRequired(true); + } + public String getJavaType() { return javaType; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java index 9883c680c4..f1a2e2c5cf 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java @@ -65,6 +65,22 @@ public List parse(ParserContext ctx, OpenAPIExt openapi) { Optional.ofNullable(ctx.getMainClass()).orElse(ctx.getRouter().getClassName()); ClassNode application = ctx.classNode(Type.getObjectType(applicationName.replace(".", "/"))); + // javadoc + var javaDoc = ctx.javadoc().parse(ctx.getRouter().getClassName()); + for (OperationExt operation : operations) { + // Script/lambda + if (operation.getController() == null) { + javaDoc + .flatMap(doc -> doc.getScript(operation.getMethod(), operation.getPattern())) + .ifPresent( + scriptDoc -> { + if (scriptDoc.getPath() != null) { + JavaDocSetter.setPath(operation, scriptDoc.getPath()); + } + JavaDocSetter.set(operation, scriptDoc); + }); + } + } // swagger/openapi: for (OperationExt operation : operations) { operation.setApplication(application); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java index ea7e9d6a22..392b4d1ba9 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -24,7 +24,7 @@ public class ClassDoc extends JavaDocNode { private final Map fields = new LinkedHashMap<>(); private final Map methods = new LinkedHashMap<>(); - private final Map scripts = new LinkedHashMap<>(); + private final Map scripts = new LinkedHashMap<>(); private final List servers; private final List contact; private final List license; @@ -161,8 +161,8 @@ public void addMethod(MethodDoc method) { this.methods.put(toMethodSignature(method), method); } - public void addScript(String pattern, MethodDoc method) { - this.scripts.put(pattern, method); + public void addScript(ScriptDoc method) { + this.scripts.put(toScriptSignature(method), method); } public void addField(FieldDoc field) { @@ -177,8 +177,16 @@ public Optional getMethod(String name, List parameterNames) { return Optional.ofNullable(methods.get(toMethodSignature(name, parameterNames))); } - public Optional getScript(String pattern) { - return Optional.ofNullable(scripts.get(pattern)); + public Optional getScript(String method, String pattern) { + return Optional.ofNullable(scripts.get(toScriptSignature(method, pattern))); + } + + private String toScriptSignature(ScriptDoc method) { + return toScriptSignature(method.getMethod(), method.getPattern()); + } + + private String toScriptSignature(String method, String pattern) { + return method + "/" + pattern; } private String toMethodSignature(MethodDoc method) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index 810aca582c..dd27593de1 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -76,35 +76,7 @@ public Map traverse(DetailAST tree) { } }); // Script routes - for (var script : - tree(scope) - .filter(tokens(TokenTypes.METHOD_CALL)) - // Test for HTTP method name - .filter( - it -> - tree(it) - .filter(tokens(TokenTypes.IDENT)) - .anyMatch(e -> Router.METHODS.contains(e.getText().toUpperCase()))) - .toList()) { - var scriptComment = - children(script) - .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) - .findFirst() - .orElse(null); - if (scriptComment != null) { - // ELIST -> EXPR -> STRING_LITERAL - children(script) - .filter(tokens(TokenTypes.ELIST)) - .findFirst() - .flatMap(it -> children(it).filter(tokens(TokenTypes.EXPR)).findFirst()) - .flatMap(it -> children(it).filter(tokens(TokenTypes.STRING_LITERAL)).findFirst()) - .map(XpathUtil::getTextAttributeValue) - .ifPresent( - pattern -> { - classDoc.addScript(pattern, new MethodDoc(this, script, scriptComment)); - }); - } - } + scripts(scope, null, null, new HashSet<>(), classDoc); if (counter.get() > 0) { classes.put(classDoc.getName(), classDoc); @@ -113,6 +85,76 @@ public Map traverse(DetailAST tree) { return classes; } + private void scripts( + DetailAST scope, PathDoc pathDoc, String prefix, Set visited, ClassDoc classDoc) { + for (var script : tree(scope).filter(tokens(TokenTypes.METHOD_CALL)).toList()) { + if (visited.add(script)) { + // Test for HTTP method name + var callName = + tree(script) + .filter(tokens(TokenTypes.IDENT)) + .findFirst() + .map(DetailAST::getText) + .stream() + .findFirst() + .orElseThrow(() -> new IllegalStateException("No method call found: " + script)); + var scriptComment = + children(script) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(JavaDocNode.EMPTY_AST); + if (Router.METHODS.contains(callName.toUpperCase())) { + pathLiteral(script) + .ifPresent( + pattern -> { + var scriptDoc = + new ScriptDoc( + this, + callName.toUpperCase(), + computePath(prefix, pattern), + script, + scriptComment); + scriptDoc.setPath(pathDoc); + classDoc.addScript(scriptDoc); + }); + } else if ("path".equals(callName)) { + pathLiteral(script) + .ifPresent( + path -> { + scripts( + script, + new PathDoc(this, script, scriptComment), + computePath(prefix, path), + visited, + classDoc); + }); + } + } + } + } + + /** + * ELIST -> EXPR -> STRING_LITERAL + * + * @param script Get string literal from method call. + * @return String literal. + */ + private static Optional pathLiteral(DetailAST script) { + return children(script) + .filter(tokens(TokenTypes.ELIST)) + .findFirst() + .flatMap(it -> children(it).filter(tokens(TokenTypes.EXPR)).findFirst()) + .flatMap(it -> children(it).filter(tokens(TokenTypes.STRING_LITERAL)).findFirst()) + .map(XpathUtil::getTextAttributeValue); + } + + private String computePath(String prefix, String pattern) { + if (prefix == null) { + return Router.normalizePath(pattern); + } + return Router.noTrailingSlash(Router.normalizePath(prefix + pattern)); + } + private void traverse( DetailAST tree, Predicate types, diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java index cf650c38af..b1f29a33b4 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java @@ -34,6 +34,8 @@ public class JavaDocTag { CUSTOM_TAG.and(it -> it.getText().startsWith("@contact.")); private static final Predicate LICENSE = CUSTOM_TAG.and(it -> it.getText().startsWith("@license.")); + private static final Predicate OPERATION_ID = + CUSTOM_TAG.and(it -> it.getText().equals("@operationId")); private static final Predicate EXTENSION = CUSTOM_TAG.and(it -> it.getText().startsWith("@x-")); private static final Predicate THROWS = @@ -250,4 +252,15 @@ public static void javaDocTag( } } } + + public static String operationId(DetailNode javadoc) { + var operationId = new ArrayList(); + javaDocTag( + javadoc, + OPERATION_ID, + (tag, value, text) -> { + operationId.add(text); + }); + return operationId.isEmpty() ? null : operationId.getFirst(); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java index 620523f2f8..689f2f0467 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java @@ -18,11 +18,13 @@ public class MethodDoc extends JavaDocNode { + private String operationId; private Map throwList; public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { super(ctx, node, javadoc); throwList = JavaDocTag.throwList(this.javadoc); + operationId = JavaDocTag.operationId(this.javadoc); } MethodDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { @@ -33,6 +35,10 @@ public String getName() { return node.findFirstToken(TokenTypes.IDENT).getText(); } + public String getOperationId() { + return operationId; + } + public List getParameterNames() { var result = new ArrayList(); var index = 0; @@ -49,6 +55,18 @@ public List getParameterNames() { return result; } + public List getJavadocParameterNames() { + return tree(javadoc) + // must be a tag + .filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)) + .flatMap( + it -> + children(it) + .filter(javadocToken(JavadocTokenTypes.PARAMETER_NAME)) + .map(DetailNode::getText)) + .toList(); + } + public String getParameterDoc(String name) { return tree(javadoc) // must be a tag diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java new file mode 100644 index 0000000000..8757c7f783 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java @@ -0,0 +1,14 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; + +public class PathDoc extends JavaDocNode { + public PathDoc(JavaDocParser ctx, DetailAST node, DetailAST comment) { + super(ctx, node, comment); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java new file mode 100644 index 0000000000..4cb6626f7b --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java @@ -0,0 +1,42 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; + +public class ScriptDoc extends MethodDoc { + private final String method; + private final String pattern; + private PathDoc path; + + public ScriptDoc( + JavaDocParser ctx, String method, String pattern, DetailAST node, DetailAST javadoc) { + super(ctx, node, javadoc); + this.method = method; + this.pattern = pattern; + } + + public PathDoc getPath() { + return path; + } + + public void setPath(PathDoc path) { + this.path = path; + } + + public String getMethod() { + return method; + } + + public String getPattern() { + return pattern; + } + + @Override + public String toString() { + return method + " " + pattern; + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java index e77bda570a..223566efda 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java @@ -62,6 +62,7 @@ public void shouldGenerateMinApp(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -77,6 +78,7 @@ public void shouldGenerateMinApp(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -91,6 +93,7 @@ public void shouldGenerateMinApp(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -141,6 +144,7 @@ public void shouldGenerateMinApp(OpenAPIResult result) { + " application/x-www-form-urlencoded:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -223,6 +227,7 @@ public void shouldGenerateKtMinApp(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -238,6 +243,7 @@ public void shouldGenerateKtMinApp(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -252,6 +258,7 @@ public void shouldGenerateKtMinApp(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -302,6 +309,7 @@ public void shouldGenerateKtMinApp(OpenAPIResult result) { + " application/x-www-form-urlencoded:\n" + " schema:\n" + " $ref: \"#/components/schemas/Pet\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -358,6 +366,7 @@ public void shouldDoForm(OpenAPIResult result) { + " properties:\n" + " name:\n" + " type: string\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -385,6 +394,7 @@ public void shouldDoForm(OpenAPIResult result) { + " picture:\n" + " type: string\n" + " format: binary\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -400,6 +410,7 @@ public void shouldDoForm(OpenAPIResult result) { + " application/x-www-form-urlencoded:\n" + " schema:\n" + " $ref: \"#/components/schemas/AForm\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -440,6 +451,7 @@ public void shouldDoMvcForm(OpenAPIResult result) { + " properties:\n" + " name:\n" + " type: string\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -463,6 +475,7 @@ public void shouldDoMvcForm(OpenAPIResult result) { + " picture:\n" + " type: string\n" + " format: binary\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" @@ -478,6 +491,7 @@ public void shouldDoMvcForm(OpenAPIResult result) { + " multipart/form-data:\n" + " schema:\n" + " $ref: \"#/components/schemas/AForm\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" diff --git a/modules/jooby-openapi/src/test/java/issues/Issue1592.java b/modules/jooby-openapi/src/test/java/issues/Issue1592.java index 27374967eb..1fa1e6b9cf 100644 --- a/modules/jooby-openapi/src/test/java/issues/Issue1592.java +++ b/modules/jooby-openapi/src/test/java/issues/Issue1592.java @@ -30,6 +30,7 @@ public void shouldParseNestedTypes(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/FairData\"\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 813648aff4..6162055be6 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -13,7 +13,16 @@ public class ApiDocTest { @OpenAPITest(value = AppLibrary.class) - public void shouldGenerateDoc(OpenAPIResult result) { + public void shouldGenerateMvcDoc(OpenAPIResult result) { + checkResult(result); + } + + @OpenAPITest(value = ScriptLibrary.class) + public void shouldGenerateScriptDoc(OpenAPIResult result) { + checkResult(result); + } + + private void checkResult(OpenAPIResult result) { assertEquals( "openapi: 3.0.1\n" + "info:\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java new file mode 100644 index 0000000000..070d789c57 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java @@ -0,0 +1,111 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import java.util.ArrayList; +import java.util.List; + +import io.jooby.Jooby; + +/** + * Library API. + * + *

Available data: Books and authors. + * + * @version 4.0.0 + * @server.url https://api.fake-museum-example.com/v1 + * @contact.name Jooby + * @contact.url https://jooby.io + * @license.name Apache + * @contact.email support@jooby.io + * @license.url https://jooby.io/LICENSE + * @x-logo.url https://redocly.github.io/redoc/museum-logo.png + * @x-logo.altText Museum logo + */ +public class ScriptLibrary extends Jooby { + + { + /* + * Library API. + * + *

Contains all operations for creating, updating and fetching books. + * + * @tag.name Library + * @tag.description Access to all books. + */ + path( + "/api/library", + () -> { + /* + * Find a book by isbn. + * + * @param isbn Book isbn. Like IK-1900. + * @return A matching book. + * @throws NotFoundException 404 If a book doesn't exist. + * @throws BadRequestException 400 For bad ISBN code. + * @tag Book + * @tag Author + * @operationId bookByIsbn + */ + get( + "/{isbn}", + ctx -> { + var isbn = ctx.path("isbn").value(); + return new Book(); + }); + + /* + * Author by Id. + * + * @param id ID. + * @return An author + * @tag Author. Oxxx + * @operationId author + */ + get( + "/{id}", + ctx -> { + var id = ctx.path("id").value(); + return new Author(); + }); + + /* + * Query books. + * + * @param query Book's param query. + * @return Matching books. + * @x-badges.-name Beta + * @x-badges.position before + * @x-badges.color purple + * @operationId query + */ + get( + "/", + ctx -> { + List result = new ArrayList<>(); + var query = ctx.query(BookQuery.class); + return result; // List.of(new Book()); + }); + + /* + * Creates a new book. + * + *

Book can be created or updated. + * + * @param book Book to create. + * @return Saved book. + * @tag Author + * @operationId createBook + */ + post( + "/", + ctx -> { + var book = ctx.body(Book.class); + return book; + }); + }); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 4c1316befd..2135c91336 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -16,10 +16,7 @@ import org.junit.jupiter.api.Test; import io.jooby.SneakyThrows; -import io.jooby.internal.openapi.javadoc.ClassDoc; -import io.jooby.internal.openapi.javadoc.FieldDoc; -import io.jooby.internal.openapi.javadoc.JavaDocParser; -import io.jooby.internal.openapi.javadoc.MethodDoc; +import io.jooby.internal.openapi.javadoc.*; import issues.i3729.api.Book; public class JavaDocParserTest { @@ -36,6 +33,7 @@ public void scriptDoc() throws Exception { withScript( doc, + "GET", "/static", method -> { assertEquals("This is a static path.", method.getSummary()); @@ -45,13 +43,83 @@ public void scriptDoc() throws Exception { withScript( doc, - "/path/{id}", + "DELETE", + "/{id}", + method -> { + assertEquals("Delete something.", method.getSummary()); + assertEquals("ID to delete.", method.getParameterDoc("id")); + }); + + withScript( + doc, + "GET", + "/{id}", method -> { assertEquals("Path param.", method.getSummary()); assertNull(method.getDescription()); assertEquals("Some value.", method.getReturnDoc()); assertEquals("Path ID.", method.getParameterDoc("id")); }); + + withScript( + doc, + "GET", + "/tree/folder/{id}", + method -> { + assertNotNull(method.getPath()); + assertEquals("Tree summary.", method.getPath().getSummary()); + assertEquals("Tree doc.", method.getPath().getDescription()); + assertNotNull(method.getPath().getTags()); + assertEquals(1, method.getPath().getTags().size()); + assertEquals("Tree", method.getPath().getTags().getFirst().getName()); + + assertEquals("Item doc.", method.getSummary()); + assertNull(method.getDescription()); + }); + + withScript( + doc, + "GET", + "/tree/folder", + method -> { + assertEquals("Items.", method.getSummary()); + assertNull(method.getDescription()); + }); + + withScript( + doc, + "GET", + "/tree/file/{fileId}", + method -> { + assertEquals("Sub Items.", method.getSummary()); + assertNull(method.getDescription()); + }); + + withScript( + doc, + "GET", + "/tree/mount", + method -> { + assertEquals("Mounted.", method.getSummary()); + assertNull(method.getDescription()); + }); + + withScript( + doc, + "POST", + "/routes", + method -> { + assertEquals("Routes.", method.getSummary()); + assertNull(method.getDescription()); + }); + withScript( + doc, + "GET", + "/nested/last", + method -> { + assertEquals("Last.", method.getSummary()); + assertNull(method.getDescription()); + }); }); } @@ -289,10 +357,11 @@ private void withMethod( consumer.accept(method.get()); } - private void withScript(ClassDoc doc, String pattern, Consumer consumer) { - var method = doc.getScript(pattern); - assertTrue(method.isPresent()); - consumer.accept(method.get()); + private void withScript( + ClassDoc doc, String method, String pattern, Consumer consumer) { + var script = doc.getScript(method, pattern); + assertTrue(script.isPresent()); + consumer.accept(script.get()); } private void withField(ClassDoc doc, String name, Consumer consumer) { diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java b/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java index 0096f94664..16726d15d8 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java @@ -5,6 +5,8 @@ */ package javadoc.input; +import java.util.function.Predicate; + import io.jooby.Context; import io.jooby.Jooby; @@ -33,6 +35,67 @@ public class ScriptApp extends Jooby { * @return Some value. */ // Ignored - get("/path/{id}", Context::getRequestPath); + get("/{id}", Context::getRequestPath); + + /** + * Delete something. + * + * @param id ID to delete. + */ + delete("/{id}", Context::getRequestPath); + + /* + * Tree summary. + * + * Tree doc. + * + * @tag Tree + */ + path( + "/tree", + () -> { + /* + * Item doc. + */ + get("/folder/{id}", Context::getRequestPath); + + /* + * Items. + */ + get("/folder", Context::getRequestPath); + + path( + "/file", + () -> { + /* + * Sub Items. + */ + get("/{fileId}", Context::getRequestPath); + }); + mount( + Predicate.isEqual("/folder/{folderId}"), + () -> { + /* + * Mounted. + */ + get("/mount", Context::getRequestPath); + }); + }); + + routes( + () -> { + /* + * Routes. + */ + post("/routes", Context::getRequestPath); + path( + "/nested", + () -> { + /* + * Last. + */ + get("/last", Context::getRequestPath); + }); + }); } } From 2dfdabd6951087de9997aa8d6f7e9ae6087a4423 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 5 Aug 2025 06:17:18 -0300 Subject: [PATCH 3/9] open-api: script javadoc - WIP for lambda-ref javadoc parsing --- .../test/java/javadoc/JavaDocParserTest.java | 9 ++++++ .../src/test/java/javadoc/PrintAstTree.java | 4 +-- .../test/java/javadoc/input/LambdaRefApp.java | 30 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 2135c91336..088fcd9e83 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -21,6 +21,15 @@ public class JavaDocParserTest { + @Test + public void lambdaDoc() throws Exception { + withDoc( + javadoc.input.LambdaRefApp.class, + doc -> { + System.out.println(doc); + }); + } + @Test public void scriptDoc() throws Exception { withDoc( diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java index 85507e93dd..bdfd9ab960 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -9,7 +9,7 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import javadoc.input.ScriptApp; +import javadoc.input.LambdaRefApp; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; @@ -25,7 +25,7 @@ public static void main(String[] args) throws CheckstyleException, IOException { .resolve("java"); var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree( - baseDir.resolve(toPath(ScriptApp.class)).toFile()); + baseDir.resolve(toPath(LambdaRefApp.class)).toFile()); System.out.println(stringAst); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java b/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java new file mode 100644 index 0000000000..16b582eb2a --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java @@ -0,0 +1,30 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Context; +import io.jooby.Jooby; + +public class LambdaRefApp extends Jooby { + + { + path( + "/api/pets", + () -> { + get("/{id}", this::findPetById); + }); + } + + /* + * Find pet by id. + * @param id Pet ID. + */ + private @NonNull String findPetById(Context ctx) { + var id = ctx.path("id").value(); + return "Pets"; + } +} From 946286ca27efeb2560503385ab7eb5a881c8ee16 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 6 Aug 2025 09:58:28 -0300 Subject: [PATCH 4/9] open-api: javadoc for lambda/script routes - support method references --- .../internal/openapi/javadoc/ClassDoc.java | 13 +- .../internal/openapi/javadoc/JavaDocNode.java | 34 +++-- .../openapi/javadoc/JavaDocParser.java | 144 +++++++++++++++++- .../internal/openapi/javadoc/MethodDoc.java | 4 + .../java/issues/i3729/api/ScriptLibrary.java | 35 ++--- .../test/java/javadoc/JavaDocParserTest.java | 73 ++++++++- .../src/test/java/javadoc/PrintAstTree.java | 4 +- .../test/java/javadoc/input/LambdaRefApp.java | 29 +++- .../java/javadoc/input/MultilineComment.java | 28 ++++ .../java/javadoc/input/RequestHandler.java | 18 +++ .../javadoc/input/sub/SubPackageHandler.java | 20 +++ 11 files changed, 353 insertions(+), 49 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/MultilineComment.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/RequestHandler.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/sub/SubPackageHandler.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java index 392b4d1ba9..3f0d056430 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -218,15 +218,20 @@ public String getName() { TokenTypes.RECORD_DEF))) .map(this::getSimpleName) .toList(); - var packageScope = + + return Stream.concat(Stream.of(getPackage()), classScope.stream()) + .collect(Collectors.joining(".")); + } + + public String getPackage() { + return String.join( + ".", backward(node) .filter(tokens(TokenTypes.COMPILATION_UNIT)) .findFirst() .flatMap(it -> tree(it).filter(tokens(TokenTypes.PACKAGE_DEF)).findFirst()) .map(it -> tree(it).filter(tokens(TokenTypes.IDENT)).map(DetailAST::getText).toList()) - .orElse(List.of()); - return Stream.concat(packageScope.stream(), classScope.stream()) - .collect(Collectors.joining(".")); + .orElse(List.of())); } public boolean isRecord() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java index ed1ec82139..492d8a44fb 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java @@ -47,6 +47,10 @@ static DetailNode toJavaDocNode(DetailAST node) { : new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); } + public DetailAST getNode() { + return node; + } + public Map getExtensions() { return extensions; } @@ -96,25 +100,29 @@ public String getText() { protected static String getText(List nodes, boolean stripLeading) { var builder = new StringBuilder(); + var visited = new HashSet(); for (var node : nodes) { - if (node.getType() == JavadocTokenTypes.TEXT) { - var text = node.getText(); - if (stripLeading && Character.isWhitespace(text.charAt(0))) { - builder.append(' ').append(text.stripLeading()); - } else { - builder.append(text); - } - } else if (node.getType() == JavadocTokenTypes.NEWLINE) { - var next = JavadocUtil.getNextSibling(node); - if (next != null && next.getType() != JavadocTokenTypes.LEADING_ASTERISK) { - builder.append(next.getText()); + if (visited.add(node)) { + if (node.getType() == JavadocTokenTypes.TEXT) { + var text = node.getText(); + if (stripLeading && Character.isWhitespace(text.charAt(0))) { + builder.append(' ').append(text.stripLeading()); + } else { + builder.append(text); + } + } else if (node.getType() == JavadocTokenTypes.NEWLINE) { + var next = JavadocUtil.getNextSibling(node); + if (next != null && next.getType() != JavadocTokenTypes.LEADING_ASTERISK) { + builder.append(next.getText()); + visited.add(next); + } } } } - return builder.isEmpty() ? null : builder.toString().trim(); + return builder.isEmpty() ? null : builder.toString().trim().replaceAll("\\s+", " "); } - protected String toString(DetailNode node) { + protected static String toString(DetailNode node) { return DetailNodeTreeStringPrinter.printTree(node, "", ""); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index dd27593de1..4358e0d83f 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -9,6 +9,7 @@ import static io.jooby.SneakyThrows.throwingFunction; import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; import static io.jooby.internal.openapi.javadoc.JavaDocSupport.tokens; +import static java.util.Optional.ofNullable; import java.io.File; import java.nio.file.Files; @@ -18,6 +19,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Predicate; +import java.util.stream.Collectors; import com.puppycrawl.tools.checkstyle.JavaParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; @@ -26,6 +28,7 @@ import io.jooby.Router; public class JavaDocParser { + private record ScriptRef(String operationId, DetailAST comment) {} private final List baseDir; private final Map cache = new HashMap<>(); @@ -39,7 +42,7 @@ public JavaDocParser(List baseDir) { } public Optional parse(String typeName) { - return Optional.ofNullable(traverse(resolveType(typeName)).get(typeName)); + return ofNullable(traverse(resolveType(typeName)).get(typeName)); } public Map traverse(DetailAST tree) { @@ -76,7 +79,7 @@ public Map traverse(DetailAST tree) { } }); // Script routes - scripts(scope, null, null, new HashSet<>(), classDoc); + scripts(scope, classDoc, null, null, new HashSet<>()); if (counter.get() > 0) { classes.put(classDoc.getName(), classDoc); @@ -86,7 +89,7 @@ public Map traverse(DetailAST tree) { } private void scripts( - DetailAST scope, PathDoc pathDoc, String prefix, Set visited, ClassDoc classDoc) { + DetailAST scope, ClassDoc classDoc, PathDoc pathDoc, String prefix, Set visited) { for (var script : tree(scope).filter(tokens(TokenTypes.METHOD_CALL)).toList()) { if (visited.add(script)) { // Test for HTTP method name @@ -107,13 +110,17 @@ private void scripts( pathLiteral(script) .ifPresent( pattern -> { + var resolvedComment = resolveScriptComment(classDoc, script, scriptComment); var scriptDoc = new ScriptDoc( this, callName.toUpperCase(), computePath(prefix, pattern), script, - scriptComment); + resolvedComment.comment); + if (resolvedComment.operationId() != null) { + scriptDoc.setOperationId(resolvedComment.operationId()); + } scriptDoc.setPath(pathDoc); classDoc.addScript(scriptDoc); }); @@ -123,16 +130,141 @@ private void scripts( path -> { scripts( script, + classDoc, new PathDoc(this, script, scriptComment), computePath(prefix, path), - visited, - classDoc); + visited); }); } } } } + /** + * get("/reference", this::findPetById); post("/static-reference", + * javadoc.input.LambdaRefApp::staticFindPetById); put("/external-reference", + * RequestHandler::external); get("/external-subPackage-reference", + * SubPackageHandler::subPackage); + * + * @param classDoc + * @param script + * @param defaultComment + * @return + */ + private ScriptRef resolveScriptComment( + ClassDoc classDoc, DetailAST script, DetailAST defaultComment) { + // ELIST -> LAMBDA (children) + // ELIST -> EXPR -> METHOD_REF (tree) + return children(script) + .filter(tokens(TokenTypes.ELIST)) + .findFirst() + .map( + statementList -> + children(statementList) + .filter(tokens(TokenTypes.LAMBDA)) + .findFirst() + .map(lambda -> new ScriptRef(null, defaultComment)) + .orElseGet( + () -> + tree(statementList) + .filter(tokens(TokenTypes.METHOD_REF)) + .findFirst() + .flatMap( + ref -> ofNullable(resolveFromMethodRef(classDoc, script, ref))) + .orElseGet(() -> new ScriptRef(null, defaultComment)))) + .orElseGet(() -> new ScriptRef(null, defaultComment)); + } + + private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST script, DetailAST methodRef) { + var referenceOwner = getTypeName(methodRef); + DetailAST scope = null; + String className; + if (referenceOwner.equals("this")) { + scope = classDoc.getNode(); + className = classDoc.getName(); + } else { + // resolve className + className = toQualifiedName(classDoc, referenceOwner); + scope = resolveType(className); + if (scope == JavaDocNode.EMPTY_AST) { + // not found + return null; + } + } + var methodName = + children(methodRef).filter(tokens(TokenTypes.IDENT)).toList().getLast().getText(); + var method = + tree(scope) + .filter(tokens(TokenTypes.METHOD_DEF)) + .filter( + it -> + children(it) + .filter(tokens(TokenTypes.IDENT)) + .findFirst() + .filter(e -> e.getText().equals(methodName)) + .isPresent()) + // One Argument + .filter(it -> tree(it).filter(tokens(TokenTypes.PARAMETER_DEF)).count() == 1) + // Context Type + .filter( + it -> + tree(it) + .filter(tokens(TokenTypes.PARAMETER_DEF)) + .findFirst() + .flatMap(p -> children(p).filter(tokens(TokenTypes.TYPE)).findFirst()) + .filter(type -> getTypeName(type).equals("Context")) + .isPresent()) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "No method found: " + className + "." + methodName)); + return children(method) + .filter(tokens(TokenTypes.MODIFIERS)) + .findFirst() + .flatMap(it -> children(it).filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)).findFirst()) + .map(comment -> new ScriptRef(methodName, comment)) + .orElseGet(() -> new ScriptRef(null, JavaDocNode.EMPTY_AST)); + } + + private static String getTypeName(DetailAST methodRef) { + var referenceOwner = + tree(methodRef.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining(".")); + return referenceOwner; + } + + private static String toQualifiedName(ClassDoc classDoc, String referenceOwner) { + var className = referenceOwner; + if (!className.contains(".")) { + if (!classDoc.getSimpleName().equals(className)) { + var cu = + backward(classDoc.getNode()) + .filter(tokens(TokenTypes.COMPILATION_UNIT)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "No compilation unit found: " + referenceOwner)); + className = + children(cu) + .filter(tokens(TokenTypes.IMPORT)) + .map( + it -> + tree(it.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining("."))) + .filter(qualifiedName -> qualifiedName.endsWith("." + referenceOwner)) + .findFirst() + .orElseGet(() -> String.join(".", classDoc.getPackage(), referenceOwner)); + } + } + return className; + } + /** * ELIST -> EXPR -> STRING_LITERAL * diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java index 689f2f0467..664f174c1b 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java @@ -39,6 +39,10 @@ public String getOperationId() { return operationId; } + public void setOperationId(String operationId) { + this.operationId = operationId; + } + public List getParameterNames() { var result = new ArrayList(); var index = 0; diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java index 070d789c57..6a5368a9f0 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.List; +import io.jooby.Context; import io.jooby.Jooby; /** @@ -39,23 +40,7 @@ public class ScriptLibrary extends Jooby { path( "/api/library", () -> { - /* - * Find a book by isbn. - * - * @param isbn Book isbn. Like IK-1900. - * @return A matching book. - * @throws NotFoundException 404 If a book doesn't exist. - * @throws BadRequestException 400 For bad ISBN code. - * @tag Book - * @tag Author - * @operationId bookByIsbn - */ - get( - "/{isbn}", - ctx -> { - var isbn = ctx.path("isbn").value(); - return new Book(); - }); + get("/{isbn}", this::bookByIsbn); /* * Author by Id. @@ -108,4 +93,20 @@ public class ScriptLibrary extends Jooby { }); }); } + + /* + * Find a book by isbn. + * + * @param isbn Book isbn. Like IK-1900. + * @return A matching book. + * @throws NotFoundException 404 If a book doesn't exist. + * @throws BadRequestException 400 For bad ISBN code. + * @tag Book + * @tag Author + * @operationId bookByIsbn + */ + private Book bookByIsbn(Context ctx) { + var isbn = ctx.path("isbn").value(); + return new Book(); + } } diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 088fcd9e83..64a4bc3d9d 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -21,12 +21,83 @@ public class JavaDocParserTest { + @Test + public void multiline() throws Exception { + withDoc( + javadoc.input.MultilineComment.class, + doc -> { + // assertNull(doc.getSummary()); + // assertNull(doc.getDescription()); + + withScript( + doc, + "GET", + "/multiline", + method -> { + assertEquals("Multiline comment.", method.getSummary()); + assertEquals("multilineComment", method.getOperationId()); + assertEquals("Description in next line.", method.getDescription()); + assertNull(method.getReturnDoc()); + assertEquals("Path ID.", method.getParameterDoc("id")); + }); + }); + } + @Test public void lambdaDoc() throws Exception { withDoc( javadoc.input.LambdaRefApp.class, doc -> { - System.out.println(doc); + assertEquals("LambdaRefApp", doc.getSimpleName()); + assertEquals("javadoc.input.LambdaRefApp", doc.getName()); + assertEquals("Lambda App.", doc.getSummary()); + assertEquals("Using method ref.", doc.getDescription()); + + withScript( + doc, + "GET", + "/reference", + method -> { + assertEquals("Find pet by id.", method.getSummary()); + assertEquals("findPetById", method.getOperationId()); + assertNull(method.getDescription()); + assertNull(method.getReturnDoc()); + assertEquals("Pet ID.", method.getParameterDoc("id")); + }); + + withScript( + doc, + "POST", + "/static-reference", + method -> { + assertEquals("Static reference.", method.getSummary()); + assertEquals("staticFindPetById", method.getOperationId()); + assertEquals("Path ID.", method.getParameterDoc("id")); + assertEquals("Description in next line.", method.getDescription()); + assertNull(method.getReturnDoc()); + }); + + withScript( + doc, + "PUT", + "/external-reference", + method -> { + assertEquals("External doc.", method.getSummary()); + assertEquals("external", method.getOperationId()); + assertNull(method.getDescription()); + assertNull(method.getReturnDoc()); + }); + + withScript( + doc, + "GET", + "/external-subPackage-reference", + method -> { + assertEquals("Sub package doc.", method.getSummary()); + assertEquals("subPackage", method.getOperationId()); + assertNull(method.getDescription()); + assertNull(method.getReturnDoc()); + }); }); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java index bdfd9ab960..b848e4e8bd 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -9,7 +9,7 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import javadoc.input.LambdaRefApp; +import javadoc.input.MultilineComment; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; @@ -25,7 +25,7 @@ public static void main(String[] args) throws CheckstyleException, IOException { .resolve("java"); var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree( - baseDir.resolve(toPath(LambdaRefApp.class)).toFile()); + baseDir.resolve(toPath(MultilineComment.class)).toFile()); System.out.println(stringAst); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java b/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java index 16b582eb2a..fe77babc81 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java @@ -5,26 +5,43 @@ */ package javadoc.input; +import javadoc.input.sub.SubPackageHandler; + import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Context; import io.jooby.Jooby; +/** + * Lambda App. + * + *

Using method ref. + */ public class LambdaRefApp extends Jooby { - { - path( - "/api/pets", - () -> { - get("/{id}", this::findPetById); - }); + get("/reference", this::findPetById); + post("/static-reference", javadoc.input.LambdaRefApp::staticFindPetById); + put("/external-reference", RequestHandler::external); + get("/external-subPackage-reference", SubPackageHandler::subPackage); } /* * Find pet by id. + * * @param id Pet ID. */ private @NonNull String findPetById(Context ctx) { var id = ctx.path("id").value(); return "Pets"; } + + /* + Static reference. + + Description in next line. + @param id Path ID. + */ + private static @NonNull String staticFindPetById(Context ctx) { + var id = ctx.path("id").value(); + return "Pets"; + } } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/MultilineComment.java b/modules/jooby-openapi/src/test/java/javadoc/input/MultilineComment.java new file mode 100644 index 0000000000..f62552a84c --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/MultilineComment.java @@ -0,0 +1,28 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Context; +import io.jooby.Jooby; + +public class MultilineComment extends Jooby { + { + get("/multiline", this::multilineComment); + } + + /* + Multiline comment. + + Description in next + line. + @param id Path ID. + */ + private @NonNull String multilineComment(Context ctx) { + var id = ctx.path("id").value(); + return "Pets"; + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/RequestHandler.java b/modules/jooby-openapi/src/test/java/javadoc/input/RequestHandler.java new file mode 100644 index 0000000000..e3bdd4f4ee --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/RequestHandler.java @@ -0,0 +1,18 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +import io.jooby.Context; + +public class RequestHandler { + + /* + * External doc. + */ + public static String external(Context ctx) { + return ctx.path("external").value(); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/sub/SubPackageHandler.java b/modules/jooby-openapi/src/test/java/javadoc/input/sub/SubPackageHandler.java new file mode 100644 index 0000000000..28966c8d1e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/sub/SubPackageHandler.java @@ -0,0 +1,20 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input.sub; + +import io.jooby.Context; + +public class SubPackageHandler { + + /* + * Sub package doc. + * + * @param external External parameter. + */ + public static String subPackage(Context ctx) { + return ctx.path("external").value(); + } +} From 3638d0c0e77a5d61fbc529887e93b1d2bac8b0df Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 6 Aug 2025 10:32:55 -0300 Subject: [PATCH 5/9] open-api: traverse mount/install instruction on lambda/script routes and documente all them --- .../jooby/internal/openapi/OperationExt.java | 5 + .../jooby/internal/openapi/RouteParser.java | 53 +++++++---- .../openapi/javadoc/JavaDocParser.java | 12 ++- .../issues/i3729/api/InstalledRouter.java | 23 +++++ .../java/issues/i3729/api/MountedApp.java | 26 ++++++ .../java/issues/i3729/api/MountedDocTest.java | 92 +++++++++++++++++++ .../java/issues/i3729/api/MountedRouter.java | 23 +++++ 7 files changed, 213 insertions(+), 21 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/InstalledRouter.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/MountedApp.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/MountedDocTest.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/MountedRouter.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java index e16c533e48..50426437dc 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java @@ -211,6 +211,11 @@ public void setController(ClassNode controller) { this.controller = controller; } + @Override + public void setOperationId(String operationId) { + super.setOperationId(operationId); + } + @JsonIgnore public List getAllAnnotations() { return Stream.of( diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java index f1a2e2c5cf..e086723705 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java @@ -65,22 +65,9 @@ public List parse(ParserContext ctx, OpenAPIExt openapi) { Optional.ofNullable(ctx.getMainClass()).orElse(ctx.getRouter().getClassName()); ClassNode application = ctx.classNode(Type.getObjectType(applicationName.replace(".", "/"))); - // javadoc - var javaDoc = ctx.javadoc().parse(ctx.getRouter().getClassName()); - for (OperationExt operation : operations) { - // Script/lambda - if (operation.getController() == null) { - javaDoc - .flatMap(doc -> doc.getScript(operation.getMethod(), operation.getPattern())) - .ifPresent( - scriptDoc -> { - if (scriptDoc.getPath() != null) { - JavaDocSetter.setPath(operation, scriptDoc.getPath()); - } - JavaDocSetter.set(operation, scriptDoc); - }); - } - } + // JavaDoc + addJavaDoc(ctx, ctx.getRouter().getClassName(), "", operations); + // swagger/openapi: for (OperationExt operation : operations) { operation.setApplication(application); @@ -116,6 +103,29 @@ public List parse(ParserContext ctx, OpenAPIExt openapi) { return result; } + private static void addJavaDoc( + ParserContext ctx, String className, String prefix, List operations) { + // javadoc + var offset = prefix == null || prefix.isEmpty() ? 0 : prefix.length(); + var javaDoc = ctx.javadoc().parse(className); + for (OperationExt operation : operations) { + // Script/lambda + if (operation.getController() == null) { + javaDoc + .flatMap( + doc -> + doc.getScript(operation.getMethod(), operation.getPattern().substring(offset))) + .ifPresent( + scriptDoc -> { + if (scriptDoc.getPath() != null) { + JavaDocSetter.setPath(operation, scriptDoc.getPath()); + } + JavaDocSetter.set(operation, scriptDoc); + }); + } + } + } + private void checkResponses(ParserContext ctx, OperationExt operation) { // checkResponse(ctx, operation, 200, operation.getDefaultResponse()); for (Map.Entry entry : operation.getResponses().entrySet()) { @@ -607,7 +617,9 @@ private List mountRouter( throw new UnsupportedOperationException(InsnSupport.toString(node)); } ClassNode classNode = ctx.classNode(router); - return parse(ctx.newContext(router), prefix, classNode); + var operations = parse(ctx.newContext(router), prefix, classNode); + addJavaDoc(ctx, router.getClassName(), prefix, operations); + return operations; } private List installApp( @@ -632,7 +644,9 @@ private List installApp( throw new UnsupportedOperationException(InsnSupport.toString(node)); } ClassNode classNode = ctx.classNode(router); - return parse(ctx.newContext(router), prefix, classNode); + var operations = parse(ctx.newContext(router), prefix, classNode); + addJavaDoc(ctx, router.getClassName(), prefix, operations); + return operations; } private Type kotlinSupplier(ParserContext ctx, MethodInsnNode node, AbstractInsnNode ins) { @@ -896,7 +910,8 @@ private OperationExt newRouteDescriptor( node.name.equals("apply") || node.name.equals("invoke") || node.name.startsWith("invoke$") - || node.name.contains("$lambda"); + || node.name.contains("$lambda") + || node.name.startsWith("fake$"); if (notSynthetic && !lambda) { operation.setOperationId(node.name); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index 4358e0d83f..e3f1d7a8e9 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -79,7 +79,7 @@ public Map traverse(DetailAST tree) { } }); // Script routes - scripts(scope, classDoc, null, null, new HashSet<>()); + counter.addAndGet(scripts(scope, classDoc, null, null, new HashSet<>())); if (counter.get() > 0) { classes.put(classDoc.getName(), classDoc); @@ -88,8 +88,9 @@ public Map traverse(DetailAST tree) { return classes; } - private void scripts( + private int scripts( DetailAST scope, ClassDoc classDoc, PathDoc pathDoc, String prefix, Set visited) { + var counter = new AtomicInteger(0); for (var script : tree(scope).filter(tokens(TokenTypes.METHOD_CALL)).toList()) { if (visited.add(script)) { // Test for HTTP method name @@ -106,11 +107,17 @@ private void scripts( .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) .findFirst() .orElse(JavaDocNode.EMPTY_AST); + if (scriptComment != JavaDocNode.EMPTY_AST) { + counter.incrementAndGet(); + } if (Router.METHODS.contains(callName.toUpperCase())) { pathLiteral(script) .ifPresent( pattern -> { var resolvedComment = resolveScriptComment(classDoc, script, scriptComment); + if (scriptComment != resolvedComment.comment) { + counter.incrementAndGet(); + } var scriptDoc = new ScriptDoc( this, @@ -138,6 +145,7 @@ private void scripts( } } } + return counter.get(); } /** diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/InstalledRouter.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/InstalledRouter.java new file mode 100644 index 0000000000..c12507a0f4 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/InstalledRouter.java @@ -0,0 +1,23 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import io.jooby.Context; +import io.jooby.Jooby; + +public class InstalledRouter extends Jooby { + + { + /* + * Installed operation. + * + * @operationId installedOp + */ + get("/installed", Context::getRequestPath); + + mount(new MountedRouter()); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedApp.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedApp.java new file mode 100644 index 0000000000..7c334555ac --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedApp.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import io.jooby.Context; +import io.jooby.Jooby; + +public class MountedApp extends Jooby { + { + /* + * This is the main router. + */ + get("/main", Context::getRequestPath); + + mount(new MountedRouter()); + + mount("/mount-point", new MountedRouter()); + + install(InstalledRouter::new); + + install("/install-point", InstalledRouter::new); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedDocTest.java new file mode 100644 index 0000000000..8a88f6e875 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedDocTest.java @@ -0,0 +1,92 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.jooby.openapi.OpenAPIResult; +import io.jooby.openapi.OpenAPITest; + +public class MountedDocTest { + + @OpenAPITest(value = MountedApp.class) + public void shouldGenerateDocFromMountedApp(OpenAPIResult result) { + assertEquals( + "openapi: 3.0.1\n" + + "info:\n" + + " title: Mounted API\n" + + " description: Mounted API description\n" + + " version: \"1.0\"\n" + + "paths:\n" + + " /main:\n" + + " get:\n" + + " summary: This is the main router.\n" + + " operationId: getMain\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: string\n" + + " /mounted:\n" + + " get:\n" + + " summary: Mounted operation.\n" + + " operationId: mountedOp3\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: string\n" + + " /mount-point/mounted:\n" + + " get:\n" + + " summary: Mounted operation.\n" + + " operationId: mountedOp2\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: string\n" + + " /installed:\n" + + " get:\n" + + " summary: Installed operation.\n" + + " operationId: installedOp\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: string\n" + + " /install-point/installed:\n" + + " get:\n" + + " summary: Installed operation.\n" + + " operationId: installedOp2\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: string\n" + + " /install-point/mounted:\n" + + " get:\n" + + " summary: Mounted operation.\n" + + " operationId: mountedOp4\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: string\n", + result.toYaml()); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedRouter.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedRouter.java new file mode 100644 index 0000000000..22bd399dc8 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/MountedRouter.java @@ -0,0 +1,23 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import io.jooby.Context; +import io.jooby.Jooby; + +public class MountedRouter extends Jooby { + + { + get("/mounted", this::mountedOp); + } + + /* + * Mounted operation. + */ + private String mountedOp(Context context) { + return ""; + } +} From 55e4e4cdb742162de099a7e372ebe091367262cb Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 6 Aug 2025 18:09:25 -0300 Subject: [PATCH 6/9] open-api: code cleanup and refactor --- .../jooby/internal/openapi/JavaDocSetter.java | 8 +- .../internal/openapi/javadoc/ClassDoc.java | 34 +- .../internal/openapi/javadoc/FieldDoc.java | 3 +- .../internal/openapi/javadoc/JavaDocNode.java | 6 +- .../openapi/javadoc/JavaDocParser.java | 50 +-- .../openapi/javadoc/JavaDocStream.java | 252 +++++++++++++++ .../openapi/javadoc/JavaDocSupport.java | 305 +++++------------- .../internal/openapi/javadoc/JavaDocTag.java | 24 +- .../internal/openapi/javadoc/MethodDoc.java | 27 +- .../internal/openapi/javadoc/ThrowsDoc.java | 30 -- ...lDocParser.java => TinyYamlDocParser.java} | 4 +- .../javadoc/ExtensionJavaDocParserTest.java | 2 +- 12 files changed, 380 insertions(+), 365 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ThrowsDoc.java rename modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/{MiniYamlDocParser.java => TinyYamlDocParser.java} (97%) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java index c37b22d37f..3312601019 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java @@ -31,7 +31,7 @@ public static void set(OperationExt operation, ScriptDoc doc) { var parameterNames = parameters.stream().map(Parameter::getName).collect(Collectors.toList()); if (operation.getRequestBody() != null) { var javaDocNames = new LinkedHashSet<>(doc.getJavadocParameterNames()); - javaDocNames.removeAll(parameterNames); + parameterNames.forEach(javaDocNames::remove); if (javaDocNames.size() == 1) { // just add body name on lambda/script routes. parameterNames.addAll(javaDocNames); @@ -75,11 +75,11 @@ public static void set(OperationExt operation, MethodDoc doc, List param defaultResponse.setDescription(doc.getReturnDoc()); } for (var throwsDoc : doc.getThrows().values()) { - var response = operation.getResponse(Integer.toString(throwsDoc.getStatusCode().value())); + var response = operation.getResponse(throwsDoc.getCode()); if (response == null) { - response = operation.addResponse(Integer.toString(throwsDoc.getStatusCode().value())); + response = operation.addResponse(throwsDoc.getCode()); } - response.setDescription(throwsDoc.getText()); + response.setDescription(throwsDoc.getDescription()); } } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java index 3f0d056430..673086d955 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -5,7 +5,8 @@ */ package io.jooby.internal.openapi.javadoc; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; +import static io.jooby.internal.openapi.javadoc.JavaDocSupport.getClassName; import java.util.*; import java.util.stream.Collectors; @@ -198,40 +199,15 @@ private String toMethodSignature(String methodName, List parameterNames) } public String getSimpleName() { - return getSimpleName(node); - } - - protected String getSimpleName(DetailAST node) { - return node.findFirstToken(TokenTypes.IDENT).getText(); + return JavaDocSupport.getSimpleName(node); } public String getName() { - var classScope = - Stream.concat( - Stream.of(node), - backward(node) - .filter( - tokens( - TokenTypes.CLASS_DEF, - TokenTypes.INTERFACE_DEF, - TokenTypes.ENUM_DEF, - TokenTypes.RECORD_DEF))) - .map(this::getSimpleName) - .toList(); - - return Stream.concat(Stream.of(getPackage()), classScope.stream()) - .collect(Collectors.joining(".")); + return getClassName(node); } public String getPackage() { - return String.join( - ".", - backward(node) - .filter(tokens(TokenTypes.COMPILATION_UNIT)) - .findFirst() - .flatMap(it -> tree(it).filter(tokens(TokenTypes.PACKAGE_DEF)).findFirst()) - .map(it -> tree(it).filter(tokens(TokenTypes.IDENT)).map(DetailAST::getText).toList()) - .orElse(List.of())); + return JavaDocSupport.getPackageName(node); } public boolean isRecord() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java index 6a8455ac17..13e1ba2117 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java @@ -7,7 +7,6 @@ import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; -import com.puppycrawl.tools.checkstyle.api.TokenTypes; public class FieldDoc extends JavaDocNode { public FieldDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { @@ -19,6 +18,6 @@ public FieldDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { } public String getName() { - return node.findFirstToken(TokenTypes.IDENT).getText(); + return JavaDocSupport.getSimpleName(node); } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java index 492d8a44fb..9f21e33fe0 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java @@ -5,8 +5,8 @@ */ package io.jooby.internal.openapi.javadoc; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.javadocToken; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.javadocToken; import java.util.*; import java.util.function.Predicate; @@ -95,7 +95,7 @@ public String getDescription() { } public String getText() { - return getText(JavaDocSupport.forward(javadoc, JAVADOC_TAG).toList(), false); + return getText(JavaDocStream.forward(javadoc, JAVADOC_TAG).toList(), false); } protected static String getText(List nodes, boolean stripLeading) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index e3f1d7a8e9..8158e3bae2 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -7,8 +7,8 @@ import static com.puppycrawl.tools.checkstyle.JavaParser.parseFile; import static io.jooby.SneakyThrows.throwingFunction; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.tokens; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.tokens; import static java.util.Optional.ofNullable; import java.io.File; @@ -177,13 +177,12 @@ private ScriptRef resolveScriptComment( tree(statementList) .filter(tokens(TokenTypes.METHOD_REF)) .findFirst() - .flatMap( - ref -> ofNullable(resolveFromMethodRef(classDoc, script, ref))) + .flatMap(ref -> ofNullable(resolveFromMethodRef(classDoc, ref))) .orElseGet(() -> new ScriptRef(null, defaultComment)))) .orElseGet(() -> new ScriptRef(null, defaultComment)); } - private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST script, DetailAST methodRef) { + private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { var referenceOwner = getTypeName(methodRef); DetailAST scope = null; String className; @@ -192,7 +191,7 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST script, Deta className = classDoc.getName(); } else { // resolve className - className = toQualifiedName(classDoc, referenceOwner); + className = JavaDocSupport.toQualifiedName(classDoc.node, referenceOwner); scope = resolveType(className); if (scope == JavaDocNode.EMPTY_AST) { // not found @@ -236,41 +235,10 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST script, Deta } private static String getTypeName(DetailAST methodRef) { - var referenceOwner = - tree(methodRef.getFirstChild()) - .filter(tokens(TokenTypes.DOT).negate()) - .map(DetailAST::getText) - .collect(Collectors.joining(".")); - return referenceOwner; - } - - private static String toQualifiedName(ClassDoc classDoc, String referenceOwner) { - var className = referenceOwner; - if (!className.contains(".")) { - if (!classDoc.getSimpleName().equals(className)) { - var cu = - backward(classDoc.getNode()) - .filter(tokens(TokenTypes.COMPILATION_UNIT)) - .findFirst() - .orElseThrow( - () -> - new IllegalArgumentException( - "No compilation unit found: " + referenceOwner)); - className = - children(cu) - .filter(tokens(TokenTypes.IMPORT)) - .map( - it -> - tree(it.getFirstChild()) - .filter(tokens(TokenTypes.DOT).negate()) - .map(DetailAST::getText) - .collect(Collectors.joining("."))) - .filter(qualifiedName -> qualifiedName.endsWith("." + referenceOwner)) - .findFirst() - .orElseGet(() -> String.join(".", classDoc.getPackage(), referenceOwner)); - } - } - return className; + return tree(methodRef.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining(".")); } /** diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java new file mode 100644 index 0000000000..9e83f8143c --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java @@ -0,0 +1,252 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; + +public final class JavaDocStream { + + public static Predicate tokens(Integer... types) { + return tokens(Set.of(types)); + } + + private static Predicate tokens(Set types) { + return it -> types.contains(it.getType()); + } + + public static Predicate javadocToken(Integer... types) { + return javadocToken(Set.of(types)); + } + + private static Predicate javadocToken(Set types) { + return it -> types.contains(it.getType()); + } + + /** + * Traverse the tree from current node to parent (backward). + * + * @param node Starting point + * @return Stream. + */ + public static Stream backward(DetailAST node) { + return backward(ASTNode.ast(node)); + } + + /** + * Traverse the tree from the current node to children and sibling (forward). + * + * @param node Starting point + * @return Stream. + */ + public static Stream forward(DetailAST node) { + return forward(ASTNode.ast(node)); + } + + /** + * Traverse the tree from the current node to children and sibling (forward) but keeping the scope + * to the given node (root). + * + * @param node Root node. + * @return Stream. + */ + public static Stream tree(DetailAST node) { + return tree(ASTNode.ast(node)); + } + + public static Stream tree(DetailNode node) { + return tree(ASTNode.javadoc(node)); + } + + public static Stream children(DetailAST node) { + return stream(childrenIterator(ASTNode.ast(node))); + } + + public static Stream children(DetailNode node) { + return stream(childrenIterator(ASTNode.javadoc(node))); + } + + public static Stream backward(DetailNode node) { + return backward(ASTNode.javadoc(node)); + } + + public static Stream forward(DetailNode node) { + return forward(ASTNode.javadoc(node)); + } + + public static Stream forward(DetailNode node, Predicate stopOn) { + var nodes = forward(ASTNode.javadoc(node)).toList(); + var result = new ArrayList(); + for (var it : nodes) { + if (stopOn.test(it)) { + break; + } + result.add(it); + } + return result.stream(); + } + + private static Stream backward(ASTNode node) { + return stream(backwardIterator(node)); + } + + private static Stream forward(ASTNode node) { + return stream(forwardIterator(node)); + } + + private static Stream tree(ASTNode node) { + return stream(treeIterator(node)); + } + + private static Stream stream(Iterator iterator) { + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + } + + private static Iterator treeIterator(ASTNode node) { + return forwardIterator(node, false); + } + + private static Iterator forwardIterator(ASTNode node) { + return forwardIterator(node, true); + } + + private static Iterator childrenIterator(ASTNode node) { + return new Iterator<>() { + Function, ASTNode> direction = null; + ASTNode it = node; + + @Override + public boolean hasNext() { + if (direction == null) { + direction = ASTNode::getFirstChild; + } else { + direction = ASTNode::getNextSibling; + } + return direction.apply(it) != null; + } + + @Override + public T next() { + it = direction.apply(it); + return it.getNode(); + } + }; + } + + private static Iterator backwardIterator(ASTNode node) { + return new Iterator<>() { + ASTNode it = node; + + @Override + public boolean hasNext() { + return it.getParent() != null; + } + + @Override + public T next() { + it = it.getParent(); + return it.getNode(); + } + }; + } + + private static Iterator forwardIterator(ASTNode node, boolean full) { + return new Iterator<>() { + ASTNode it = node; + final Stack> stack = new Stack<>(); + + @Override + public boolean hasNext() { + return it != null; + } + + @Override + public T next() { + if (it.getNextSibling() != null) { + if (full || it != node) { + stack.push(it.getNextSibling()); + } + } + var current = it; + var child = it.getFirstChild(); + if (child == null) { + if (!stack.isEmpty()) { + it = stack.pop(); + } else { + it = null; + } + } else { + it = child; + } + return current.getNode(); + } + }; + } + + private interface ASTNode { + ASTNode getFirstChild(); + + ASTNode getNextSibling(); + + ASTNode getParent(); + + Node getNode(); + + static ASTNode ast(DetailAST node) { + return ast(node, DetailAST::getParent, DetailAST::getFirstChild, DetailAST::getNextSibling); + } + + static ASTNode javadoc(DetailNode node) { + return ast( + node, DetailNode::getParent, JavadocUtil::getFirstChild, JavadocUtil::getNextSibling); + } + + static ASTNode ast( + N node, Function parent, Function child, Function sibling) { + return new ASTNode<>() { + @Override + public ASTNode getParent() { + var parentNode = parent.apply(node); + if (parentNode == null) { + return null; + } + return ast(parentNode, parent, child, sibling); + } + + @Override + public ASTNode getFirstChild() { + var childNode = child.apply(node); + if (childNode == null) { + return null; + } + return ast(childNode, parent, child, sibling); + } + + @Override + public N getNode() { + return node; + } + + @Override + public ASTNode getNextSibling() { + var siblingNode = sibling.apply(node); + if (siblingNode == null) { + return null; + } + return ast(siblingNode, parent, child, sibling); + } + }; + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java index 6000c4ea52..fd464f0870 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java @@ -5,248 +5,99 @@ */ package io.jooby.internal.openapi.javadoc; -import java.util.*; -import java.util.function.Function; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.tokens; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.tree; + +import java.util.List; import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.stream.StreamSupport; import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.DetailNode; -import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; - -public final class JavaDocSupport { +import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import com.puppycrawl.tools.checkstyle.utils.TokenUtil; - public static Predicate tokens(Integer... types) { - return tokens(Set.of(types)); - } - - private static Predicate tokens(Set types) { - return it -> types.contains(it.getType()); - } - - public static Predicate javadocToken(Integer... types) { - return javadocToken(Set.of(types)); - } - - private static Predicate javadocToken(Set types) { - return it -> types.contains(it.getType()); - } +public class JavaDocSupport { - /** - * Traverse the tree from current node to parent (backward). - * - * @param node Starting point - * @return Stream. - */ - public static Stream backward(DetailAST node) { - return backward(ASTNode.ast(node)); - } + public static final Predicate TYPES = + tokens( + TokenTypes.CLASS_DEF, + TokenTypes.INTERFACE_DEF, + TokenTypes.ENUM_DEF, + TokenTypes.RECORD_DEF); - /** - * Traverse the tree from the current node to children and sibling (forward). - * - * @param node Starting point - * @return Stream. - */ - public static Stream forward(DetailAST node) { - return forward(ASTNode.ast(node)); - } + public static final Predicate EXTENDED_TYPES = + TYPES.or(tokens(TokenTypes.VARIABLE_DEF, TokenTypes.PARAMETER_DEF)); /** - * Traverse the tree from the current node to children and sibling (forward) but keeping the scope - * to the given node (root). + * Name from class, method, field, parameter. * - * @param node Root node. - * @return Stream. + * @param node Node. + * @return Name. */ - public static Stream tree(DetailAST node) { - return tree(ASTNode.ast(node)); - } - - public static Stream tree(DetailNode node) { - return tree(ASTNode.javadoc(node)); - } - - public static Stream children(DetailAST node) { - return stream(childrenIterator(ASTNode.ast(node))); - } - - public static Stream children(DetailNode node) { - return stream(childrenIterator(ASTNode.javadoc(node))); - } - - public static Stream backward(DetailNode node) { - return backward(ASTNode.javadoc(node)); - } - - public static Stream forward(DetailNode node) { - return forward(ASTNode.javadoc(node)); - } - - public static Stream forward(DetailNode node, Predicate stopOn) { - var nodes = forward(ASTNode.javadoc(node)).toList(); - var result = new ArrayList(); - for (var it : nodes) { - if (stopOn.test(it)) { - break; + public static String getSimpleName(DetailAST node) { + checkTypeDef(EXTENDED_TYPES, node); + return node.findFirstToken(TokenTypes.IDENT).getText(); + } + + public static String getClassName(DetailAST node) { + checkTypeDef(TYPES, node); + var classScope = + Stream.concat(Stream.of(node), backward(node).filter(TYPES)) + .map(JavaDocSupport::getSimpleName) + .toList(); + + return Stream.concat(Stream.of(getPackageName(node)), classScope.stream()) + .collect(Collectors.joining(".")); + } + + public static DetailAST getCompilationUnit(DetailAST node) { + return backward(node) + .filter(tokens(TokenTypes.COMPILATION_UNIT)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Compilation unit missing: " + TokenUtil.getTokenName(node.getType()))); + } + + public static String getPackageName(DetailAST node) { + return String.join( + ".", + tree(getCompilationUnit(node)) + .filter(tokens(TokenTypes.PACKAGE_DEF)) + .findFirst() + .map(it -> tree(it).filter(tokens(TokenTypes.IDENT)).map(DetailAST::getText).toList()) + .orElse(List.of())); + } + + public static String toQualifiedName(DetailAST classDef, String typeName) { + checkTypeDef(TYPES, classDef); + if (!typeName.contains(".")) { + if (!getSimpleName(classDef).equals(typeName)) { + var cu = getCompilationUnit(classDef); + return children(cu) + .filter(tokens(TokenTypes.IMPORT)) + .map( + it -> + tree(it.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining("."))) + .filter(qualifiedName -> qualifiedName.endsWith("." + typeName)) + .findFirst() + .orElseGet(() -> String.join(".", getPackageName(classDef), typeName)); } - result.add(it); } - return result.stream(); - } - - private static Stream backward(ASTNode node) { - return stream(backwardIterator(node)); - } - - private static Stream forward(ASTNode node) { - return stream(forwardIterator(node)); - } - - private static Stream tree(ASTNode node) { - return stream(treeIterator(node)); + // Already qualified. + return typeName; } - private static Stream stream(Iterator iterator) { - return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); - } - - private static Iterator treeIterator(ASTNode node) { - return forwardIterator(node, false); - } - - private static Iterator forwardIterator(ASTNode node) { - return forwardIterator(node, true); - } - - private static Iterator childrenIterator(ASTNode node) { - return new Iterator<>() { - Function, ASTNode> direction = null; - ASTNode it = node; - - @Override - public boolean hasNext() { - if (direction == null) { - direction = ASTNode::getFirstChild; - } else { - direction = ASTNode::getNextSibling; - } - return direction.apply(it) != null; - } - - @Override - public T next() { - it = direction.apply(it); - return it.getNode(); - } - }; - } - - private static Iterator backwardIterator(ASTNode node) { - return new Iterator<>() { - ASTNode it = node; - - @Override - public boolean hasNext() { - return it.getParent() != null; - } - - @Override - public T next() { - it = it.getParent(); - return it.getNode(); - } - }; - } - - private static Iterator forwardIterator(ASTNode node, boolean full) { - return new Iterator<>() { - ASTNode it = node; - final Stack> stack = new Stack<>(); - - @Override - public boolean hasNext() { - return it != null; - } - - @Override - public T next() { - if (it.getNextSibling() != null) { - if (full || it != node) { - stack.push(it.getNextSibling()); - } - } - var current = it; - var child = it.getFirstChild(); - if (child == null) { - if (!stack.isEmpty()) { - it = stack.pop(); - } else { - it = null; - } - } else { - it = child; - } - return current.getNode(); - } - }; - } - - private interface ASTNode { - ASTNode getFirstChild(); - - ASTNode getNextSibling(); - - ASTNode getParent(); - - Node getNode(); - - static ASTNode ast(DetailAST node) { - return ast(node, DetailAST::getParent, DetailAST::getFirstChild, DetailAST::getNextSibling); - } - - static ASTNode javadoc(DetailNode node) { - return ast( - node, DetailNode::getParent, JavadocUtil::getFirstChild, JavadocUtil::getNextSibling); - } - - static ASTNode ast( - N node, Function parent, Function child, Function sibling) { - return new ASTNode<>() { - @Override - public ASTNode getParent() { - var parentNode = parent.apply(node); - if (parentNode == null) { - return null; - } - return ast(parentNode, parent, child, sibling); - } - - @Override - public ASTNode getFirstChild() { - var childNode = child.apply(node); - if (childNode == null) { - return null; - } - return ast(childNode, parent, child, sibling); - } - - @Override - public N getNode() { - return node; - } - - @Override - public ASTNode getNextSibling() { - var siblingNode = sibling.apply(node); - if (siblingNode == null) { - return null; - } - return ast(siblingNode, parent, child, sibling); - } - }; + private static void checkTypeDef(Predicate predicate, DetailAST node) { + if (!predicate.test(node)) { + throw new IllegalArgumentException( + "Must be a type definition, found: " + TokenUtil.getTokenName(node.getType())); } } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java index b1f29a33b4..d0a7b53f86 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java @@ -6,8 +6,8 @@ package io.jooby.internal.openapi.javadoc; import static io.jooby.internal.openapi.javadoc.JavaDocNode.getText; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.children; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.children; import java.util.*; import java.util.function.Function; @@ -18,6 +18,7 @@ import io.jooby.SneakyThrows.Consumer2; import io.jooby.SneakyThrows.Consumer3; import io.jooby.StatusCode; +import io.jooby.internal.openapi.ResponseExt; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.servers.Server; @@ -93,11 +94,12 @@ private static List openApiComponent( }); var result = new ArrayList(); if (!values.isEmpty()) { - var output = MiniYamlDocParser.parse(values); + var output = TinyYamlDocParser.parse(values); var itemList = output.get(path); if (!(itemList instanceof List)) { itemList = List.of(itemList); } + //noinspection unchecked,rawtypes ((List) itemList) .forEach( it -> { @@ -109,8 +111,8 @@ private static List openApiComponent( return result; } - public static Map throwList(DetailNode node) { - var result = new LinkedHashMap(); + public static Map throwList(DetailNode node) { + var result = new LinkedHashMap(); javaDocTag( node, THROWS, @@ -151,7 +153,13 @@ public static Map throwList(DetailNode node) { .findFirst()) .orElse(null); if (statusCode != null) { - var throwsDoc = new ThrowsDoc(statusCode, text); + if (text == null) { + text = statusCode.reason(); + } else { + text = statusCode.reason() + ": " + text; + } + var throwsDoc = new ResponseExt(Integer.toString(statusCode.value())); + throwsDoc.setDescription(text); result.putIfAbsent(statusCode, throwsDoc); } }); @@ -168,7 +176,7 @@ public static Map extensions(DetailNode node) { values.add(tag.getText().substring(1)); values.add(value); }); - return MiniYamlDocParser.parse(values); + return TinyYamlDocParser.parse(values); } public static List tags(DetailNode node) { @@ -204,7 +212,7 @@ public static List tags(DetailNode node) { } }); if (!values.isEmpty()) { - var tagMap = MiniYamlDocParser.parse(values); + var tagMap = TinyYamlDocParser.parse(values); var tags = tagMap.get("tag"); if (!(tags instanceof List)) { tags = List.of(tags); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java index 664f174c1b..e30817a654 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java @@ -5,7 +5,7 @@ */ package io.jooby.internal.openapi.javadoc; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; +import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; import java.util.*; import java.util.stream.Stream; @@ -15,11 +15,11 @@ import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; import io.jooby.StatusCode; +import io.jooby.internal.openapi.ResponseExt; public class MethodDoc extends JavaDocNode { - private String operationId; - private Map throwList; + private Map throwList; public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { super(ctx, node, javadoc); @@ -44,19 +44,10 @@ public void setOperationId(String operationId) { } public List getParameterNames() { - var result = new ArrayList(); - var index = 0; - for (var parameterDef : tree(node).filter(tokens(TokenTypes.PARAMETER_DEF)).toList()) { - var name = - children(parameterDef) - .filter(tokens(TokenTypes.IDENT)) - .findFirst() - .map(DetailAST::getText) - .orElse("param" + index); - result.add(name); - index++; - } - return result; + return tree(node) + .filter(tokens(TokenTypes.PARAMETER_DEF)) + .map(JavaDocSupport::getSimpleName) + .toList(); } public List getJavadocParameterNames() { @@ -91,7 +82,7 @@ public String getParameterDoc(String name) { getText( Stream.of(it.getChildren()) .filter(e -> e.getType() == JavadocTokenTypes.DESCRIPTION) - .flatMap(JavaDocSupport::tree) + .flatMap(JavaDocStream::tree) .toList(), true)) .filter(it -> !it.isEmpty()) @@ -111,7 +102,7 @@ public String getReturnDoc() { .orElse(null); } - public Map getThrows() { + public Map getThrows() { return throwList; } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ThrowsDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ThrowsDoc.java deleted file mode 100644 index 0fe1520e38..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ThrowsDoc.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import io.jooby.StatusCode; - -public class ThrowsDoc { - private final String text; - private final StatusCode statusCode; - - public ThrowsDoc(StatusCode statusCode, String text) { - this.statusCode = statusCode; - if (text == null) { - this.text = statusCode.reason(); - } else { - this.text = statusCode.reason() + ": " + text; - } - } - - public String getText() { - return text; - } - - public StatusCode getStatusCode() { - return statusCode; - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/TinyYamlDocParser.java similarity index 97% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java rename to modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/TinyYamlDocParser.java index cb41bd937d..65acab89f5 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/TinyYamlDocParser.java @@ -10,7 +10,7 @@ import java.util.List; import java.util.Map; -public class MiniYamlDocParser { +public class TinyYamlDocParser { @SuppressWarnings("unchecked") public static Map parse(List properties) { // The root of our final tree structure. @@ -102,7 +102,7 @@ private static Object restructureNode(Object node) { listOfObjects.add(objectMap); } if (listOfObjects.size() == 1 - && restructuredMap.keySet().stream().noneMatch(MiniYamlDocParser::startsWithDash)) { + && restructuredMap.keySet().stream().noneMatch(TinyYamlDocParser::startsWithDash)) { return listOfObjects.getFirst(); } return listOfObjects; diff --git a/modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.java index fa0b1e048e..c4d49a21e7 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.java @@ -5,7 +5,7 @@ */ package javadoc; -import static io.jooby.internal.openapi.javadoc.MiniYamlDocParser.parse; +import static io.jooby.internal.openapi.javadoc.TinyYamlDocParser.parse; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; From 2f5c598dcbcab4b9070d4c8fb883480022268b53 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 6 Aug 2025 19:48:51 -0300 Subject: [PATCH 7/9] open-api: Strong lookup of MVC method - use types to find a method --- .../internal/openapi/AnnotationParser.java | 10 ++-- .../internal/openapi/javadoc/ClassDoc.java | 18 +++--- .../openapi/javadoc/JavaDocParser.java | 25 ++------- .../openapi/javadoc/JavaDocStream.java | 4 -- .../openapi/javadoc/JavaDocSupport.java | 56 +++++++++++++------ .../internal/openapi/javadoc/MethodDoc.java | 34 +++++++++-- .../test/java/javadoc/JavaDocParserTest.java | 10 ++-- .../src/test/java/javadoc/PrintAstTree.java | 4 +- 8 files changed, 96 insertions(+), 65 deletions(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java index f2eab7a15f..0f8455fdc1 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java @@ -279,12 +279,12 @@ public static List parse(ParserContext ctx, String prefix, Type ty doc -> { JavaDocSetter.setPath(operationExt, doc); var parameterNames = - Optional.ofNullable(operationExt.getNode().parameters) - .orElse(List.of()) - .stream() - .map(p -> p.name) + operationExt.getNode().parameters.stream().map(p -> p.name).toList(); + var parameterTypes = + Stream.of(Type.getArgumentTypes(operationExt.getNode().desc)) + .map(Type::getClassName) .toList(); - doc.getMethod(operationExt.getOperationId(), parameterNames) + doc.getMethod(operationExt.getOperationId(), parameterTypes) .ifPresent( methodDoc -> JavaDocSetter.set(operationExt, methodDoc, parameterNames)); }); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java index 673086d955..7380500ad0 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -117,7 +117,11 @@ private void defaultRecordMembers() { /* Virtual method */ var method = new MethodDoc( - context, createVirtualMember(name.getText(), TokenTypes.METHOD_DEF), memberDoc); + context, + createVirtualMember(name.getText(), TokenTypes.METHOD_DEF), + memberDoc) + .markAsVirtual(); + addMethod(method); }); } @@ -142,7 +146,7 @@ private void defaultEnumMembers() { } } - private static DetailAstImpl createVirtualMember(String name, int tokenType) { + private DetailAstImpl createVirtualMember(String name, int tokenType) { var publicMod = new DetailAstImpl(); publicMod.initialize( TokenTypes.LITERAL_PUBLIC, TokenUtil.getTokenName(TokenTypes.LITERAL_PUBLIC)); @@ -174,8 +178,8 @@ public Optional getField(String name) { return Optional.ofNullable(fields.get(name)); } - public Optional getMethod(String name, List parameterNames) { - return Optional.ofNullable(methods.get(toMethodSignature(name, parameterNames))); + public Optional getMethod(String name, List types) { + return Optional.ofNullable(methods.get(toMethodSignature(name, types))); } public Optional getScript(String method, String pattern) { @@ -191,11 +195,11 @@ private String toScriptSignature(String method, String pattern) { } private String toMethodSignature(MethodDoc method) { - return toMethodSignature(method.getName(), method.getParameterNames()); + return toMethodSignature(method.getName(), method.getParameterTypes()); } - private String toMethodSignature(String methodName, List parameterNames) { - return methodName + parameterNames.stream().collect(Collectors.joining(", ", "(", ")")); + private String toMethodSignature(String methodName, List types) { + return methodName + types.stream().collect(Collectors.joining(", ", "(", ")")); } public String getSimpleName() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index 8158e3bae2..1d1dd952d1 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -9,6 +9,7 @@ import static io.jooby.SneakyThrows.throwingFunction; import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; import static io.jooby.internal.openapi.javadoc.JavaDocStream.tokens; +import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; import static java.util.Optional.ofNullable; import java.io.File; @@ -19,7 +20,6 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Predicate; -import java.util.stream.Collectors; import com.puppycrawl.tools.checkstyle.JavaParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; @@ -47,15 +47,9 @@ public Optional parse(String typeName) { public Map traverse(DetailAST tree) { var classes = new HashMap(); - var types = - tokens( - TokenTypes.ENUM_DEF, - TokenTypes.CLASS_DEF, - TokenTypes.INTERFACE_DEF, - TokenTypes.RECORD_DEF); traverse( tree, - types, + TYPES, modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_PRIVATE)), (scope, comment) -> { var counter = new AtomicInteger(0); @@ -70,7 +64,7 @@ public Map traverse(DetailAST tree) { (member, memberComment) -> { counter.addAndGet(memberComment == JavaDocNode.EMPTY_AST ? 0 : 1); // check member belong to current scope - if (scope == backward(member).filter(types).findFirst().orElse(null)) { + if (scope == backward(member).filter(TYPES).findFirst().orElse(null)) { if (member.getType() == TokenTypes.VARIABLE_DEF) { classDoc.addField(new FieldDoc(this, member, memberComment)); } else { @@ -183,7 +177,7 @@ private ScriptRef resolveScriptComment( } private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { - var referenceOwner = getTypeName(methodRef); + var referenceOwner = getQualifiedName(methodRef); DetailAST scope = null; String className; if (referenceOwner.equals("this")) { @@ -191,7 +185,7 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { className = classDoc.getName(); } else { // resolve className - className = JavaDocSupport.toQualifiedName(classDoc.node, referenceOwner); + className = toQualifiedName(classDoc.node, referenceOwner); scope = resolveType(className); if (scope == JavaDocNode.EMPTY_AST) { // not found @@ -219,7 +213,7 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { .filter(tokens(TokenTypes.PARAMETER_DEF)) .findFirst() .flatMap(p -> children(p).filter(tokens(TokenTypes.TYPE)).findFirst()) - .filter(type -> getTypeName(type).equals("Context")) + .filter(type -> getQualifiedName(type).equals("Context")) .isPresent()) .findFirst() .orElseThrow( @@ -234,13 +228,6 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { .orElseGet(() -> new ScriptRef(null, JavaDocNode.EMPTY_AST)); } - private static String getTypeName(DetailAST methodRef) { - return tree(methodRef.getFirstChild()) - .filter(tokens(TokenTypes.DOT).negate()) - .map(DetailAST::getText) - .collect(Collectors.joining(".")); - } - /** * ELIST -> EXPR -> STRING_LITERAL * diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java index 9e83f8143c..b8c86e5bdd 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java @@ -80,10 +80,6 @@ public static Stream backward(DetailNode node) { return backward(ASTNode.javadoc(node)); } - public static Stream forward(DetailNode node) { - return forward(ASTNode.javadoc(node)); - } - public static Stream forward(DetailNode node, Predicate stopOn) { var nodes = forward(ASTNode.javadoc(node)).toList(); var result = new ArrayList(); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java index fd464f0870..59a5c536ce 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java @@ -72,26 +72,46 @@ public static String getPackageName(DetailAST node) { .orElse(List.of())); } + public static String getQualifiedName(DetailAST node) { + return tree(node.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining(".")); + } + public static String toQualifiedName(DetailAST classDef, String typeName) { - checkTypeDef(TYPES, classDef); - if (!typeName.contains(".")) { - if (!getSimpleName(classDef).equals(typeName)) { - var cu = getCompilationUnit(classDef); - return children(cu) - .filter(tokens(TokenTypes.IMPORT)) - .map( - it -> - tree(it.getFirstChild()) - .filter(tokens(TokenTypes.DOT).negate()) - .map(DetailAST::getText) - .collect(Collectors.joining("."))) - .filter(qualifiedName -> qualifiedName.endsWith("." + typeName)) - .findFirst() - .orElseGet(() -> String.join(".", getPackageName(classDef), typeName)); + return switch (typeName) { + case "char", "boolean", "int", "short", "long", "float", "double" -> typeName; + case "Character" -> Character.class.getName(); + case "Boolean" -> Boolean.class.getName(); + case "Integer" -> Integer.class.getName(); + case "Short" -> Short.class.getName(); + case "Long" -> Long.class.getName(); + case "Float" -> Float.class.getName(); + case "Double" -> Double.class.getName(); + case "String" -> String.class.getName(); + default -> { + checkTypeDef(TYPES, classDef); + if (!typeName.contains(".")) { + if (!getSimpleName(classDef).equals(typeName)) { + var cu = getCompilationUnit(classDef); + yield children(cu) + .filter(tokens(TokenTypes.IMPORT)) + .map( + it -> + tree(it.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining("."))) + .filter(qualifiedName -> qualifiedName.endsWith("." + typeName)) + .findFirst() + .orElseGet(() -> String.join(".", getPackageName(classDef), typeName)); + } + } + // Already qualified. + yield typeName; } - } - // Already qualified. - return typeName; + }; } private static void checkTypeDef(Predicate predicate, DetailAST node) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java index e30817a654..9842a1c8c3 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java @@ -20,6 +20,7 @@ public class MethodDoc extends JavaDocNode { private String operationId; private Map throwList; + private List parameterTypes = null; public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { super(ctx, node, javadoc); @@ -43,11 +44,34 @@ public void setOperationId(String operationId) { this.operationId = operationId; } - public List getParameterNames() { - return tree(node) - .filter(tokens(TokenTypes.PARAMETER_DEF)) - .map(JavaDocSupport::getSimpleName) - .toList(); + public List getParameterTypes() { + if (parameterTypes == null) { + parameterTypes = new ArrayList<>(); + var classDef = + backward(node) + .filter(JavaDocSupport.TYPES) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Class not found: " + node)); + for (var parameter : tree(node).filter(tokens(TokenTypes.PARAMETER_DEF)).toList()) { + var type = + children(parameter) + .filter(tokens(TokenTypes.TYPE)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Parameter type not found: " + + JavaDocSupport.getSimpleName(parameter))); + parameterTypes.add( + JavaDocSupport.toQualifiedName(classDef, JavaDocSupport.getQualifiedName(type))); + } + } + return parameterTypes; + } + + public MethodDoc markAsVirtual() { + parameterTypes = List.of(); + return this; } public List getJavadocParameterNames() { diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 64a4bc3d9d..14d1b4c35a 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -226,7 +226,7 @@ public void apiDoc() throws Exception { withMethod( doc, "hello", - List.of("name", "age", "list", "str"), + List.of("java.util.List", "int", "java.util.List", "java.lang.String"), method -> { assertEquals("This is the Hello /endpoint", method.getSummary()); assertEquals("Operation description", method.getDescription()); @@ -240,7 +240,7 @@ public void apiDoc() throws Exception { withMethod( doc, "search", - List.of("query"), + List.of("javadoc.input.QueryBeanDoc"), method -> { assertEquals("Search database.", method.getSummary()); assertEquals("Search DB", method.getDescription()); @@ -253,7 +253,7 @@ public void apiDoc() throws Exception { withMethod( doc, "recordBean", - List.of("query"), + List.of("javadoc.input.RecordBeanDoc"), method -> { assertEquals("Record database.", method.getSummary()); assertNull(method.getDescription()); @@ -264,7 +264,7 @@ public void apiDoc() throws Exception { withMethod( doc, "enumParam", - List.of("query"), + List.of("javadoc.input.EnumDoc"), method -> { assertEquals("Enum database.", method.getSummary()); assertEquals("Enum doc.", method.getParameterDoc("query")); @@ -295,7 +295,7 @@ public void noClassDoc() throws Exception { withMethod( doc, "hello", - List.of("name"), + List.of("java.lang.String"), methodDoc -> { assertEquals("Method Doc.", methodDoc.getSummary()); assertNull(methodDoc.getDescription()); diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java index b848e4e8bd..8a9922c1db 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -9,7 +9,7 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import javadoc.input.MultilineComment; +import javadoc.input.RecordBeanDoc; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; @@ -25,7 +25,7 @@ public static void main(String[] args) throws CheckstyleException, IOException { .resolve("java"); var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree( - baseDir.resolve(toPath(MultilineComment.class)).toFile()); + baseDir.resolve(toPath(RecordBeanDoc.class)).toFile()); System.out.println(stringAst); } From e39a80dba8cf86d31970d646f22b46bfca88618c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 6 Aug 2025 19:51:00 -0300 Subject: [PATCH 8/9] open-api: use fully qualified name to lookup for method reference --- .../io/jooby/internal/openapi/javadoc/JavaDocParser.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index 1d1dd952d1..decf48065e 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -25,6 +25,7 @@ import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.TokenTypes; import com.puppycrawl.tools.checkstyle.utils.XpathUtil; +import io.jooby.Context; import io.jooby.Router; public class JavaDocParser { @@ -213,7 +214,10 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { .filter(tokens(TokenTypes.PARAMETER_DEF)) .findFirst() .flatMap(p -> children(p).filter(tokens(TokenTypes.TYPE)).findFirst()) - .filter(type -> getQualifiedName(type).equals("Context")) + .filter( + type -> + toQualifiedName(classDoc.node, getQualifiedName(type)) + .equals(Context.class.getName())) .isPresent()) .findFirst() .orElseThrow( From 2b1fd8a1dc1df66e7de04184ae6b6f8ac5a17a8b Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 6 Aug 2025 20:51:59 -0300 Subject: [PATCH 9/9] open-api: inheritance support --- .../openapi/javadoc/JavaDocParser.java | 86 +++++++++++++------ .../test/java/javadoc/JavaDocParserTest.java | 29 ++++++- .../src/test/java/javadoc/PrintAstTree.java | 4 +- .../src/test/java/javadoc/input/Subclass.java | 36 ++++++++ .../src/test/java/javadoc/input/sub/Base.java | 32 +++++++ 5 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/Subclass.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/sub/Base.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java index decf48065e..b5a638ec00 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -24,6 +24,7 @@ import com.puppycrawl.tools.checkstyle.JavaParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import com.puppycrawl.tools.checkstyle.utils.TokenUtil; import com.puppycrawl.tools.checkstyle.utils.XpathUtil; import io.jooby.Context; import io.jooby.Router; @@ -46,26 +47,31 @@ public Optional parse(String typeName) { return ofNullable(traverse(resolveType(typeName)).get(typeName)); } - public Map traverse(DetailAST tree) { + private Map traverse(DetailAST tree) { var classes = new HashMap(); traverse( - tree, + List.of(tree), TYPES, modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_PRIVATE)), (scope, comment) -> { + var scopes = typeHierarchy(scope); var counter = new AtomicInteger(0); counter.addAndGet(comment == JavaDocNode.EMPTY_AST ? 0 : 1); var classDoc = new ClassDoc(this, scope, comment); // MVC routes traverse( - scope, + scopes, tokens(TokenTypes.VARIABLE_DEF, TokenTypes.METHOD_DEF), modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_STATIC)), (member, memberComment) -> { counter.addAndGet(memberComment == JavaDocNode.EMPTY_AST ? 0 : 1); // check member belong to current scope - if (scope == backward(member).filter(TYPES).findFirst().orElse(null)) { + if (backward(member) + .filter(TYPES) + .findFirst() + .filter(scopes::contains) + .isPresent()) { if (member.getType() == TokenTypes.VARIABLE_DEF) { classDoc.addField(new FieldDoc(this, member, memberComment)); } else { @@ -83,6 +89,53 @@ public Map traverse(DetailAST tree) { return classes; } + private void traverse( + List scopes, + Predicate types, + Predicate modifiers, + BiConsumer action) { + + for (var scope : scopes) { + for (var node : tree(scope).filter(types).toList()) { + var mods = + tree(node) + .filter(tokens(TokenTypes.MODIFIERS)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Modifiers not found on " + TokenUtil.getTokenName(node.getType()))); + if (modifiers.test(mods)) { + var docRoot = node.getType() == TokenTypes.VARIABLE_DEF ? mods.getParent() : mods; + var comment = + tree(docRoot) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(JavaDocNode.EMPTY_AST); + action.accept(node, comment); + } + } + } + } + + private List typeHierarchy(DetailAST scope) { + var result = new ArrayList(); + // call parent members first, so we can inherit and override later. + children(scope) + .filter(tokens(TokenTypes.EXTENDS_CLAUSE)) + .findFirst() + .map(it -> JavaDocSupport.toQualifiedName(scope, getQualifiedName(it))) + .flatMap( + superClass -> + tree(resolveType(superClass)) + .filter(TYPES) + .filter(it -> superClass.endsWith("." + getSimpleName(it))) + .findFirst()) + .ifPresent(parent -> result.addAll(typeHierarchy(parent))); + result.add(scope); + return result; + } + private int scripts( DetailAST scope, ClassDoc classDoc, PathDoc pathDoc, String prefix, Set visited) { var counter = new AtomicInteger(0); @@ -96,7 +149,7 @@ private int scripts( .map(DetailAST::getText) .stream() .findFirst() - .orElseThrow(() -> new IllegalStateException("No method call found: " + script)); + .orElseThrow(() -> new IllegalArgumentException("No method call found: " + script)); var scriptComment = children(script) .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) @@ -254,29 +307,6 @@ private String computePath(String prefix, String pattern) { return Router.noTrailingSlash(Router.normalizePath(prefix + pattern)); } - private void traverse( - DetailAST tree, - Predicate types, - Predicate modifiers, - BiConsumer action) { - for (var node : tree(tree).filter(types).toList()) { - var mods = - tree(node) - .filter(tokens(TokenTypes.MODIFIERS)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Modifiers not found on " + node)); - if (modifiers.test(mods)) { - var docRoot = node.getType() == TokenTypes.VARIABLE_DEF ? mods.getParent() : mods; - var comment = - tree(docRoot) - .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) - .findFirst() - .orElse(JavaDocNode.EMPTY_AST); - action.accept(node, comment); - } - } - } - public DetailAST resolve(Path path) { return lookup(path) .map( diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 14d1b4c35a..a8dd91ae0a 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -21,13 +21,38 @@ public class JavaDocParserTest { + @Test + public void inheritance() throws Exception { + withDoc( + javadoc.input.Subclass.class, + doc -> { + assertEquals("Subclass summary.", doc.getSummary()); + assertEquals("Subclass description.", doc.getDescription()); + + assertEquals("Number on subclass.", doc.getPropertyDoc("number")); + assertEquals("Name on subclass.", doc.getPropertyDoc("name")); + assertEquals("Desc on base class.", doc.getPropertyDoc("description")); + }); + + withDoc( + javadoc.input.sub.Base.class, + doc -> { + assertEquals("Base summary.", doc.getSummary()); + assertEquals("Base description.", doc.getDescription()); + + assertNull(doc.getPropertyDoc("number")); + assertEquals("Name on base class.", doc.getPropertyDoc("name")); + assertEquals("Desc on base class.", doc.getPropertyDoc("description")); + }); + } + @Test public void multiline() throws Exception { withDoc( javadoc.input.MultilineComment.class, doc -> { - // assertNull(doc.getSummary()); - // assertNull(doc.getDescription()); + assertNull(doc.getSummary()); + assertNull(doc.getDescription()); withScript( doc, diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java index 8a9922c1db..4761e372e9 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -9,7 +9,7 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import javadoc.input.RecordBeanDoc; +import javadoc.input.Subclass; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; @@ -25,7 +25,7 @@ public static void main(String[] args) throws CheckstyleException, IOException { .resolve("java"); var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree( - baseDir.resolve(toPath(RecordBeanDoc.class)).toFile()); + baseDir.resolve(toPath(Subclass.class)).toFile()); System.out.println(stringAst); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/Subclass.java b/modules/jooby-openapi/src/test/java/javadoc/input/Subclass.java new file mode 100644 index 0000000000..f169ba3a47 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/Subclass.java @@ -0,0 +1,36 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +import javadoc.input.sub.Base; + +/** + * Subclass summary. + * + *

Subclass description. + */ +public class Subclass extends Base { + private int number; + + /** + * Number on subclass. + * + * @return Number on subclass. + */ + public int getNumber() { + return number; + } + + /** + * Name on subclass. + * + * @return Name on subclass. + */ + @Override + public String getName() { + return super.getName(); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/sub/Base.java b/modules/jooby-openapi/src/test/java/javadoc/input/sub/Base.java new file mode 100644 index 0000000000..b878717e46 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/sub/Base.java @@ -0,0 +1,32 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input.sub; + +/** + * Base summary. + * + *

Base description. + */ +public class Base { + + private String name; + + /** Desc on base class. */ + private String description; + + /** + * Name on base class. + * + * @return On base class. + */ + public String getName() { + return name; + } + + public String getDescription() { + return description; + } +}