From 42c4e1a40dafb06575b811880e21335416f8a704 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 27 Jul 2025 18:19:07 -0300 Subject: [PATCH 01/17] WIP: open-api: parse javadoc - Java APT can't be used to read doc bc we use byte code analysis - Use checkstyle for getting doc - This feature will be only for Java code base (not available on Kotlin) - ref #3729 --- .gitignore | 1 - modules/jooby-openapi/pom.xml | 13 +- .../internal/openapi/javadoc/ClassDoc.java | 91 +++++++ .../internal/openapi/javadoc/JavaDocNode.java | 54 ++++ .../openapi/javadoc/JavaDocParser.java | 61 +++++ .../openapi/javadoc/JavaDocSupport.java | 249 ++++++++++++++++++ .../internal/openapi/javadoc/MethodDoc.java | 90 +++++++ .../test/java/javadoc/JavaDocParserTest.java | 88 +++++++ .../src/test/java/javadoc/JavadocPoc.java | 8 + .../src/test/java/javadoc/input/ApiDoc.java | 52 ++++ .../src/test/java/javadoc/input/NoDoc.java | 20 ++ .../test/java/javadoc/input/QueryBeanDoc.java | 37 +++ 12 files changed, 756 insertions(+), 8 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java diff --git a/.gitignore b/.gitignore index f28abc57e3..376f0eed86 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ TODO .interp tmp checkstyle -javadoc *.mv.db versions out diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index fd43b61bff..882731de9a 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -64,6 +64,12 @@ swagger-models + + com.puppycrawl.tools + checkstyle + 10.26.1 + + commons-codec commons-codec @@ -134,13 +140,6 @@ 1.17.6 test - - - com.puppycrawl.tools - checkstyle - 10.26.1 - test - 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 new file mode 100644 index 0000000000..84abb433b8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -0,0 +1,91 @@ +/* + * 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 static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; + +public class ClassDoc extends JavaDocNode { + + private final DetailAST node; + private List methods = new ArrayList<>(); + + public ClassDoc(DetailAST node, DetailAST javaDoc) { + super(javaDoc); + this.node = node; + } + + public void addMethod(MethodDoc method) { + this.methods.add(method); + } + + public String getSummary() { + var text = new StringBuilder(); + for (var node : forward(javadoc, STOP_TOKENS).toList()) { + if (node.getType() == JavadocTokenTypes.NEWLINE && !text.isEmpty()) { + break; + } else if (node.getType() == JavadocTokenTypes.TEXT) { + text.append(node.getText()); + } + } + return text.isEmpty() ? getText().trim() : text.toString().trim(); + } + + public String getDescription() { + var text = getText(); + var summary = getSummary(); + return summary.equals(text) ? "" : text.replaceAll(summary, "").trim(); + } + + public Optional getMethod(String name, List parameterNames) { + var filtered = methods.stream().filter(it -> it.getName().equals(name)).toList(); + if (filtered.isEmpty()) { + return Optional.empty(); + } + if (filtered.size() == 1) { + return Optional.of(filtered.get(0)); + } + return filtered.stream() + .filter(it -> it.getParameterNames().equals(parameterNames)) + .findFirst(); + } + + public String getSimpleName() { + return node.findFirstToken(TokenTypes.IDENT).getText(); + } + + public String getName() { + return forward(node.getParent()) + .filter(tokens(TokenTypes.PACKAGE_DEF)) + .map( + it -> + tree(it) + .filter(tokens(TokenTypes.DOT, TokenTypes.IDENT)) + .findFirst() + .orElse(null)) + .filter(Objects::nonNull) + .flatMap( + it -> + tree(it) + .filter(tokens(TokenTypes.DOT, TokenTypes.SEMI).negate()) + .map(DetailAST::getText)) + .collect(Collectors.joining(".", "", ".")) + + getSimpleName(); + } + + public List getMethods() { + return methods; + } +} 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 new file mode 100644 index 0000000000..bc6ca69967 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java @@ -0,0 +1,54 @@ +/* + * 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.List; +import java.util.Set; + +import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter; +import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser; +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; +import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; + +public class JavaDocNode { + protected final DetailNode javadoc; + protected static final Set STOP_TOKENS = Set.of(JavadocTokenTypes.JAVADOC_TAG); + + public JavaDocNode(DetailAST node) { + this.javadoc = new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); + } + + public String getText() { + return getText(JavaDocSupport.forward(javadoc, STOP_TOKENS).toList(), false); + } + + protected String getText(List nodes, boolean stripLeading) { + var builder = new StringBuilder(); + 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()); + } + } + } + return builder.toString().trim(); + } + + @Override + public String toString() { + return DetailNodeTreeStringPrinter.printTree(javadoc, "", ""); + } +} 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 new file mode 100644 index 0000000000..2f84bbdea6 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -0,0 +1,61 @@ +/* + * 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 static io.jooby.internal.openapi.javadoc.JavaDocSupport.backward; +import static io.jooby.internal.openapi.javadoc.JavaDocSupport.tokens; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +import com.puppycrawl.tools.checkstyle.JavaParser; +import com.puppycrawl.tools.checkstyle.api.CheckstyleException; +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; + +public class JavaDocParser { + + public static Optional parse(Path filePath) throws CheckstyleException, IOException { + ClassDoc result = null; + var tree = JavaParser.parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); + for (var comment : + JavaDocSupport.forward(tree).filter(tokens(TokenTypes.COMMENT_CONTENT)).toList()) { + var nodePath = path(comment); + // ensure class + if (result == null) { + result = new ClassDoc(nodePath[1], comment.getParent()); + } + if (nodePath[nodePath.length - 1] != null) { + // there is a method here + var method = new MethodDoc(nodePath[nodePath.length - 1], comment.getParent()); + result.addMethod(method); + } + } + return Optional.ofNullable(result); + } + + private static DetailAST[] path(DetailAST comment) { + var classDef = + backward(comment) + .filter( + tokens( + TokenTypes.ENUM_DEF, + TokenTypes.CLASS_DEF, + TokenTypes.INTERFACE_DEF, + TokenTypes.RECORD_DEF)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("no type found")); + var packageDef = + JavaDocSupport.forward(classDef.getParent()) + .filter(tokens(TokenTypes.PACKAGE_DEF)) + .findFirst() + .orElse(null); + var methodDef = + backward(comment).filter(tokens(TokenTypes.METHOD_DEF)).findFirst().orElse(null); + return new DetailAST[] {packageDef, classDef, methodDef}; + } +} 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 new file mode 100644 index 0000000000..d8d4de65c8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java @@ -0,0 +1,249 @@ +/* + * 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; +import com.puppycrawl.tools.checkstyle.utils.TokenUtil; + +public final class JavaDocSupport { + + public static Predicate tokens(Integer... types) { + return tokens(Set.of(types)); + } + + public static Predicate imaginary() { + return it -> it.getText().equals(TokenUtil.getTokenName(it.getType())); + } + + private static Predicate tokens(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, Set stopOn) { + var nodes = forward(ASTNode.javadoc(node)).toList(); + var result = new ArrayList(); + for (var it : nodes) { + if (stopOn.contains(it.getType())) { + 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/MethodDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java new file mode 100644 index 0000000000..0dcefb2659 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java @@ -0,0 +1,90 @@ +/* + * 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 static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; + +public class MethodDoc extends JavaDocNode { + private final DetailAST node; + + public MethodDoc(DetailAST node, DetailAST javadoc) { + super(javadoc); + this.node = node; + } + + public String getName() { + return node.findFirstToken(TokenTypes.IDENT).getText(); + } + + public List getParameterTypes() { + var result = new ArrayList(); + for (var parameterDef : tree(node).filter(tokens(TokenTypes.PARAMETER_DEF)).toList()) { + var typeNode = + tree(parameterDef) + .filter(tokens(TokenTypes.TYPE)) + .findFirst() + .orElseThrow( + () -> new IllegalStateException("Parameter type not found: " + parameterDef)); + if (typeNode.getFirstChild().getType() == TokenTypes.DOT) { + result.add(typeNode.getFirstChild().getLastChild().getText()); + } else { + result.add(typeNode.getFirstChild().getText()); + } + } + return result; + } + + 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; + } + + public String getParameterDoc(String name) { + return tree(javadoc) + // must be a tag + .filter(it -> it.getType() == JavadocTokenTypes.JAVADOC_TAG) + .filter( + it -> { + var children = children(it).toList(); + return children.stream() + .anyMatch( + t -> + t.getType() == JavadocTokenTypes.PARAM_LITERAL + && t.getText().equals("@param")) + && children.stream().anyMatch(t -> t.getText().equals(name)); + }) + .findFirst() + .map( + it -> + getText( + Stream.of(it.getChildren()) + .filter(e -> e.getType() == JavadocTokenTypes.DESCRIPTION) + .flatMap(JavaDocSupport::tree) + .toList(), + true)) + .filter(it -> !it.isEmpty()) + .orElse(null); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java new file mode 100644 index 0000000000..b7ffb7fb2f --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -0,0 +1,88 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; +import com.puppycrawl.tools.checkstyle.JavaParser; +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.javadoc.ClassDoc; +import io.jooby.internal.openapi.javadoc.JavaDocParser; + +public class JavaDocParserTest { + + @Test + public void apiDoc() throws Exception { + withDoc( + baseDir().resolve("ApiDoc.java"), + doc -> { + assertEquals("ApiDoc", doc.getSimpleName()); + assertEquals("javadoc.input.ApiDoc", doc.getName()); + assertEquals("Api summary.", doc.getSummary()); + assertEquals( + "Proin sit amet lectus interdum, porta libero quis, fringilla metus. Integer viverra" + + " ante id vestibulum congue. Nam et tortor at magna tempor congue.", + doc.getDescription()); + // throw new UnsupportedOperationException(); + var methods = doc.getMethods(); + assertEquals(2, methods.size()); + assertEquals("hello", methods.get(0).getName()); + assertEquals(List.of("name", "age", "list", "str"), methods.get(0).getParameterNames()); + assertEquals( + List.of("List", "int", "List", "String"), methods.get(0).getParameterTypes()); + // + var method = doc.getMethod("hello", List.of("List", "int", "List", "String")); + assertTrue(method.isPresent()); + assertEquals("This is the Hello /endpoint.", method.get().getText()); + assertEquals("Person name.", method.get().getParameterDoc("name")); + assertEquals("Person age.", method.get().getParameterDoc("age")); + assertEquals("This line has a break.", method.get().getParameterDoc("list")); + assertEquals("Some string.", method.get().getParameterDoc("str")); + + // TODO: continue here + var search = doc.getMethod("search", List.of("QueryBeanDoc")); + assertTrue(search.isPresent()); + assertEquals("This is the Hello /endpoint.", search.get().getText()); + }); + } + + @Test + public void noDoc() throws Exception { + var result = JavaDocParser.parse(baseDir().resolve("NoDoc.java")); + assertTrue(result.isEmpty()); + } + + private Path baseDir() { + var baseDir = Paths.get(System.getProperty("user.dir")); + return baseDir + .resolve("src") + .resolve("test") + .resolve("java") + .resolve("javadoc") + .resolve("input"); + } + + private void withDoc(Path path, Consumer consumer) throws Exception { + try { + var result = JavaDocParser.parse(path); + assertFalse(result.isEmpty()); + consumer.accept(result.get()); + } catch (Throwable cause) { + var stringAst = + AstTreeStringPrinter.printFileAst(path.toFile(), JavaParser.Options.WITH_COMMENTS); + cause.addSuppressed(new RuntimeException("\n" + stringAst)); + throw SneakyThrows.propagate(cause); + } + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.java b/modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.java new file mode 100644 index 0000000000..fc9747c1cd --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc; + +public class JavadocPoc {} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java new file mode 100644 index 0000000000..49466cc65d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -0,0 +1,52 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.QueryParam; + +/** + * Api summary. + * + *

Proin sit amet lectus interdum, porta libero quis, fringilla metus. Integer viverra ante id + * vestibulum congue. Nam et tortor at magna tempor congue. + */ +@Path("/api") +public class ApiDoc { + + /** + * This is the Hello /endpoint. + * + * @param name Person name. + * @param age Person age. + * @param list This line has a break. + * @param str Some string. + * @return Say hello. + */ + @NonNull @GET + public String hello( + @QueryParam List> name, + @QueryParam int age, + @QueryParam List list, + @QueryParam java.lang.String str) { + return "hello"; + } + + /** + * Search database. + * + * @param query + * @return + */ + @GET + public String search(@QueryParam QueryBeanDoc query) { + return "hello"; + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java new file mode 100644 index 0000000000..acc547d244 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.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; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.QueryParam; + +@Path("/api") +public class NoDoc { + + @NonNull @GET + public String hello(@QueryParam String name) { + return "hello"; + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java new file mode 100644 index 0000000000..371883e36a --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java @@ -0,0 +1,37 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +/** Search options. */ +public class QueryBeanDoc { + private String fq; + private int offset; + private int limit; + + public String getFq() { + return fq; + } + + public void setFq(String fq) { + this.fq = fq; + } + + public int getOffset() { + return offset; + } + + public void setOffset(int offset) { + this.offset = offset; + } + + public int getLimit() { + return limit; + } + + public void setLimit(int limit) { + this.limit = limit; + } +} From 7009f99260d1eda2b6b95b7a1ab97e59d4badf1c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 28 Jul 2025 10:53:54 -0300 Subject: [PATCH 02/17] openapi: parse javadoc - Parse query string from JavaBean - ref #3729 --- .../jooby/internal/openapi/ParameterExt.java | 11 ++ .../jooby/internal/openapi/RouteParser.java | 1 + .../internal/openapi/javadoc/ClassDoc.java | 4 +- .../openapi/javadoc/JavaDocContext.java | 117 ++++++++++++++++++ .../internal/openapi/javadoc/JavaDocNode.java | 16 ++- .../openapi/javadoc/JavaDocParser.java | 28 +++-- .../internal/openapi/javadoc/MethodDoc.java | 88 ++++++++++++- .../test/java/javadoc/JavaDocParserTest.java | 32 +++-- .../src/test/java/javadoc/PrinteAstTree.java | 25 ++++ .../test/java/javadoc/input/QueryBeanDoc.java | 23 +++- 10 files changed, 312 insertions(+), 33 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java index 16c05e9a1e..ac2b7dbf58 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java @@ -10,6 +10,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore; public class ParameterExt extends io.swagger.v3.oas.models.parameters.Parameter { + /* keep track of expanded query bean parameters. */ + @JsonIgnore private String containerType; + @JsonIgnore private String javaType; @JsonIgnore private Object defaultValue; @@ -24,6 +27,14 @@ public String getJavaType() { return javaType; } + public void setContainerType(String containerType) { + this.containerType = containerType; + } + + public String getContainerType() { + return containerType; + } + public Object getDefaultValue() { if (defaultValue != null) { if (javaType.equals(boolean.class.getName())) { 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 4caad4f0ff..b1cfd467e1 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 @@ -189,6 +189,7 @@ private List checkParameters(ParserContext ctx, List param String name = (String) ((Map.Entry) e).getKey(); Schema s = (Schema) ((Map.Entry) e).getValue(); ParameterExt p = new ParameterExt(); + p.setContainerType(javaType); p.setName(name); p.setIn(parameter.getIn()); p.setSchema(s); 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 84abb433b8..e89d7c43d8 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 @@ -22,8 +22,8 @@ public class ClassDoc extends JavaDocNode { private final DetailAST node; private List methods = new ArrayList<>(); - public ClassDoc(DetailAST node, DetailAST javaDoc) { - super(javaDoc); + public ClassDoc(JavaDocContext ctx, DetailAST node, DetailAST javaDoc) { + super(ctx, javaDoc); this.node = node; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java new file mode 100644 index 0000000000..0778633466 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java @@ -0,0 +1,117 @@ +/* + * 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 static com.puppycrawl.tools.checkstyle.JavaParser.parseFile; +import static io.jooby.SneakyThrows.throwingFunction; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import com.puppycrawl.tools.checkstyle.JavaParser; +import com.puppycrawl.tools.checkstyle.api.DetailAST; + +public class JavaDocContext { + private final Path baseDir; + private final Map cache = new HashMap<>(); + + public JavaDocContext(Path baseDir) { + this.baseDir = baseDir; + } + + public DetailAST resolve(Path path) { + return cache.computeIfAbsent( + baseDir.resolve(path), + throwingFunction( + filePath -> { + if (Files.exists(filePath)) { + return parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); + } else { + return NULL; + } + })); + } + + public static final DetailAST NULL = + new DetailAST() { + @Override + public int getChildCount() { + return 0; + } + + @Override + public int getChildCount(int type) { + return 0; + } + + @Override + public DetailAST getParent() { + return null; + } + + @Override + public String getText() { + return ""; + } + + @Override + public int getType() { + return 0; + } + + @Override + public int getLineNo() { + return 0; + } + + @Override + public int getColumnNo() { + return 0; + } + + @Override + public DetailAST getLastChild() { + return null; + } + + @Override + public boolean branchContains(int type) { + return false; + } + + @Override + public DetailAST getPreviousSibling() { + return null; + } + + @Override + public DetailAST findFirstToken(int type) { + return null; + } + + @Override + public DetailAST getNextSibling() { + return null; + } + + @Override + public DetailAST getFirstChild() { + return null; + } + + @Override + public int getNumberOfChildren() { + return 0; + } + + @Override + public boolean hasChildren() { + return false; + } + }; +} 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 bc6ca69967..8a3bdff306 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 @@ -16,11 +16,17 @@ import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; public class JavaDocNode { + protected final JavaDocContext context; protected final DetailNode javadoc; protected static final Set STOP_TOKENS = Set.of(JavadocTokenTypes.JAVADOC_TAG); - public JavaDocNode(DetailAST node) { - this.javadoc = new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); + public JavaDocNode(JavaDocContext ctx, DetailAST node) { + this.context = ctx; + this.javadoc = toJavaDocNode(node); + } + + private static DetailNode toJavaDocNode(DetailAST node) { + return new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); } public String getText() { @@ -49,6 +55,10 @@ protected String getText(List nodes, boolean stripLeading) { @Override public String toString() { - return DetailNodeTreeStringPrinter.printTree(javadoc, "", ""); + return toString(javadoc); + } + + protected 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 2f84bbdea6..5f1b44e968 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 @@ -5,33 +5,39 @@ */ package io.jooby.internal.openapi.javadoc; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.backward; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.tokens; +import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import java.io.IOException; import java.nio.file.Path; import java.util.Optional; +import java.util.function.Predicate; -import com.puppycrawl.tools.checkstyle.JavaParser; -import com.puppycrawl.tools.checkstyle.api.CheckstyleException; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.TokenTypes; public class JavaDocParser { - public static Optional parse(Path filePath) throws CheckstyleException, IOException { + private static final Predicate HAS_CLASS = + it -> backward(it).anyMatch(tokens(TokenTypes.CLASS_DEF)); + + private final JavaDocContext context; + + public JavaDocParser(JavaDocContext context) { + this.context = context; + } + + public Optional parse(Path filePath) throws Exception { ClassDoc result = null; - var tree = JavaParser.parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); + var tree = context.resolve(filePath); for (var comment : - JavaDocSupport.forward(tree).filter(tokens(TokenTypes.COMMENT_CONTENT)).toList()) { + forward(tree).filter(tokens(TokenTypes.COMMENT_CONTENT)).filter(HAS_CLASS).toList()) { var nodePath = path(comment); // ensure class if (result == null) { - result = new ClassDoc(nodePath[1], comment.getParent()); + result = new ClassDoc(context, nodePath[1], comment.getParent()); } if (nodePath[nodePath.length - 1] != null) { // there is a method here - var method = new MethodDoc(nodePath[nodePath.length - 1], comment.getParent()); + var method = new MethodDoc(context, nodePath[nodePath.length - 1], comment.getParent()); result.addMethod(method); } } @@ -50,7 +56,7 @@ private static DetailAST[] path(DetailAST comment) { .findFirst() .orElseThrow(() -> new IllegalStateException("no type found")); var packageDef = - JavaDocSupport.forward(classDef.getParent()) + forward(classDef.getParent()) .filter(tokens(TokenTypes.PACKAGE_DEF)) .findFirst() .orElse(null); 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 0dcefb2659..782602b2d4 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 @@ -7,19 +7,23 @@ import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; public class MethodDoc extends JavaDocNode { private final DetailAST node; - public MethodDoc(DetailAST node, DetailAST javadoc) { - super(javadoc); + public MethodDoc(JavaDocContext ctx, DetailAST node, DetailAST javadoc) { + super(ctx, javadoc); this.node = node; } @@ -62,6 +66,18 @@ public List getParameterNames() { } public String getParameterDoc(String name) { + return getParameterDoc(name, null); + } + + public String getParameterDoc(String name, String in) { + DetailNode javadoc = this.javadoc; + if (in != null) { + var tree = context.resolve(toJavaPath(in)); + if (tree == JavaDocContext.NULL) { + return null; + } + return getPropertyDoc(tree, name); + } return tree(javadoc) // must be a tag .filter(it -> it.getType() == JavadocTokenTypes.JAVADOC_TAG) @@ -87,4 +103,72 @@ public String getParameterDoc(String name) { .filter(it -> !it.isEmpty()) .orElse(null); } + + private Path toJavaPath(String in) { + var segments = in.split("\\."); + segments[segments.length - 1] = segments[segments.length - 1] + ".java"; + return Paths.get(String.join(File.separator, segments)); + } + + private String getPropertyDoc(DetailAST bean, String name) { + var comment = commentFromGetter(bean, name); + if (comment == null) { + comment = commentFromField(bean, name); + } + return comment == null ? null : new JavaDocNode(context, comment).getText(); + } + + private DetailAST commentFromGetter(DetailAST bean, String name) { + var methods = JavaDocSupport.forward(bean).filter(tokens(TokenTypes.METHOD_DEF)).toList(); + var getterName = "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1); + for (var method : methods) { + var noArgs = tree(method).noneMatch(tokens(TokenTypes.PARAMETER_DEF)); + var isPublic = tree(method).anyMatch(tokens(TokenTypes.LITERAL_PUBLIC)); + var methodName = + children(method) + .filter(tokens(TokenTypes.IDENT)) + .map(DetailAST::getText) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Method name not found")); + if (noArgs && isPublic && (methodName.equals(getterName) || methodName.equals(name))) { + var comment = commentFromMember(method); + if (comment != null) { + return comment; + } + } + } + return null; + } + + private DetailAST commentFromField(DetailAST bean, String name) { + for (var field : + JavaDocSupport.forward(bean).filter(tokens(TokenTypes.VARIABLE_DEF)).toList()) { + var isInstance = tree(field).noneMatch(tokens(TokenTypes.LITERAL_STATIC)); + var fieldName = + children(field) + .filter(tokens(TokenTypes.IDENT)) + .map(DetailAST::getText) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Field name not found")); + if (isInstance && fieldName.equals(name)) { + var comment = commentFromMember(field); + if (comment != null) { + return comment; + } + } + } + return null; + } + + private static DetailAST commentFromMember(DetailAST member) { + var modifiers = tree(member).filter(tokens(TokenTypes.MODIFIERS)).findFirst().orElse(null); + if (modifiers != null) { + var comment = + tree(modifiers).filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)).findFirst().orElse(null); + if (comment != null) { + return comment; + } + } + return null; + } } diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index b7ffb7fb2f..7f8b6f1fb3 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -18,6 +18,7 @@ import com.puppycrawl.tools.checkstyle.JavaParser; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.javadoc.ClassDoc; +import io.jooby.internal.openapi.javadoc.JavaDocContext; import io.jooby.internal.openapi.javadoc.JavaDocParser; public class JavaDocParserTest { @@ -25,7 +26,7 @@ public class JavaDocParserTest { @Test public void apiDoc() throws Exception { withDoc( - baseDir().resolve("ApiDoc.java"), + Paths.get("javadoc", "input", "ApiDoc.java"), doc -> { assertEquals("ApiDoc", doc.getSimpleName()); assertEquals("javadoc.input.ApiDoc", doc.getName()); @@ -50,37 +51,42 @@ public void apiDoc() throws Exception { assertEquals("This line has a break.", method.get().getParameterDoc("list")); assertEquals("Some string.", method.get().getParameterDoc("str")); - // TODO: continue here var search = doc.getMethod("search", List.of("QueryBeanDoc")); assertTrue(search.isPresent()); - assertEquals("This is the Hello /endpoint.", search.get().getText()); + assertEquals("Search database.", search.get().getText()); + assertEquals( + "Filter query. Works like internal filter.", + search.get().getParameterDoc("fq", "javadoc.input.QueryBeanDoc")); + assertEquals( + "Offset, used for paging.", + search.get().getParameterDoc("offset", "javadoc.input.QueryBeanDoc")); + assertNull(search.get().getParameterDoc("limit", "javadoc.input.QueryBeanDoc")); }); } @Test public void noDoc() throws Exception { - var result = JavaDocParser.parse(baseDir().resolve("NoDoc.java")); + var result = newParser().parse(Paths.get("javadoc", "input", "NoDoc.java")); assertTrue(result.isEmpty()); } + private JavaDocParser newParser() { + return new JavaDocParser(new JavaDocContext(baseDir())); + } + private Path baseDir() { - var baseDir = Paths.get(System.getProperty("user.dir")); - return baseDir - .resolve("src") - .resolve("test") - .resolve("java") - .resolve("javadoc") - .resolve("input"); + return Paths.get(System.getProperty("user.dir")).resolve("src").resolve("test").resolve("java"); } private void withDoc(Path path, Consumer consumer) throws Exception { try { - var result = JavaDocParser.parse(path); + var result = newParser().parse(path); assertFalse(result.isEmpty()); consumer.accept(result.get()); } catch (Throwable cause) { var stringAst = - AstTreeStringPrinter.printFileAst(path.toFile(), JavaParser.Options.WITH_COMMENTS); + AstTreeStringPrinter.printFileAst( + baseDir().resolve(path).toFile(), JavaParser.Options.WITH_COMMENTS); cause.addSuppressed(new RuntimeException("\n" + stringAst)); throw SneakyThrows.propagate(cause); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java new file mode 100644 index 0000000000..f422d58a61 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java @@ -0,0 +1,25 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc; + +import java.io.IOException; +import java.nio.file.Paths; + +import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; +import com.puppycrawl.tools.checkstyle.JavaParser; +import com.puppycrawl.tools.checkstyle.api.CheckstyleException; + +public class PrinteAstTree { + public static void main(String[] args) throws CheckstyleException, IOException { + var baseDir = + Paths.get(System.getProperty("user.dir")).resolve("modules").resolve("jooby-openapi"); + var input = Paths.get("src", "test", "java", "javadoc", "input", "QueryBeanDoc.java"); + var stringAst = + AstTreeStringPrinter.printFileAst( + baseDir.resolve(input).toFile(), JavaParser.Options.WITH_COMMENTS); + System.out.println(stringAst); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java index 371883e36a..ece28f5518 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java @@ -5,13 +5,32 @@ */ package javadoc.input; +import edu.umd.cs.findbugs.annotations.NonNull; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; + /** Search options. */ public class QueryBeanDoc { + public static final int DEFAULT_OFFSET = 0; + + /** This comment will be ignored. */ private String fq; - private int offset; + + /** Offset, used for paging. */ + @Min(0) + // Something + private int offset = DEFAULT_OFFSET; + private int limit; - public String getFq() { + // Odd position of annotations + @NotEmpty + /** + * Filter query. Works like internal filter. + * + * @return Filter query. Works like internal filter. + */ + @NonNull public String getFq() { return fq; } From 6606ca2cde3ac4b4e0fa4e40731a0b8d5be1b84a Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 28 Jul 2025 14:50:22 -0300 Subject: [PATCH 03/17] openapi: clean up - remove unused `META-INF` scanning, we dont generate that anymore - ref #2403 --- .../main/java/io/jooby/maven/OpenAPIMojo.java | 10 +--- .../jooby/internal/openapi/RouteParser.java | 39 ------------- .../io/jooby/openapi/OpenAPIGenerator.java | 18 +----- .../io/jooby/openapi/OpenAPIExtension.java | 9 +-- .../src/test/java/issues/i2403/App2403.java | 16 ------ .../java/issues/i2403/Controller2403.java | 18 ------ .../java/issues/i2403/Controller2403Copy.java | 18 ------ .../src/test/java/issues/i2403/Issue2403.java | 55 ------------------- 8 files changed, 3 insertions(+), 180 deletions(-) delete mode 100644 modules/jooby-openapi/src/test/java/issues/i2403/App2403.java delete mode 100644 modules/jooby-openapi/src/test/java/issues/i2403/Controller2403.java delete mode 100644 modules/jooby-openapi/src/test/java/issues/i2403/Controller2403Copy.java delete mode 100644 modules/jooby-openapi/src/test/java/issues/i2403/Issue2403.java diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java index 2cbe5aafff..217b86ae01 100644 --- a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java @@ -50,20 +50,12 @@ protected void doExecute(@NonNull List projects, @NonNull String m ClassLoader classLoader = createClassLoader(projects); Path outputDir = Paths.get(project.getBuild().getOutputDirectory()); // Reduce lookup to current project: See https://github.com/jooby-project/jooby/issues/2756 - String metaInf = - outputDir - .resolve("META-INF") - .resolve("services") - .resolve("io.jooby.MvcFactory") - .toAbsolutePath() - .toString(); getLog().info("Generating OpenAPI: " + mainClass); getLog().debug("Using classloader: " + classLoader); getLog().debug("Output directory: " + outputDir); - getLog().debug("META-INF: " + metaInf); - OpenAPIGenerator tool = new OpenAPIGenerator(metaInf); + OpenAPIGenerator tool = new OpenAPIGenerator(); tool.setClassLoader(classLoader); tool.setOutputDir(outputDir); trim(includes).ifPresent(tool::setIncludes); 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 b1cfd467e1..e32aa4ae5c 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 @@ -17,15 +17,12 @@ import static io.jooby.internal.openapi.TypeFactory.STRING_ARRAY; import static org.objectweb.asm.Opcodes.GETSTATIC; -import java.io.IOException; import java.lang.reflect.Modifier; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -59,23 +56,9 @@ public class RouteParser { - private String metaInf; - - public RouteParser(String metaInf) { - this.metaInf = metaInf; - } - public List parse(ParserContext ctx, OpenAPIExt openapi) { List operations = parse(ctx, null, ctx.classNode(ctx.getRouter())); - // Checkout controllers without explicit mapping, just META-INF - Set controllers = - operations.stream() - .map(OperationExt::getControllerName) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - operations.addAll(metaInf(ctx, null, name -> !controllers.contains(name))); - operations.addAll(parseManuallyRegisteredControllers(ctx)); String applicationName = @@ -280,28 +263,6 @@ public List parse(ParserContext ctx, String prefix, ClassNode node return handlerList; } - private List metaInf( - ParserContext ctx, String prefix, Predicate predicate) { - // META-INF (Spring or similar) - try { - String content = new String(ctx.loadResource(metaInf), StandardCharsets.UTF_8); - String[] lines = content.split("\\n"); - List handlerList = new ArrayList<>(); - for (String line : lines) { - String controller = line.replace("$Module", "").trim(); - if (!controller.isEmpty()) { - Type type = TypeFactory.fromJavaName(controller); - if (predicate.test(type.getInternalName())) { - handlerList.addAll(AnnotationParser.parse(ctx, prefix, type)); - } - } - } - return handlerList; - } catch (IOException ex) { - return Collections.emptyList(); - } - } - private List parseManuallyRegisteredControllers(ParserContext ctx) { List handlerList = new ArrayList<>(); ClassNode classNode = ctx.classNode(ctx.getRouter()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 99ebc1670c..c58e2a5372 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -100,22 +100,6 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { private String excludes; - private String metaInf; - - /** - * Test Only. - * - * @param metaInf Location of meta-inf directory. - */ - public OpenAPIGenerator(String metaInf) { - this.metaInf = metaInf; - } - - /** Creates a new instance. */ - public OpenAPIGenerator() { - this("META-INF/services/io.jooby.MvcFactory"); - } - /** * Export an {@link OpenAPI} model to the given format. * @@ -169,7 +153,7 @@ public OpenAPIGenerator() { OpenAPIExt openapi = OpenApiTemplate.fromTemplate(basedir, classLoader, templateName).orElseGet(OpenAPIExt::new); - RouteParser routes = new RouteParser(metaInf); + RouteParser routes = new RouteParser(); ParserContext ctx = new ParserContext(source, TypeFactory.fromJavaName(classname), debug); List operations = routes.parse(ctx, openapi); diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java index 659b1cab4a..c182f05309 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java @@ -82,14 +82,7 @@ public void afterEach(ExtensionContext ctx) { } private OpenAPIGenerator newTool(Set debug, Class klass) { - String metaInf = - Optional.ofNullable(klass.getPackage()) - .map(Package::getName) - .map(name -> name.replace(".", "/") + "/") - .orElse("") - + klass.getSimpleName(); - - OpenAPIGenerator tool = new OpenAPIGenerator(metaInf); + OpenAPIGenerator tool = new OpenAPIGenerator(); tool.setDebug(debug); return tool; } diff --git a/modules/jooby-openapi/src/test/java/issues/i2403/App2403.java b/modules/jooby-openapi/src/test/java/issues/i2403/App2403.java deleted file mode 100644 index f3342f4fc3..0000000000 --- a/modules/jooby-openapi/src/test/java/issues/i2403/App2403.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package issues.i2403; - -import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; - -import io.jooby.Jooby; - -public class App2403 extends Jooby { - { - mvc(toMvcExtension(Controller2403Copy.class)); - } -} diff --git a/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403.java b/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403.java deleted file mode 100644 index f722181443..0000000000 --- a/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package issues.i2403; - -import io.jooby.annotation.GET; -import io.jooby.annotation.Path; -import io.jooby.annotation.QueryParam; - -public class Controller2403 { - @GET - @Path("me") - public String me(@QueryParam String user) { - return user; - } -} diff --git a/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403Copy.java b/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403Copy.java deleted file mode 100644 index 4dd2ba08ef..0000000000 --- a/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403Copy.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package issues.i2403; - -import io.jooby.annotation.GET; -import io.jooby.annotation.Path; -import io.jooby.annotation.QueryParam; - -public class Controller2403Copy { - @GET - @Path("copy") - public String copy(@QueryParam String user) { - return user; - } -} diff --git a/modules/jooby-openapi/src/test/java/issues/i2403/Issue2403.java b/modules/jooby-openapi/src/test/java/issues/i2403/Issue2403.java deleted file mode 100644 index 28ec791edd..0000000000 --- a/modules/jooby-openapi/src/test/java/issues/i2403/Issue2403.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package issues.i2403; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.jooby.openapi.OpenAPIResult; -import io.jooby.openapi.OpenAPITest; - -public class Issue2403 { - @OpenAPITest(value = App2403.class) - public void shouldParseMetaInf(OpenAPIResult result) { - assertEquals( - "openapi: 3.0.1\n" - + "info:\n" - + " title: 2403 API\n" - + " description: 2403 API description\n" - + " version: \"1.0\"\n" - + "paths:\n" - + " /copy:\n" - + " get:\n" - + " operationId: copy\n" - + " parameters:\n" - + " - name: user\n" - + " in: query\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: Success\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " type: string\n" - + " /me:\n" - + " get:\n" - + " operationId: me\n" - + " parameters:\n" - + " - name: user\n" - + " in: query\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: Success\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " type: string\n", - result.toYaml()); - } -} From 6ff90e25603151480b46c2ce41e16ed863f5e85f Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 28 Jul 2025 14:51:27 -0300 Subject: [PATCH 04/17] openapi: parse javadoc from enum/record --- .../internal/openapi/javadoc/JavaDocNode.java | 4 +- .../openapi/javadoc/JavaDocSupport.java | 11 ++- .../internal/openapi/javadoc/MethodDoc.java | 84 +++++++++++++++---- .../test/java/javadoc/JavaDocParserTest.java | 83 ++++++++++++------ .../src/test/java/javadoc/PrinteAstTree.java | 7 +- .../src/test/java/javadoc/input/ApiDoc.java | 25 +++++- .../src/test/java/javadoc/input/EnumDoc.java | 15 ++++ .../java/javadoc/input/RecordBeanDoc.java | 16 ++++ 8 files changed, 193 insertions(+), 52 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java 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 8a3bdff306..250afe682b 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 @@ -25,7 +25,7 @@ public JavaDocNode(JavaDocContext ctx, DetailAST node) { this.javadoc = toJavaDocNode(node); } - private static DetailNode toJavaDocNode(DetailAST node) { + static DetailNode toJavaDocNode(DetailAST node) { return new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); } @@ -50,7 +50,7 @@ protected String getText(List nodes, boolean stripLeading) { } } } - return builder.toString().trim(); + return builder.isEmpty() ? null : builder.toString().trim(); } @Override 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 d8d4de65c8..77491bf76f 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 @@ -14,7 +14,6 @@ import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; -import com.puppycrawl.tools.checkstyle.utils.TokenUtil; public final class JavaDocSupport { @@ -22,11 +21,15 @@ public static Predicate tokens(Integer... types) { return tokens(Set.of(types)); } - public static Predicate imaginary() { - return it -> it.getText().equals(TokenUtil.getTokenName(it.getType())); + private static Predicate tokens(Set types) { + return it -> types.contains(it.getType()); } - private static Predicate tokens(Set types) { + public static Predicate javadocToken(Integer... types) { + return javadocToken(Set.of(types)); + } + + private static Predicate javadocToken(Set types) { return it -> types.contains(it.getType()); } 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 782602b2d4..3d040d25fc 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 @@ -12,10 +12,10 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; @@ -70,7 +70,6 @@ public String getParameterDoc(String name) { } public String getParameterDoc(String name, String in) { - DetailNode javadoc = this.javadoc; if (in != null) { var tree = context.resolve(toJavaPath(in)); if (tree == JavaDocContext.NULL) { @@ -104,6 +103,19 @@ public String getParameterDoc(String name, String in) { .orElse(null); } + public String getReturnDoc() { + return tree(javadoc) + .filter(javadocToken(JavadocTokenTypes.RETURN_LITERAL)) + .findFirst() + .flatMap( + it -> + tree(it.getParent()) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst()) + .map(it -> getText(tree(it).toList(), true)) + .orElse(null); + } + private Path toJavaPath(String in) { var segments = in.split("\\."); segments[segments.length - 1] = segments[segments.length - 1] + ".java"; @@ -111,14 +123,53 @@ private Path toJavaPath(String in) { } private String getPropertyDoc(DetailAST bean, String name) { - var comment = commentFromGetter(bean, name); - if (comment == null) { - comment = commentFromField(bean, name); + String comment; + var isRecord = tree(bean).anyMatch(tokens(TokenTypes.RECORD_DEF)); + if (isRecord) { + comment = commentFromRecord(bean, name); + } else { + comment = commentFromGetter(bean, name); + if (comment == null) { + comment = commentFromField(bean, name); + } } - return comment == null ? null : new JavaDocNode(context, comment).getText(); + return comment; } - private DetailAST commentFromGetter(DetailAST bean, String name) { + private String commentFromRecord(DetailAST bean, String name) { + var commentNode = + tree(bean) + .filter(tokens(TokenTypes.RECORD_DEF)) + .findFirst() + .flatMap(it -> Optional.ofNullable(commentFromMember(it))) + .map(JavaDocNode::toJavaDocNode) + .orElse(null); + if (commentNode == null) { + return null; + } + + for (var tag : tree(commentNode).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { + var isParam = tree(tag).anyMatch(javadocToken(JavadocTokenTypes.PARAM_LITERAL)); + var matchesName = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.PARAMETER_NAME)) + .findFirst() + .filter(it -> it.getText().equals(name)) + .isPresent(); + if (isParam && matchesName) { + return getText( + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .flatMap(it -> Stream.of(it.getChildren())) + .toList(), + true); + } + } + + return null; + } + + private String commentFromGetter(DetailAST bean, String name) { var methods = JavaDocSupport.forward(bean).filter(tokens(TokenTypes.METHOD_DEF)).toList(); var getterName = "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1); for (var method : methods) { @@ -133,14 +184,18 @@ private DetailAST commentFromGetter(DetailAST bean, String name) { if (noArgs && isPublic && (methodName.equals(getterName) || methodName.equals(name))) { var comment = commentFromMember(method); if (comment != null) { - return comment; + return textFromComment(comment); } } } return null; } - private DetailAST commentFromField(DetailAST bean, String name) { + private String textFromComment(DetailAST comment) { + return comment == null ? null : new JavaDocNode(context, comment).getText(); + } + + private String commentFromField(DetailAST bean, String name) { for (var field : JavaDocSupport.forward(bean).filter(tokens(TokenTypes.VARIABLE_DEF)).toList()) { var isInstance = tree(field).noneMatch(tokens(TokenTypes.LITERAL_STATIC)); @@ -153,7 +208,7 @@ private DetailAST commentFromField(DetailAST bean, String name) { if (isInstance && fieldName.equals(name)) { var comment = commentFromMember(field); if (comment != null) { - return comment; + return textFromComment(comment); } } } @@ -163,11 +218,10 @@ private DetailAST commentFromField(DetailAST bean, String name) { private static DetailAST commentFromMember(DetailAST member) { var modifiers = tree(member).filter(tokens(TokenTypes.MODIFIERS)).findFirst().orElse(null); if (modifiers != null) { - var comment = - tree(modifiers).filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)).findFirst().orElse(null); - if (comment != null) { - return comment; - } + return tree(modifiers) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(null); } return null; } diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 7f8b6f1fb3..e41a475de3 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -20,6 +20,7 @@ import io.jooby.internal.openapi.javadoc.ClassDoc; import io.jooby.internal.openapi.javadoc.JavaDocContext; import io.jooby.internal.openapi.javadoc.JavaDocParser; +import io.jooby.internal.openapi.javadoc.MethodDoc; public class JavaDocParserTest { @@ -35,32 +36,57 @@ public void apiDoc() throws Exception { "Proin sit amet lectus interdum, porta libero quis, fringilla metus. Integer viverra" + " ante id vestibulum congue. Nam et tortor at magna tempor congue.", doc.getDescription()); - // throw new UnsupportedOperationException(); - var methods = doc.getMethods(); - assertEquals(2, methods.size()); - assertEquals("hello", methods.get(0).getName()); - assertEquals(List.of("name", "age", "list", "str"), methods.get(0).getParameterNames()); - assertEquals( - List.of("List", "int", "List", "String"), methods.get(0).getParameterTypes()); - // - var method = doc.getMethod("hello", List.of("List", "int", "List", "String")); - assertTrue(method.isPresent()); - assertEquals("This is the Hello /endpoint.", method.get().getText()); - assertEquals("Person name.", method.get().getParameterDoc("name")); - assertEquals("Person age.", method.get().getParameterDoc("age")); - assertEquals("This line has a break.", method.get().getParameterDoc("list")); - assertEquals("Some string.", method.get().getParameterDoc("str")); - var search = doc.getMethod("search", List.of("QueryBeanDoc")); - assertTrue(search.isPresent()); - assertEquals("Search database.", search.get().getText()); - assertEquals( - "Filter query. Works like internal filter.", - search.get().getParameterDoc("fq", "javadoc.input.QueryBeanDoc")); - assertEquals( - "Offset, used for paging.", - search.get().getParameterDoc("offset", "javadoc.input.QueryBeanDoc")); - assertNull(search.get().getParameterDoc("limit", "javadoc.input.QueryBeanDoc")); + withMethod( + doc, + "hello", + List.of("List", "int", "List", "String"), + method -> { + assertEquals("This is the Hello /endpoint.", method.getText()); + assertEquals("Person name.", method.getParameterDoc("name")); + assertEquals("Person age.", method.getParameterDoc("age")); + assertEquals("This line has a break.", method.getParameterDoc("list")); + assertEquals("Some string.", method.getParameterDoc("str")); + assertEquals("Welcome message 200.", method.getReturnDoc()); + }); + + withMethod( + doc, + "search", + List.of("QueryBeanDoc"), + method -> { + assertEquals("Search database.", method.getText()); + assertEquals( + "Filter query. Works like internal filter.", + method.getParameterDoc("fq", "javadoc.input.QueryBeanDoc")); + assertEquals( + "Offset, used for paging.", + method.getParameterDoc("offset", "javadoc.input.QueryBeanDoc")); + assertNull(method.getParameterDoc("limit", "javadoc.input.QueryBeanDoc")); + assertNull(method.getReturnDoc()); + }); + + withMethod( + doc, + "recordBean", + List.of("RecordBeanDoc"), + method -> { + assertEquals("Record database.", method.getText()); + assertEquals( + "Person id.", method.getParameterDoc("id", "javadoc.input.RecordBeanDoc")); + assertEquals( + "Person name. Example: edgar.", + method.getParameterDoc("name", "javadoc.input.RecordBeanDoc")); + }); + + withMethod( + doc, + "enumParam", + List.of("EnumDoc"), + method -> { + assertEquals("Enum database.", method.getText()); + assertEquals("Enum doc.", method.getParameterDoc("query")); + }); }); } @@ -91,4 +117,11 @@ private void withDoc(Path path, Consumer consumer) throws Exception { throw SneakyThrows.propagate(cause); } } + + private void withMethod( + ClassDoc doc, String name, List types, Consumer consumer) { + var method = doc.getMethod(name, types); + assertTrue(method.isPresent()); + consumer.accept(method.get()); + } } diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java index f422d58a61..f275b90072 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java @@ -9,17 +9,14 @@ import java.nio.file.Paths; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; -import com.puppycrawl.tools.checkstyle.JavaParser; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; public class PrinteAstTree { public static void main(String[] args) throws CheckstyleException, IOException { var baseDir = Paths.get(System.getProperty("user.dir")).resolve("modules").resolve("jooby-openapi"); - var input = Paths.get("src", "test", "java", "javadoc", "input", "QueryBeanDoc.java"); - var stringAst = - AstTreeStringPrinter.printFileAst( - baseDir.resolve(input).toFile(), JavaParser.Options.WITH_COMMENTS); + var input = Paths.get("src", "test", "java", "javadoc", "input", "EnumDoc.java"); + var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree(baseDir.resolve(input).toFile()); System.out.println(stringAst); } } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java index 49466cc65d..8daa222bbb 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -28,7 +28,8 @@ public class ApiDoc { * @param age Person age. * @param list This line has a break. * @param str Some string. - * @return Say hello. + * @return Welcome message 200. + * @throws NullPointerException One something is null. */ @NonNull @GET public String hello( @@ -49,4 +50,26 @@ public String hello( public String search(@QueryParam QueryBeanDoc query) { return "hello"; } + + /** + * Record database. + * + * @param query + * @return + */ + @GET + public String recordBean(@QueryParam RecordBeanDoc query) { + return "hello"; + } + + /** + * Enum database. + * + * @param query Enum doc. + * @return + */ + @GET + public String enumParam(@QueryParam EnumDoc query) { + return "hello"; + } } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java new file mode 100644 index 0000000000..a5190ec41e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java @@ -0,0 +1,15 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +/** Cras dictum. */ +public enum EnumDoc { + /** Foo doc. */ + Foo, + + /** Bar doc. */ + Bar +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java new file mode 100644 index 0000000000..ed2aa22a3e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +import jakarta.validation.constraints.NotEmpty; + +/** + * Record documentation. + * + * @param id Person id. + * @param name Person name. Example: edgar. + */ +public record RecordBeanDoc(String id, @NotEmpty String name) {} From 6316259fae7411f849bf7a9dcf00c824ef9f5ff8 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 29 Jul 2025 12:33:12 -0300 Subject: [PATCH 05/17] openapi: javadoc - working in progress of adding javadoc to openapi - still a lot to do --- .../internal/openapi/AnnotationParser.java | 69 +++++++- .../jooby/internal/openapi/ParserContext.java | 28 ++- .../io/jooby/internal/openapi/SchemaRef.java | 8 +- .../internal/openapi/javadoc/ClassDoc.java | 23 +-- .../openapi/javadoc/JavaDocContext.java | 119 ++++--------- .../internal/openapi/javadoc/JavaDocNode.java | 166 +++++++++++++++++- .../openapi/javadoc/JavaDocParser.java | 71 +++++--- .../openapi/javadoc/JavaDocSupport.java | 4 +- .../internal/openapi/javadoc/MethodDoc.java | 13 +- .../io/jooby/openapi/OpenAPIGenerator.java | 13 +- .../io/jooby/openapi/OpenAPIExtension.java | 16 +- .../src/test/java/issues/i3729/App3729.java | 16 ++ .../java/issues/i3729/Controller3729.java | 34 ++++ .../src/test/java/issues/i3729/Issue3729.java | 53 ++++++ .../test/java/issues/i3729/api/Address.java | 45 +++++ .../java/issues/i3729/api/ApiDocTest.java | 117 ++++++++++++ .../java/issues/i3729/api/AppLibrary.java | 17 ++ .../test/java/issues/i3729/api/Author.java | 50 ++++++ .../src/test/java/issues/i3729/api/Book.java | 71 ++++++++ .../java/issues/i3729/api/LibraryApi.java | 42 +++++ .../src/test/java/issues/i3729/api/Type.java | 13 ++ .../test/java/javadoc/JavaDocParserTest.java | 40 ++++- .../src/test/java/javadoc/input/ApiDoc.java | 4 +- .../test/java/javadoc/input/NoClassDoc.java | 26 +++ 24 files changed, 876 insertions(+), 182 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/App3729.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/Controller3729.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/NoClassDoc.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 9b3001e5cc..557d36ec9b 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 @@ -10,6 +10,8 @@ import static java.util.Arrays.asList; import static java.util.Collections.singletonList; +import java.io.File; +import java.nio.file.Paths; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -32,6 +34,7 @@ import io.jooby.annotation.Path; import io.jooby.annotation.PathParam; import io.jooby.annotation.QueryParam; +import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; @@ -251,9 +254,18 @@ public static List parse( return parse(ctx, prefix, type); } } - return Collections.emptyList(); + return List.of(); } + /** + * This is the main entrypoint beside there is a public {@link #parse(ParserContext, String, + * Signature, MethodInsnNode)}. + * + * @param ctx + * @param prefix + * @param type + * @return + */ public static List parse(ParserContext ctx, String prefix, Type type) { List result = new ArrayList<>(); ClassNode classNode = ctx.classNode(type); @@ -262,10 +274,63 @@ public static List parse(ParserContext ctx, String prefix, Type ty ctx.debugHandler(method); result.addAll(routerMethod(ctx, prefix, classNode, method)); } - result.forEach(it -> it.setController(classNode)); + var javadocContext = ctx.javadoc(); + var javadocParser = new JavaDocParser(javadocContext); + for (OperationExt operationExt : result) { + operationExt.setController(classNode); + try { + var className = operationExt.getControllerName(); + javadocParser + .parseMvc(toJavaPath(className)) + .ifPresent( + doc -> { + operationExt.setPathDescription(doc.getDescription()); + operationExt.setPathSummary(doc.getSummary()); + var args = + operationExt.getParameters().stream() + .map(ParameterExt.class::cast) + .map( + p -> + Optional.ofNullable(p.getContainerType()).orElse(p.getJavaType())) + .map( + qualifiedName -> { + var index = qualifiedName.lastIndexOf('.'); + return index == -1 + ? qualifiedName + : qualifiedName.substring(index + 1); + }) + .toList(); + doc.getMethod(operationExt.getOperationId(), args) + .ifPresent( + methodDoc -> { + operationExt.setSummary(methodDoc.getSummary()); + operationExt.setDescription(methodDoc.getDescription()); + + for (var parameter : operationExt.getParameters()) { + var paramExt = (ParameterExt) parameter; + var paramDoc = + methodDoc.getParameterDoc( + paramExt.getName(), paramExt.getContainerType()); + if (paramDoc != null) { + paramExt.setDescription(paramDoc); + } + } + }); + }); + } catch (Exception x) { + // TODO: how to log from here + x.printStackTrace(); + } + } return result; } + private static java.nio.file.Path toJavaPath(String className) { + var segments = className.split("/"); + segments[segments.length - 1] = segments[segments.length - 1] + ".java"; + return Paths.get(String.join(File.separator, segments)); + } + private static Map methods(ParserContext ctx, ClassNode node) { Map methods = new LinkedHashMap<>(); if (node.superName != null && !node.superName.equals(TypeFactory.OBJECT.getInternalName())) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 644a950ad6..58cbc14f00 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -70,6 +70,8 @@ import io.jooby.FileUpload; import io.jooby.SneakyThrows; import io.jooby.StatusCode; +import io.jooby.internal.openapi.javadoc.JavaDocContext; +import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.jooby.openapi.DebugOption; import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.converter.ResolvedSchema; @@ -107,17 +109,24 @@ public TypeLiteral() {} private final Set instructions = new HashSet<>(); private final Set debug; private final ConcurrentMap schemas = new ConcurrentHashMap<>(); + private final JavaDocContext javadocContext; - public ParserContext(ClassSource source, Type router, Set debug) { - this(source, new HashMap<>(), router, debug); + public ParserContext( + ClassSource source, Type router, JavaDocContext javadocContext, Set debug) { + this(source, new HashMap<>(), router, javadocContext, debug); } private ParserContext( - ClassSource source, Map nodes, Type router, Set debug) { + ClassSource source, + Map nodes, + Type router, + JavaDocContext javadocContext, + Set debug) { this.router = router; this.source = source; this.debug = Optional.ofNullable(debug).orElse(Collections.emptySet()); this.nodes = nodes; + this.javadocContext = javadocContext; List mappers = asList(Json.mapper(), Yaml.mapper()); jacksonModules(source.getClassLoader(), mappers); @@ -163,6 +172,10 @@ public Collection schemas() { return schemas.values().stream().map(ref -> ref.schema).collect(Collectors.toList()); } + public JavaDocContext javadoc() { + return javadocContext; + } + public Schema schema(Class type) { if (isVoid(type.getName())) { return null; @@ -269,13 +282,14 @@ public Schema schema(Class type) { new SchemaRef( resolvedSchema.schema, RefUtils.constructRef(resolvedSchema.schema.getName())); schemas.put(type.getName(), schemaRef); - + document(type.getName(), resolvedSchema.schema); if (resolvedSchema.referencedSchemas != null) { for (Map.Entry e : resolvedSchema.referencedSchemas.entrySet()) { if (!e.getKey().equals(schemaRef.schema.getName())) { SchemaRef dependency = new SchemaRef(e.getValue(), RefUtils.constructRef(e.getValue().getName())); schemas.putIfAbsent(e.getKey(), dependency); + document(e.getKey(), dependency.schema); } } } @@ -283,6 +297,10 @@ public Schema schema(Class type) { return schemaRef.toSchema(); } + private void document(String typeName, Schema schema) { + var parser = new JavaDocParser(javadocContext); + } + public Optional schemaRef(String type) { return Optional.ofNullable(schemas.get(type)); } @@ -429,7 +447,7 @@ public boolean process(AbstractInsnNode instruction) { } public ParserContext newContext(Type router) { - return new ParserContext(source, nodes, router, debug); + return new ParserContext(source, nodes, router, javadocContext, debug); } public String getMainClass() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaRef.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaRef.java index a75ba7967d..40c36fb5ac 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaRef.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaRef.java @@ -5,21 +5,19 @@ */ package io.jooby.internal.openapi; -import java.util.Optional; - import io.swagger.v3.oas.models.media.Schema; public class SchemaRef { public final Schema schema; - public final Optional ref; + public final String ref; public SchemaRef(Schema schema, String ref) { this.schema = schema; - this.ref = Optional.ofNullable(ref); + this.ref = ref; } public Schema toSchema() { - return this.ref.map(ref -> new Schema().$ref(ref)).orElse(this.schema); + return ref == null ? schema : new Schema().$ref(ref); } } 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 e89d7c43d8..80de626623 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 @@ -14,13 +14,12 @@ import java.util.stream.Collectors; import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; public class ClassDoc extends JavaDocNode { private final DetailAST node; - private List methods = new ArrayList<>(); + private final List methods = new ArrayList<>(); public ClassDoc(JavaDocContext ctx, DetailAST node, DetailAST javaDoc) { super(ctx, javaDoc); @@ -31,31 +30,13 @@ public void addMethod(MethodDoc method) { this.methods.add(method); } - public String getSummary() { - var text = new StringBuilder(); - for (var node : forward(javadoc, STOP_TOKENS).toList()) { - if (node.getType() == JavadocTokenTypes.NEWLINE && !text.isEmpty()) { - break; - } else if (node.getType() == JavadocTokenTypes.TEXT) { - text.append(node.getText()); - } - } - return text.isEmpty() ? getText().trim() : text.toString().trim(); - } - - public String getDescription() { - var text = getText(); - var summary = getSummary(); - return summary.equals(text) ? "" : text.replaceAll(summary, "").trim(); - } - public Optional getMethod(String name, List parameterNames) { var filtered = methods.stream().filter(it -> it.getName().equals(name)).toList(); if (filtered.isEmpty()) { return Optional.empty(); } if (filtered.size() == 1) { - return Optional.of(filtered.get(0)); + return Optional.of(filtered.getFirst()); } return filtered.stream() .filter(it -> it.getParameterNames().equals(parameterNames)) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java index 0778633466..da6855e47c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java @@ -8,110 +8,57 @@ import static com.puppycrawl.tools.checkstyle.JavaParser.parseFile; import static io.jooby.SneakyThrows.throwingFunction; +import java.io.File; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; import com.puppycrawl.tools.checkstyle.JavaParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; public class JavaDocContext { - private final Path baseDir; + private final List baseDir; private final Map cache = new HashMap<>(); public JavaDocContext(Path baseDir) { + this(List.of(baseDir)); + } + + public JavaDocContext(List baseDir) { this.baseDir = baseDir; } public DetailAST resolve(Path path) { - return cache.computeIfAbsent( - baseDir.resolve(path), - throwingFunction( - filePath -> { - if (Files.exists(filePath)) { - return parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); - } else { - return NULL; - } - })); + return lookup(path) + .map( + it -> + cache.computeIfAbsent( + it, + throwingFunction( + filePath -> { + return parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); + }))) + .orElse(JavaDocNode.EMPTY_AST); } - public static final DetailAST NULL = - new DetailAST() { - @Override - public int getChildCount() { - return 0; - } - - @Override - public int getChildCount(int type) { - return 0; - } - - @Override - public DetailAST getParent() { - return null; - } - - @Override - public String getText() { - return ""; - } - - @Override - public int getType() { - return 0; - } - - @Override - public int getLineNo() { - return 0; - } - - @Override - public int getColumnNo() { - return 0; - } - - @Override - public DetailAST getLastChild() { - return null; - } - - @Override - public boolean branchContains(int type) { - return false; - } - - @Override - public DetailAST getPreviousSibling() { - return null; - } - - @Override - public DetailAST findFirstToken(int type) { - return null; - } - - @Override - public DetailAST getNextSibling() { - return null; - } - - @Override - public DetailAST getFirstChild() { - return null; - } + public DetailAST resolveType(Class type) { + return resolveType(type.getName()); + } - @Override - public int getNumberOfChildren() { - return 0; - } + public DetailAST resolveType(String typeName) { + var segments = typeName.split("\\."); + segments[segments.length - 1] = segments[segments.length - 1] + ".java"; + return resolve(Paths.get(String.join(File.separator, segments))); + } - @Override - public boolean hasChildren() { - return false; - } - }; + protected Optional lookup(Path path) { + return baseDir.stream() + .map(parentDir -> parentDir.resolve(path)) + .filter(Files::exists) + .findFirst(); + } } 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 250afe682b..35da662a5e 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,10 @@ */ package io.jooby.internal.openapi.javadoc; +import static io.jooby.internal.openapi.javadoc.JavaDocSupport.forward; + import java.util.List; -import java.util.Set; +import java.util.function.Predicate; import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter; import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser; @@ -16,9 +18,126 @@ import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; public class JavaDocNode { + public static final DetailNode EMPTY_NODE = + new DetailNode() { + @Override + public int getType() { + return 0; + } + + @Override + public String getText() { + return ""; + } + + @Override + public int getLineNumber() { + return 0; + } + + @Override + public int getColumnNumber() { + return 0; + } + + @Override + public DetailNode[] getChildren() { + return new DetailNode[0]; + } + + @Override + public DetailNode getParent() { + return null; + } + + @Override + public int getIndex() { + return 0; + } + }; + + public static final DetailAST EMPTY_AST = + new DetailAST() { + @Override + public int getChildCount() { + return 0; + } + + @Override + public int getChildCount(int type) { + return 0; + } + + @Override + public DetailAST getParent() { + return null; + } + + @Override + public String getText() { + return ""; + } + + @Override + public int getType() { + return 0; + } + + @Override + public int getLineNo() { + return 0; + } + + @Override + public int getColumnNo() { + return 0; + } + + @Override + public DetailAST getLastChild() { + return null; + } + + @Override + public boolean branchContains(int type) { + return false; + } + + @Override + public DetailAST getPreviousSibling() { + return null; + } + + @Override + public DetailAST findFirstToken(int type) { + return null; + } + + @Override + public DetailAST getNextSibling() { + return null; + } + + @Override + public DetailAST getFirstChild() { + return null; + } + + @Override + public int getNumberOfChildren() { + return 0; + } + + @Override + public boolean hasChildren() { + return false; + } + }; + protected final JavaDocContext context; protected final DetailNode javadoc; - protected static final Set STOP_TOKENS = Set.of(JavadocTokenTypes.JAVADOC_TAG); + private static final Predicate JAVADOC_TAG = + JavaDocSupport.javadocToken(JavadocTokenTypes.JAVADOC_TAG); public JavaDocNode(JavaDocContext ctx, DetailAST node) { this.context = ctx; @@ -26,11 +145,48 @@ public JavaDocNode(JavaDocContext ctx, DetailAST node) { } static DetailNode toJavaDocNode(DetailAST node) { - return new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); + return node == EMPTY_AST + ? EMPTY_NODE + : new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); + } + + public String getSummary() { + var builder = new StringBuilder(); + for (var node : forward(javadoc, JAVADOC_TAG).toList()) { + if (node.getType() == JavadocTokenTypes.TEXT) { + var text = node.getText(); + var trimmed = text.trim(); + if (trimmed.isEmpty()) { + if (!builder.isEmpty()) { + builder.append(text); + } + } else { + builder.append(text); + } + } else if (node.getType() == JavadocTokenTypes.NEWLINE && !builder.isEmpty()) { + break; + } + var index = builder.indexOf("."); + if (index > 0) { + builder.setLength(index + 1); + break; + } + } + var string = builder.toString().trim(); + return string.isEmpty() ? null : string; + } + + public String getDescription() { + var text = getText(); + var summary = getSummary(); + if (summary == null) { + return text; + } + return summary.equals(text) ? null : text.replaceAll(summary, "").trim(); } - public String getText() { - return getText(JavaDocSupport.forward(javadoc, STOP_TOKENS).toList(), false); + protected String getText() { + return getText(JavaDocSupport.forward(javadoc, JAVADOC_TAG).toList(), false); } protected 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 5f1b44e968..e1fce0befa 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 @@ -19,49 +19,62 @@ public class JavaDocParser { private static final Predicate HAS_CLASS = it -> backward(it).anyMatch(tokens(TokenTypes.CLASS_DEF)); + private static final Predicate STATEMENT_LIST = + it -> backward(it).anyMatch(tokens(TokenTypes.SLIST)); + private final JavaDocContext context; public JavaDocParser(JavaDocContext context) { this.context = context; } - public Optional parse(Path filePath) throws Exception { + public Optional parseMvc(Path filePath) throws Exception { ClassDoc result = null; var tree = context.resolve(filePath); for (var comment : - forward(tree).filter(tokens(TokenTypes.COMMENT_CONTENT)).filter(HAS_CLASS).toList()) { - var nodePath = path(comment); - // ensure class - if (result == null) { - result = new ClassDoc(context, nodePath[1], comment.getParent()); - } - if (nodePath[nodePath.length - 1] != null) { - // there is a method here - var method = new MethodDoc(context, nodePath[nodePath.length - 1], comment.getParent()); + forward(tree) + .filter(tokens(TokenTypes.COMMENT_CONTENT)) + .filter(HAS_CLASS) + .filter(STATEMENT_LIST.negate()) + .toList()) { + var classOrMethod = classOrMethod(comment); + if (classOrMethod.getType() == TokenTypes.METHOD_DEF) { + if (result == null) { + // No comment on class + result = + new ClassDoc( + context, + tree(tree) + .filter( + tokens( + TokenTypes.ENUM_DEF, + TokenTypes.CLASS_DEF, + TokenTypes.INTERFACE_DEF, + TokenTypes.RECORD_DEF)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Class not found " + tree)), + JavaDocNode.EMPTY_AST); + } + var method = new MethodDoc(context, classOrMethod, comment.getParent()); result.addMethod(method); + } else { + // always as class + result = new ClassDoc(context, classOrMethod, comment.getParent()); } } return Optional.ofNullable(result); } - private static DetailAST[] path(DetailAST comment) { - var classDef = - backward(comment) - .filter( - tokens( - TokenTypes.ENUM_DEF, - TokenTypes.CLASS_DEF, - TokenTypes.INTERFACE_DEF, - TokenTypes.RECORD_DEF)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("no type found")); - var packageDef = - forward(classDef.getParent()) - .filter(tokens(TokenTypes.PACKAGE_DEF)) - .findFirst() - .orElse(null); - var methodDef = - backward(comment).filter(tokens(TokenTypes.METHOD_DEF)).findFirst().orElse(null); - return new DetailAST[] {packageDef, classDef, methodDef}; + private static DetailAST classOrMethod(DetailAST comment) { + return backward(comment) + .filter( + tokens( + TokenTypes.METHOD_DEF, + TokenTypes.ENUM_DEF, + TokenTypes.CLASS_DEF, + TokenTypes.INTERFACE_DEF, + TokenTypes.RECORD_DEF)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Invalid comment: " + comment.getText())); } } 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 77491bf76f..6000c4ea52 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 @@ -84,11 +84,11 @@ public static Stream forward(DetailNode node) { return forward(ASTNode.javadoc(node)); } - public static Stream forward(DetailNode node, Set stopOn) { + 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.contains(it.getType())) { + if (stopOn.test(it)) { break; } result.add(it); 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 3d040d25fc..83bfcf9a3d 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 @@ -7,9 +7,6 @@ import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import java.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -71,8 +68,8 @@ public String getParameterDoc(String name) { public String getParameterDoc(String name, String in) { if (in != null) { - var tree = context.resolve(toJavaPath(in)); - if (tree == JavaDocContext.NULL) { + var tree = context.resolveType(in); + if (tree == JavaDocNode.EMPTY_AST) { return null; } return getPropertyDoc(tree, name); @@ -116,12 +113,6 @@ public String getReturnDoc() { .orElse(null); } - private Path toJavaPath(String in) { - var segments = in.split("\\."); - segments[segments.length - 1] = segments[segments.length - 1] + ".java"; - return Paths.get(String.join(File.separator, segments)); - } - private String getPropertyDoc(DetailAST bean, String name) { String comment; var isRecord = tree(bean).anyMatch(tokens(TokenTypes.RECORD_DEF)); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index c58e2a5372..95e64d3c4f 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -26,6 +26,7 @@ import io.jooby.Router; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.*; +import io.jooby.internal.openapi.javadoc.JavaDocContext; import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Yaml; import io.swagger.v3.oas.models.OpenAPI; @@ -100,6 +101,8 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { private String excludes; + private List sources; + /** * Export an {@link OpenAPI} model to the given format. * @@ -154,7 +157,9 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { OpenApiTemplate.fromTemplate(basedir, classLoader, templateName).orElseGet(OpenAPIExt::new); RouteParser routes = new RouteParser(); - ParserContext ctx = new ParserContext(source, TypeFactory.fromJavaName(classname), debug); + ParserContext ctx = + new ParserContext( + source, TypeFactory.fromJavaName(classname), new JavaDocContext(sources), debug); List operations = routes.parse(ctx, openapi); String contextPath = ContextPathParser.parse(ctx); @@ -179,7 +184,7 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { Map regexMap = new HashMap<>(); Router.pathKeys( pattern, (key, value) -> Optional.ofNullable(value).ifPresent(v -> regexMap.put(key, v))); - if (regexMap.size() > 0) { + if (!regexMap.isEmpty()) { for (Map.Entry e : regexMap.entrySet()) { String name = e.getKey(); String regex = e.getValue(); @@ -326,6 +331,10 @@ public void setBasedir(@NonNull Path basedir) { this.basedir = basedir; } + public void setSources(@NonNull List sources) { + this.sources = sources; + } + /** * Base directory used it for loading openAPI template file name. * diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java index c182f05309..3a44a442a8 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java @@ -8,11 +8,8 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.lang.reflect.Parameter; -import java.util.Arrays; -import java.util.Collections; -import java.util.EnumSet; -import java.util.Optional; -import java.util.Set; +import java.nio.file.Paths; +import java.util.*; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; @@ -45,7 +42,7 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte ? Collections.emptySet() : EnumSet.copyOf(Arrays.asList(metadata.debug())); - OpenAPIGenerator tool = newTool(debugOptions, klass); + OpenAPIGenerator tool = newTool(debugOptions); String templateName = metadata.templateName(); if (templateName.isEmpty()) { templateName = classname.replace(".", "/").toLowerCase() + ".yaml"; @@ -81,9 +78,14 @@ public void afterEach(ExtensionContext ctx) { } } - private OpenAPIGenerator newTool(Set debug, Class klass) { + private OpenAPIGenerator newTool(Set debug) { OpenAPIGenerator tool = new OpenAPIGenerator(); tool.setDebug(debug); + var baseDir = Paths.get(System.getProperty("user.dir")); + if (!baseDir.getFileName().toString().endsWith("openapi")) { + baseDir = baseDir.resolve("modules").resolve("jooby-openapi"); + } + tool.setSources(List.of(baseDir.resolve("src").resolve("test").resolve("java"))); return tool; } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/App3729.java b/modules/jooby-openapi/src/test/java/issues/i3729/App3729.java new file mode 100644 index 0000000000..76708ec5d2 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/App3729.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +import io.jooby.Jooby; + +public class App3729 extends Jooby { + { + mvc(toMvcExtension(Controller3729.class)); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/Controller3729.java b/modules/jooby-openapi/src/test/java/issues/i3729/Controller3729.java new file mode 100644 index 0000000000..ce3134f509 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/Controller3729.java @@ -0,0 +1,34 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729; + +import java.util.Map; + +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.PathParam; +import io.jooby.annotation.QueryParam; + +/** + * Playing with API doc. + * + *

Sed eget orci imperdiet massa ultrices congue. Etiam ornare velit eu justo efficitur. + */ +@Path("/3729") +public class Controller3729 { + + /** + * Find a user by ID. Finds a user by ID or throws a 404 + * + * @param id The user ID. + * @param activeOnly Flag for fetching active/inactive users. (Defaults to true if not provided). + * @return Found user. + */ + @GET("/{id}") + public Map getUser(@PathParam String id, @QueryParam Boolean activeOnly) { + return null; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java b/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java new file mode 100644 index 0000000000..2549512d07 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java @@ -0,0 +1,53 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.jooby.openapi.OpenAPIResult; +import io.jooby.openapi.OpenAPITest; + +public class Issue3729 { + + @OpenAPITest(value = App3729.class) + public void shouldGenerateDoc(OpenAPIResult result) { + assertEquals( + "openapi: 3.0.1\n" + + "info:\n" + + " title: 3729 API\n" + + " description: 3729 API description\n" + + " version: \"1.0\"\n" + + "paths:\n" + + " /3729/{id}:\n" + + " get:\n" + + " summary: Find a user by ID.\n" + + " description: Finds a user by ID or throws a 404\n" + + " operationId: getUser\n" + + " parameters:\n" + + " - name: id\n" + + " in: path\n" + + " description: The user ID.\n" + + " required: true\n" + + " schema:\n" + + " type: string\n" + + " - name: activeOnly\n" + + " in: query\n" + + " description: Flag for fetching active/inactive users. (Defaults to true if\n" + + " not provided).\n" + + " schema:\n" + + " type: boolean\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " additionalProperties:\n" + + " type: object\n", + result.toYaml()); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java new file mode 100644 index 0000000000..d9f05f3b30 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java @@ -0,0 +1,45 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +public class Address { + private String street; + private String city; + private String state; + private String country; + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } +} 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 new file mode 100644 index 0000000000..96ff596b78 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -0,0 +1,117 @@ +/* + * 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 ApiDocTest { + + @OpenAPITest(value = AppLibrary.class) + public void shouldGenerateDoc(OpenAPIResult result) { + assertEquals( + "openapi: 3.0.1\n" + + "info:\n" + + " title: Library API\n" + + " description: Library API description\n" + + " version: \"1.0\"\n" + + "paths:\n" + + " /api/library/{isbn}:\n" + + " summary: Library API.\n" + + " description: \"Contains all operations for creating, updating and fetching" + + " books.\"\n" + + " get:\n" + + " summary: Find a book by isbn.\n" + + " operationId: bookByIsbn\n" + + " parameters:\n" + + " - name: isbn\n" + + " in: path\n" + + " description: Book isbn. Like IK-1900.\n" + + " required: true\n" + + " schema:\n" + + " type: string\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: \"#/components/schemas/Book\"\n" + + " /api/library:\n" + + " summary: Library API.\n" + + " description: \"Contains all operations for creating, updating and fetching" + + " books.\"\n" + + " post:\n" + + " summary: Creates a new book.\n" + + " operationId: createBook\n" + + " requestBody:\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: \"#/components/schemas/Book\"\n" + + " required: false\n" + + " responses:\n" + + " \"200\":\n" + + " description: Success\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: \"#/components/schemas/Book\"\n" + + "components:\n" + + " schemas:\n" + + " Address:\n" + + " type: object\n" + + " properties:\n" + + " street:\n" + + " type: string\n" + + " city:\n" + + " type: string\n" + + " state:\n" + + " type: string\n" + + " country:\n" + + " type: string\n" + + " Book:\n" + + " type: object\n" + + " properties:\n" + + " isbn:\n" + + " type: string\n" + + " title:\n" + + " type: string\n" + + " publicationDate:\n" + + " type: string\n" + + " format: date\n" + + " text:\n" + + " type: string\n" + + " type:\n" + + " type: string\n" + + " enum:\n" + + " - Book\n" + + " - Manual\n" + + " authors:\n" + + " uniqueItems: true\n" + + " type: array\n" + + " items:\n" + + " $ref: \"#/components/schemas/Author\"\n" + + " Author:\n" + + " type: object\n" + + " properties:\n" + + " ssn:\n" + + " type: string\n" + + " name:\n" + + " type: string\n" + + " address:\n" + + " $ref: \"#/components/schemas/Address\"\n" + + " books:\n" + + " uniqueItems: true\n" + + " type: array\n" + + " items:\n" + + " $ref: \"#/components/schemas/Book\"\n", + result.toYaml()); + System.out.println(result.toYaml()); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java new file mode 100644 index 0000000000..a91e03c754 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +import io.jooby.Jooby; + +public class AppLibrary extends Jooby { + + { + mvc(toMvcExtension(LibraryApi.class)); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java new file mode 100644 index 0000000000..9bd057d961 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java @@ -0,0 +1,50 @@ +/* + * 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.Set; + +public class Author { + String ssn; + + String name; + + Address address; + + Set books; + + public String getSsn() { + return ssn; + } + + public void setSsn(String ssn) { + this.ssn = ssn; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Address getAddress() { + return address; + } + + public void setAddress(Address address) { + this.address = address; + } + + public Set getBooks() { + return books; + } + + public void setBooks(Set books) { + this.books = books; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java new file mode 100644 index 0000000000..0ff9e6ea3a --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java @@ -0,0 +1,71 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import java.time.LocalDate; +import java.util.Set; + +public class Book { + String isbn; + + String title; + + LocalDate publicationDate; + + String text; + + Type type = Type.Book; + + Set authors; + + public String getIsbn() { + return isbn; + } + + public void setIsbn(String isbn) { + this.isbn = isbn; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDate getPublicationDate() { + return publicationDate; + } + + public void setPublicationDate(LocalDate publicationDate) { + this.publicationDate = publicationDate; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public Type getType() { + return type; + } + + public void setType(Type type) { + this.type = type; + } + + public Set getAuthors() { + return authors; + } + + public void setAuthors(Set authors) { + this.authors = authors; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java new file mode 100644 index 0000000000..ecf33e7acc --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -0,0 +1,42 @@ +/* + * 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.annotation.GET; +import io.jooby.annotation.POST; +import io.jooby.annotation.Path; +import io.jooby.annotation.PathParam; + +/** + * Library API. + * + *

Contains all operations for creating, updating and fetching books. + */ +@Path("/api/library") +public class LibraryApi { + + /** + * Find a book by isbn. + * + * @param isbn Book isbn. Like IK-1900. + * @return A book + */ + @GET("/{isbn}") + public Book bookByIsbn(@PathParam String isbn) { + return new Book(); + } + + /** + * Creates a new book. + * + * @param book Book to create. + * @return Saved book. + */ + @POST + public Book createBook(Book book) { + return book; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java new file mode 100644 index 0000000000..c258527143 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java @@ -0,0 +1,13 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +/** Book type. */ +public enum Type { + Book, + + Manual +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index e41a475de3..3111505f55 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -42,7 +42,8 @@ public void apiDoc() throws Exception { "hello", List.of("List", "int", "List", "String"), method -> { - assertEquals("This is the Hello /endpoint.", method.getText()); + assertEquals("This is the Hello /endpoint", method.getSummary()); + assertEquals("Operation description", method.getDescription()); assertEquals("Person name.", method.getParameterDoc("name")); assertEquals("Person age.", method.getParameterDoc("age")); assertEquals("This line has a break.", method.getParameterDoc("list")); @@ -55,7 +56,8 @@ public void apiDoc() throws Exception { "search", List.of("QueryBeanDoc"), method -> { - assertEquals("Search database.", method.getText()); + assertEquals("Search database.", method.getSummary()); + assertEquals("Search DB", method.getDescription()); assertEquals( "Filter query. Works like internal filter.", method.getParameterDoc("fq", "javadoc.input.QueryBeanDoc")); @@ -71,7 +73,8 @@ public void apiDoc() throws Exception { "recordBean", List.of("RecordBeanDoc"), method -> { - assertEquals("Record database.", method.getText()); + assertEquals("Record database.", method.getSummary()); + assertNull(method.getDescription()); assertEquals( "Person id.", method.getParameterDoc("id", "javadoc.input.RecordBeanDoc")); assertEquals( @@ -84,18 +87,43 @@ public void apiDoc() throws Exception { "enumParam", List.of("EnumDoc"), method -> { - assertEquals("Enum database.", method.getText()); + assertEquals("Enum database.", method.getSummary()); assertEquals("Enum doc.", method.getParameterDoc("query")); }); }); } + @Test + public void ignoreStatementComment() throws Exception { + var result = newParser().parseMvc(Paths.get("issues", "i1580", "Controller1580.java")); + assertTrue(result.isEmpty()); + } + @Test public void noDoc() throws Exception { - var result = newParser().parse(Paths.get("javadoc", "input", "NoDoc.java")); + var result = newParser().parseMvc(Paths.get("javadoc", "input", "NoDoc.java")); assertTrue(result.isEmpty()); } + @Test + public void noClassDoc() throws Exception { + withDoc( + Paths.get("javadoc", "input", "NoClassDoc.java"), + doc -> { + assertNull(doc.getSummary()); + assertNull(doc.getDescription()); + + withMethod( + doc, + "hello", + List.of("String"), + methodDoc -> { + assertEquals("Method Doc.", methodDoc.getSummary()); + assertNull(methodDoc.getDescription()); + }); + }); + } + private JavaDocParser newParser() { return new JavaDocParser(new JavaDocContext(baseDir())); } @@ -106,7 +134,7 @@ private Path baseDir() { private void withDoc(Path path, Consumer consumer) throws Exception { try { - var result = newParser().parse(path); + var result = newParser().parseMvc(path); assertFalse(result.isEmpty()); consumer.accept(result.get()); } catch (Throwable cause) { diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java index 8daa222bbb..9a778939f1 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -22,7 +22,7 @@ public class ApiDoc { /** - * This is the Hello /endpoint. + * This is the Hello /endpoint Operation description * * @param name Person name. * @param age Person age. @@ -43,6 +43,8 @@ public String hello( /** * Search database. * + *

Search DB + * * @param query * @return */ diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/NoClassDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/NoClassDoc.java new file mode 100644 index 0000000000..19ee1eab5d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/NoClassDoc.java @@ -0,0 +1,26 @@ +/* + * 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.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.QueryParam; + +@Path("/api") +public class NoClassDoc { + + /** + * Method Doc. + * + * @param name Name. + * @return Person name. + */ + @NonNull @GET + public String hello(@QueryParam String name) { + return "hello"; + } +} From 42af609253c937f5b12c2aaee7af9560902c18ee Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 29 Jul 2025 17:17:28 -0300 Subject: [PATCH 06/17] openapi: work in progress --- .../internal/openapi/AnnotationParser.java | 2 +- .../openapi/javadoc/JavaDocParser.java | 8 +++--- .../test/java/javadoc/JavaDocParserTest.java | 25 ++++++++++++++++--- .../src/test/java/javadoc/input/ApiDoc.java | 4 ++- 4 files changed, 30 insertions(+), 9 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 557d36ec9b..6943efb3a4 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 @@ -281,7 +281,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty try { var className = operationExt.getControllerName(); javadocParser - .parseMvc(toJavaPath(className)) + .parse(toJavaPath(className)) .ifPresent( doc -> { operationExt.setPathDescription(doc.getDescription()); 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 e1fce0befa..5037de76cc 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 @@ -19,8 +19,8 @@ public class JavaDocParser { private static final Predicate HAS_CLASS = it -> backward(it).anyMatch(tokens(TokenTypes.CLASS_DEF)); - private static final Predicate STATEMENT_LIST = - it -> backward(it).anyMatch(tokens(TokenTypes.SLIST)); + private static final Predicate SINGLE_LINE_COMMENT = + it -> backward(it).anyMatch(tokens(TokenTypes.SINGLE_LINE_COMMENT)); private final JavaDocContext context; @@ -28,14 +28,14 @@ public JavaDocParser(JavaDocContext context) { this.context = context; } - public Optional parseMvc(Path filePath) throws Exception { + public Optional parse(Path filePath) throws Exception { ClassDoc result = null; var tree = context.resolve(filePath); for (var comment : forward(tree) .filter(tokens(TokenTypes.COMMENT_CONTENT)) .filter(HAS_CLASS) - .filter(STATEMENT_LIST.negate()) + .filter(SINGLE_LINE_COMMENT.negate()) .toList()) { var classOrMethod = classOrMethod(comment); if (classOrMethod.getType() == TokenTypes.METHOD_DEF) { diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 3111505f55..016d751058 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -95,13 +95,13 @@ public void apiDoc() throws Exception { @Test public void ignoreStatementComment() throws Exception { - var result = newParser().parseMvc(Paths.get("issues", "i1580", "Controller1580.java")); + var result = newParser().parse(Paths.get("issues", "i1580", "Controller1580.java")); assertTrue(result.isEmpty()); } @Test public void noDoc() throws Exception { - var result = newParser().parseMvc(Paths.get("javadoc", "input", "NoDoc.java")); + var result = newParser().parse(Paths.get("javadoc", "input", "NoDoc.java")); assertTrue(result.isEmpty()); } @@ -124,6 +124,25 @@ public void noClassDoc() throws Exception { }); } + @Test + public void shouldParseBean() throws Exception { + withDoc( + Paths.get("javadoc", "input", "QueryBeanDoc.java"), + doc -> { + assertNull(doc.getSummary()); + assertNull(doc.getDescription()); + + withMethod( + doc, + "hello", + List.of("String"), + methodDoc -> { + assertEquals("Method Doc.", methodDoc.getSummary()); + assertNull(methodDoc.getDescription()); + }); + }); + } + private JavaDocParser newParser() { return new JavaDocParser(new JavaDocContext(baseDir())); } @@ -134,7 +153,7 @@ private Path baseDir() { private void withDoc(Path path, Consumer consumer) throws Exception { try { - var result = newParser().parseMvc(path); + var result = newParser().parse(path); assertFalse(result.isEmpty()); consumer.accept(result.get()); } catch (Throwable cause) { diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java index 9a778939f1..fc8666f0f9 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -22,7 +22,9 @@ public class ApiDoc { /** - * This is the Hello /endpoint Operation description + * This is the Hello /endpoint + * + *

Operation description * * @param name Person name. * @param age Person age. From 8554fda1eabdabb269f80c3dbc6661920116fdff Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 31 Jul 2025 17:42:15 -0300 Subject: [PATCH 07/17] openapi: redo parsing + integrate parameters and operation doc - make parser safe - simplify/cleanup code - integrate with openapi operations and parameters and schemas - ref #3733 --- .../internal/openapi/AnnotationParser.java | 68 +++--- .../internal/openapi/ModelConverterExt.java | 3 +- .../internal/openapi/ModelConvertersExt.java | 141 ++++++++++++ .../jooby/internal/openapi/ParserContext.java | 117 ++++++++-- .../internal/openapi/ResolvedSchemaExt.java | 16 ++ .../internal/openapi/javadoc/ClassDoc.java | 217 +++++++++++++++--- .../internal/openapi/javadoc/FieldDoc.java | 29 +++ .../openapi/javadoc/JavaDocContext.java | 64 ------ .../internal/openapi/javadoc/JavaDocNode.java | 172 +++++++------- .../openapi/javadoc/JavaDocParser.java | 158 ++++++++----- .../internal/openapi/javadoc/MethodDoc.java | 139 +---------- .../io/jooby/openapi/OpenAPIGenerator.java | 31 ++- .../test/java/issues/i3729/api/Address.java | 12 + .../java/issues/i3729/api/ApiDocTest.java | 30 ++- .../java/issues/i3729/api/AppLibrary.java | 7 + .../test/java/issues/i3729/api/Author.java | 5 + .../src/test/java/issues/i3729/api/Book.java | 14 +- .../src/test/java/issues/i3729/api/Type.java | 14 +- .../test/java/javadoc/JavaDocParserTest.java | 142 ++++++++++-- .../src/test/java/javadoc/PrintAstTree.java | 40 ++++ .../src/test/java/javadoc/PrinteAstTree.java | 22 -- .../src/test/java/javadoc/input/EnumDoc.java | 2 +- .../test/java/javadoc/input/InterfaceDoc.java | 17 ++ .../test/java/javadoc/input/QueryBeanDoc.java | 4 +- .../java/javadoc/input/RecordBeanDoc.java | 2 +- .../src/test/java/javadoc/input/ScopeDoc.java | 48 ++++ 26 files changed, 1015 insertions(+), 499 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConvertersExt.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ResolvedSchemaExt.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java delete mode 100644 modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/InterfaceDoc.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/input/ScopeDoc.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 6943efb3a4..68defb52c3 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 @@ -10,8 +10,6 @@ import static java.util.Arrays.asList; import static java.util.Collections.singletonList; -import java.io.File; -import java.nio.file.Paths; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -22,10 +20,7 @@ import org.objectweb.asm.Type; import org.objectweb.asm.tree.*; -import io.jooby.Context; -import io.jooby.MediaType; -import io.jooby.Router; -import io.jooby.Session; +import io.jooby.*; import io.jooby.annotation.ContextParam; import io.jooby.annotation.CookieParam; import io.jooby.annotation.FormParam; @@ -34,7 +29,6 @@ import io.jooby.annotation.Path; import io.jooby.annotation.PathParam; import io.jooby.annotation.QueryParam; -import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; @@ -274,38 +268,49 @@ public static List parse(ParserContext ctx, String prefix, Type ty ctx.debugHandler(method); result.addAll(routerMethod(ctx, prefix, classNode, method)); } - var javadocContext = ctx.javadoc(); - var javadocParser = new JavaDocParser(javadocContext); + var javaDocParser = ctx.javadoc(); for (OperationExt operationExt : result) { operationExt.setController(classNode); try { - var className = operationExt.getControllerName(); - javadocParser - .parse(toJavaPath(className)) + var className = operationExt.getControllerName().replace("/", "."); + javaDocParser + .parse(className) .ifPresent( doc -> { operationExt.setPathDescription(doc.getDescription()); operationExt.setPathSummary(doc.getSummary()); - var args = - operationExt.getParameters().stream() - .map(ParameterExt.class::cast) - .map( - p -> - Optional.ofNullable(p.getContainerType()).orElse(p.getJavaType())) - .map( - qualifiedName -> { - var index = qualifiedName.lastIndexOf('.'); - return index == -1 - ? qualifiedName - : qualifiedName.substring(index + 1); - }) + var parameterNames = + Optional.ofNullable(operationExt.getNode().parameters) + .orElse(List.of()) + .stream() + .map(p -> p.name) .toList(); - doc.getMethod(operationExt.getOperationId(), args) + doc.getMethod(operationExt.getOperationId(), parameterNames) .ifPresent( methodDoc -> { operationExt.setSummary(methodDoc.getSummary()); operationExt.setDescription(methodDoc.getDescription()); - + 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, + Optional.ofNullable(paramExt) + .map(ParameterExt::getContainerType) + .orElse(null)); + if (paramDoc != null) { + if (paramExt == null) { + operationExt.getRequestBody().setDescription(paramDoc); + } else { + paramExt.setDescription(paramDoc); + } + } + } for (var parameter : operationExt.getParameters()) { var paramExt = (ParameterExt) parameter; var paramDoc = @@ -318,19 +323,12 @@ public static List parse(ParserContext ctx, String prefix, Type ty }); }); } catch (Exception x) { - // TODO: how to log from here - x.printStackTrace(); + throw SneakyThrows.propagate(x); } } return result; } - private static java.nio.file.Path toJavaPath(String className) { - var segments = className.split("/"); - segments[segments.length - 1] = segments[segments.length - 1] + ".java"; - return Paths.get(String.join(File.separator, segments)); - } - private static Map methods(ParserContext ctx, ClassNode node) { Map methods = new LinkedHashMap<>(); if (node.superName != null && !node.superName.equals(TypeFactory.OBJECT.getInternalName())) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java index da75514c89..67e9f1e85e 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java @@ -10,7 +10,6 @@ import java.util.Iterator; import java.util.Set; -import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import io.jooby.FileUpload; import io.jooby.Jooby; @@ -37,7 +36,7 @@ public ModelConverterExt(ObjectMapper mapper) { @Override public Schema resolve( AnnotatedType type, ModelConverterContext context, Iterator chain) { - JavaType javaType = _mapper.getTypeFactory().constructType(type.getType()); + var javaType = _mapper.getTypeFactory().constructType(type.getType()); if (javaType.isCollectionLikeType() || javaType.isArrayType()) { if (isFile(javaType.getContentType().getRawClass())) { return new ArraySchema().items(new FileSchema()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConvertersExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConvertersExt.java new file mode 100644 index 0000000000..1391a3e1e3 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConvertersExt.java @@ -0,0 +1,141 @@ +/* + * 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.lang.reflect.Type; +import java.util.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.swagger.v3.core.converter.*; +import io.swagger.v3.core.util.ReferenceTypeUtils; +import io.swagger.v3.oas.models.media.Schema; + +public class ModelConvertersExt extends ModelConverters { + + /** Copy of {@link ModelConverterContextImpl} required for access to schemas by class name. */ + private static class ModelConverterContextExt implements ModelConverterContext { + private static final Logger LOGGER = LoggerFactory.getLogger(ModelConverterContextExt.class); + + private final List converters; + private final Map modelByName; + private final HashMap modelByType; + private final Set processedTypes; + + public ModelConverterContextExt(List converters) { + this.converters = converters; + modelByName = new TreeMap<>(); + modelByType = new HashMap<>(); + processedTypes = new HashSet<>(); + } + + public ModelConverterContextExt(ModelConverter converter) { + this(new ArrayList()); + converters.add(converter); + } + + @Override + public Iterator getConverters() { + return converters.iterator(); + } + + @Override + public void defineModel(String name, Schema model) { + AnnotatedType aType = null; + defineModel(name, model, aType, null); + } + + @Override + public void defineModel(String name, Schema model, Type type, String prevName) { + defineModel(name, model, new AnnotatedType().type(type), prevName); + } + + @Override + public void defineModel(String name, Schema model, AnnotatedType type, String prevName) { + if (LOGGER.isTraceEnabled()) { + LOGGER.trace(String.format("defineModel %s %s", name, model)); + } + modelByName.put(name, model); + + if (prevName != null && !prevName.isBlank() && !prevName.equals(name)) { + modelByName.remove(prevName); + } + + if (type != null && type.getType() != null) { + modelByType.put(type, model); + } + } + + @Override + public Map getDefinedModels() { + return Collections.unmodifiableMap(modelByName); + } + + @Override + public Schema resolve(AnnotatedType type) { + AnnotatedType aType = ReferenceTypeUtils.unwrapReference(type); + if (aType != null) { + return resolve(aType); + } + + if (processedTypes.contains(type)) { + return modelByType.get(type); + } else { + processedTypes.add(type); + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("resolve %s", type.getType())); + } + Iterator converters = this.getConverters(); + Schema resolved = null; + if (converters.hasNext()) { + ModelConverter converter = converters.next(); + LOGGER.trace("trying extension {}", converter); + resolved = converter.resolve(type, this, converters); + } + if (resolved != null) { + modelByType.put(type, resolved); + + Schema resolvedImpl = resolved; + if (resolvedImpl.getName() != null) { + modelByName.put(resolvedImpl.getName(), resolved); + } + } else { + processedTypes.remove(type); + } + + return resolved; + } + } + + public ModelConvertersExt() { + super(false); + } + + @Override + public ResolvedSchemaExt readAllAsResolvedSchema(Type type) { + return (ResolvedSchemaExt) super.readAllAsResolvedSchema(type); + } + + @Override + public ResolvedSchemaExt readAllAsResolvedSchema(AnnotatedType type) { + return (ResolvedSchemaExt) super.readAllAsResolvedSchema(type); + } + + @Override + public ResolvedSchemaExt resolveAsResolvedSchema(AnnotatedType type) { + var context = new ModelConverterContextExt(getConverters()); + var resolvedSchema = new ResolvedSchemaExt(); + resolvedSchema.schema = context.resolve(type); + resolvedSchema.referencedSchemas = context.getDefinedModels(); + resolvedSchema.referencedSchemasByType = new HashMap<>(); + context.modelByType.forEach( + (annotatedType, schema) -> + resolvedSchema.referencedSchemasByType.put(annotatedType.getType(), schema)); + return resolvedSchema; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 58cbc14f00..51509198d4 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -65,16 +65,14 @@ import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.type.SimpleType; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import io.jooby.Context; import io.jooby.FileUpload; import io.jooby.SneakyThrows; import io.jooby.StatusCode; -import io.jooby.internal.openapi.javadoc.JavaDocContext; import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.jooby.openapi.DebugOption; -import io.swagger.v3.core.converter.ModelConverters; -import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.RefUtils; import io.swagger.v3.core.util.Yaml; @@ -102,35 +100,35 @@ public TypeLiteral() {} } private String mainClass; - private final ModelConverters converters; + private final ModelConvertersExt converters; private final Type router; private final Map nodes; private final ClassSource source; private final Set instructions = new HashSet<>(); private final Set debug; private final ConcurrentMap schemas = new ConcurrentHashMap<>(); - private final JavaDocContext javadocContext; + private final JavaDocParser javadocParser; public ParserContext( - ClassSource source, Type router, JavaDocContext javadocContext, Set debug) { - this(source, new HashMap<>(), router, javadocContext, debug); + ClassSource source, Type router, JavaDocParser javadocParser, Set debug) { + this(source, new HashMap<>(), router, javadocParser, debug); } private ParserContext( ClassSource source, Map nodes, Type router, - JavaDocContext javadocContext, + JavaDocParser javadocParser, Set debug) { this.router = router; this.source = source; this.debug = Optional.ofNullable(debug).orElse(Collections.emptySet()); this.nodes = nodes; - this.javadocContext = javadocContext; + this.javadocParser = javadocParser; List mappers = asList(Json.mapper(), Yaml.mapper()); jacksonModules(source.getClassLoader(), mappers); - this.converters = ModelConverters.getInstance(); + this.converters = new ModelConvertersExt(); mappers.stream().map(ModelConverterExt::new).forEach(converters::addConverter); } @@ -172,8 +170,8 @@ public Collection schemas() { return schemas.values().stream().map(ref -> ref.schema).collect(Collectors.toList()); } - public JavaDocContext javadoc() { - return javadocContext; + public JavaDocParser javadoc() { + return javadocParser; } public Schema schema(Class type) { @@ -274,7 +272,7 @@ public Schema schema(Class type) { } SchemaRef schemaRef = schemas.get(type.getName()); if (schemaRef == null) { - ResolvedSchema resolvedSchema = converters.readAllAsResolvedSchema(type); + var resolvedSchema = converters.readAllAsResolvedSchema(type); if (resolvedSchema.schema == null) { throw new IllegalArgumentException("Unsupported type: " + type); } @@ -282,14 +280,19 @@ public Schema schema(Class type) { new SchemaRef( resolvedSchema.schema, RefUtils.constructRef(resolvedSchema.schema.getName())); schemas.put(type.getName(), schemaRef); - document(type.getName(), resolvedSchema.schema); + document(type, resolvedSchema.schema, resolvedSchema); if (resolvedSchema.referencedSchemas != null) { - for (Map.Entry e : resolvedSchema.referencedSchemas.entrySet()) { + for (var e : resolvedSchema.referencedSchemas.entrySet()) { if (!e.getKey().equals(schemaRef.schema.getName())) { SchemaRef dependency = new SchemaRef(e.getValue(), RefUtils.constructRef(e.getValue().getName())); schemas.putIfAbsent(e.getKey(), dependency); - document(e.getKey(), dependency.schema); + } + } + for (var e : resolvedSchema.referencedSchemasByType.entrySet()) { + var qualifiedTypeName = toClass(e.getKey()); + if (qualifiedTypeName instanceof Class classType) { + document(classType, e.getValue(), resolvedSchema); } } } @@ -297,8 +300,84 @@ public Schema schema(Class type) { return schemaRef.toSchema(); } - private void document(String typeName, Schema schema) { - var parser = new JavaDocParser(javadocContext); + private java.lang.reflect.Type toClass(java.lang.reflect.Type type) { + if (type instanceof Class) { + return type; + } + if (type instanceof SimpleType simpleType) { + return simpleType.getRawClass(); + } + return type; + } + + private void document(Class typeName, Schema schema, ResolvedSchemaExt resolvedSchema) { + javadocParser + .parse(typeName.getName()) + .ifPresent( + javadoc -> { + Optional.ofNullable(javadoc.getText()).ifPresent(schema::setDescription); + Map properties = schema.getProperties(); + if (properties != null) { + properties.forEach( + (key, value) -> { + var text = javadoc.getPropertyDoc(key); + var propertyType = getPropertyType(typeName, key); + var isEnum = + propertyType != null + && propertyType.isEnum() + && resolvedSchema.referencedSchemasByType.keySet().stream() + .map(this::toClass) + .anyMatch(it -> !it.equals(propertyType)); + if (isEnum) { + javadocParser + .parse(propertyType.getName()) + .ifPresent( + enumDoc -> { + var enumDesc = enumDoc.getEnumDescription(text); + if (enumDesc != null) { + value.setDescription(enumDesc); + } + }); + } else { + value.setDescription(text); + } + }); + } + }); + } + + public Class getPropertyType(Class clazz, String name) { + Class type = null; + while (type == null && clazz != Object.class) { + type = getGetter(clazz, List.of(name, getName(name))); + if (type == null) { + type = getField(clazz, name); + } + clazz = clazz.getSuperclass(); + } + return type; + } + + private Class getField(Class clazz, String name) { + try { + return clazz.getDeclaredField(name).getType(); + } catch (NoSuchFieldException e) { + return null; + } + } + + private Class getGetter(Class clazz, List names) { + for (String name : names) { + try { + return clazz.getDeclaredMethod(name).getReturnType(); + } catch (NoSuchMethodException ignored) { + } + } + return null; + } + + private String getName(String name) { + return "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1); } public Optional schemaRef(String type) { @@ -447,7 +526,7 @@ public boolean process(AbstractInsnNode instruction) { } public ParserContext newContext(Type router) { - return new ParserContext(source, nodes, router, javadocContext, debug); + return new ParserContext(source, nodes, router, javadocParser, debug); } public String getMainClass() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ResolvedSchemaExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ResolvedSchemaExt.java new file mode 100644 index 0000000000..e6a1fbf005 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ResolvedSchemaExt.java @@ -0,0 +1,16 @@ +/* + * 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.lang.reflect.Type; +import java.util.Map; + +import io.swagger.v3.core.converter.ResolvedSchema; +import io.swagger.v3.oas.models.media.Schema; + +public class ResolvedSchemaExt extends ResolvedSchema { + public Map referencedSchemasByType; +} 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 80de626623..ef22251abc 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 @@ -7,66 +7,209 @@ import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; +import com.puppycrawl.tools.checkstyle.DetailAstImpl; import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import com.puppycrawl.tools.checkstyle.utils.TokenUtil; public class ClassDoc extends JavaDocNode { + private final Map fields = new LinkedHashMap<>(); + private final Map methods = new LinkedHashMap<>(); - private final DetailAST node; - private final List methods = new ArrayList<>(); + public ClassDoc(JavaDocParser ctx, DetailAST node, DetailAST javaDoc) { + super(ctx, node, javaDoc); + if (isRecord()) { + defaultRecordMembers(); + } else if (isEnum()) { + defaultEnumMembers(); + } + } - public ClassDoc(JavaDocContext ctx, DetailAST node, DetailAST javaDoc) { - super(ctx, javaDoc); - this.node = node; + public String getVersion() { + return tree(javadoc) + .filter(javadocToken(JavadocTokenTypes.VERSION_LITERAL)) + .findFirst() + .flatMap( + version -> + tree(version.getParent()) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .flatMap( + it -> tree(it).filter(javadocToken(JavadocTokenTypes.TEXT)).findFirst()) + .map(DetailNode::getText)) + .orElse(null); } - public void addMethod(MethodDoc method) { - this.methods.add(method); + public String getEnumDescription(String text) { + if (isEnum()) { + var sb = new StringBuilder(); + var summary = Optional.ofNullable(text).orElseGet(this::getSummary); + if (summary != null) { + sb.append(summary); + } + for (Map.Entry e : fields.entrySet()) { + sb.append("\n - ").append(e.getKey()).append(": ").append(e.getValue().getText()); + } + return sb.toString().trim(); + } + return text; } - public Optional getMethod(String name, List parameterNames) { - var filtered = methods.stream().filter(it -> it.getName().equals(name)).toList(); - if (filtered.isEmpty()) { - return Optional.empty(); + private void defaultRecordMembers() { + for (var tag : tree(javadoc).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { + var isParam = tree(tag).anyMatch(javadocToken(JavadocTokenTypes.PARAM_LITERAL)); + var name = + tree(tag).filter(javadocToken(JavadocTokenTypes.PARAMETER_NAME)).findFirst().orElse(null); + if (isParam && name != null) { + /* Virtual Field */ + var memberDoc = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .orElse(EMPTY_NODE); + var field = + new FieldDoc( + context, createVirtualMember(name.getText(), TokenTypes.VARIABLE_DEF), memberDoc); + addField(field); + /* Virtual method */ + var method = + new MethodDoc( + context, createVirtualMember(name.getText(), TokenTypes.METHOD_DEF), memberDoc); + addMethod(method); + } } - if (filtered.size() == 1) { - return Optional.of(filtered.getFirst()); + } + + private void defaultEnumMembers() { + for (var constant : tree(node).filter(tokens(TokenTypes.ENUM_CONSTANT_DEF)).toList()) { + /* Virtual Field */ + var name = + tree(constant) + .filter(tokens(TokenTypes.IDENT)) + .findFirst() + .map(DetailAST::getText) + .orElseThrow(() -> new IllegalStateException("Unnamed constant: " + constant)); + var comment = + tree(constant) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(JavaDocNode.EMPTY_AST); + var field = + new FieldDoc(context, createVirtualMember(name, TokenTypes.VARIABLE_DEF), comment); + addField(field); } - return filtered.stream() - .filter(it -> it.getParameterNames().equals(parameterNames)) - .findFirst(); + } + + private static DetailAstImpl createVirtualMember(String name, int tokenType) { + var publicMod = new DetailAstImpl(); + publicMod.initialize( + TokenTypes.LITERAL_PUBLIC, TokenUtil.getTokenName(TokenTypes.LITERAL_PUBLIC)); + var modifiers = new DetailAstImpl(); + modifiers.initialize(TokenTypes.MODIFIERS, TokenUtil.getTokenName(tokenType)); + modifiers.addChild(publicMod); + var memberName = new DetailAstImpl(); + memberName.initialize(TokenTypes.IDENT, name); + var member = new DetailAstImpl(); + member.initialize(tokenType, TokenUtil.getTokenName(tokenType)); + memberName.addChild(modifiers); + member.addChild(memberName); + return member; + } + + public void addMethod(MethodDoc method) { + this.methods.put(toMethodSignature(method), method); + } + + public void addField(FieldDoc field) { + this.fields.put(field.getName(), field); + } + + 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))); + } + + private String toMethodSignature(MethodDoc method) { + return toMethodSignature(method.getName(), method.getParameterNames()); + } + + private String toMethodSignature(String methodName, List parameterNames) { + return methodName + parameterNames.stream().collect(Collectors.joining(", ", "(", ")")); } public String getSimpleName() { + return getSimpleName(node); + } + + protected String getSimpleName(DetailAST node) { return node.findFirstToken(TokenTypes.IDENT).getText(); } public String getName() { - return forward(node.getParent()) - .filter(tokens(TokenTypes.PACKAGE_DEF)) - .map( - it -> - tree(it) - .filter(tokens(TokenTypes.DOT, TokenTypes.IDENT)) - .findFirst() - .orElse(null)) + 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(); + var packageScope = + 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(".")); + } + + public boolean isRecord() { + return tree(node).anyMatch(tokens(TokenTypes.RECORD_DEF)); + } + + public boolean isEnum() { + return tree(node).anyMatch(tokens(TokenTypes.ENUM_DEF)); + } + + public String getPropertyDoc(String name) { + var getterDoc = + Stream.of(name, getterName(name)) + .map(n -> methods.get(toMethodSignature(n, List.of()))) .filter(Objects::nonNull) - .flatMap( - it -> - tree(it) - .filter(tokens(TokenTypes.DOT, TokenTypes.SEMI).negate()) - .map(DetailAST::getText)) - .collect(Collectors.joining(".", "", ".")) - + getSimpleName(); + .findFirst() + .map(MethodDoc::getText) + .orElse(null); + if (getterDoc == null) { + var field = fields.get(name); + return field == null ? null : field.getText(); + } + return getterDoc; + } + + private String getterName(String name) { + return "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1); } - public List getMethods() { - return methods; + @Override + public String toString() { + return "fields: " + + String.join(", ", fields.keySet()) + + "\nmethods: " + + String.join(", ", methods.keySet()); } } 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 new file mode 100644 index 0000000000..8e217876f3 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java @@ -0,0 +1,29 @@ +/* + * 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; +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) { + super(ctx, node, javadoc); + } + + FieldDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { + super(ctx, node, javadoc); + } + + public String getName() { + return node.findFirstToken(TokenTypes.IDENT).getText(); + } + + @Override + public String getText() { + return super.getText(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java deleted file mode 100644 index da6855e47c..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocContext.java +++ /dev/null @@ -1,64 +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 static com.puppycrawl.tools.checkstyle.JavaParser.parseFile; -import static io.jooby.SneakyThrows.throwingFunction; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import com.puppycrawl.tools.checkstyle.JavaParser; -import com.puppycrawl.tools.checkstyle.api.DetailAST; - -public class JavaDocContext { - private final List baseDir; - private final Map cache = new HashMap<>(); - - public JavaDocContext(Path baseDir) { - this(List.of(baseDir)); - } - - public JavaDocContext(List baseDir) { - this.baseDir = baseDir; - } - - public DetailAST resolve(Path path) { - return lookup(path) - .map( - it -> - cache.computeIfAbsent( - it, - throwingFunction( - filePath -> { - return parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); - }))) - .orElse(JavaDocNode.EMPTY_AST); - } - - public DetailAST resolveType(Class type) { - return resolveType(type.getName()); - } - - public DetailAST resolveType(String typeName) { - var segments = typeName.split("\\."); - segments[segments.length - 1] = segments[segments.length - 1] + ".java"; - return resolve(Paths.get(String.join(File.separator, segments))); - } - - protected Optional lookup(Path path) { - return baseDir.stream() - .map(parentDir -> parentDir.resolve(path)) - .filter(Files::exists) - .findFirst(); - } -} 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 35da662a5e..a541f3a49b 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 @@ -18,6 +18,92 @@ import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; public class JavaDocNode { + private static final Predicate JAVADOC_TAG = + JavaDocSupport.javadocToken(JavadocTokenTypes.JAVADOC_TAG); + + protected final JavaDocParser context; + protected final DetailAST node; + protected final DetailNode javadoc; + + public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) { + this(ctx, node, toJavaDocNode(comment)); + } + + protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { + this.context = ctx; + this.node = node; + this.javadoc = javadoc; + } + + static DetailNode toJavaDocNode(DetailAST node) { + return node == EMPTY_AST + ? EMPTY_NODE + : new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); + } + + public String getSummary() { + var builder = new StringBuilder(); + for (var node : forward(javadoc, JAVADOC_TAG).toList()) { + if (node.getType() == JavadocTokenTypes.TEXT) { + var text = node.getText(); + var trimmed = text.trim(); + if (trimmed.isEmpty()) { + if (!builder.isEmpty()) { + builder.append(text); + } + } else { + builder.append(text); + } + } else if (node.getType() == JavadocTokenTypes.NEWLINE && !builder.isEmpty()) { + break; + } + var index = builder.indexOf("."); + if (index > 0) { + builder.setLength(index + 1); + break; + } + } + var string = builder.toString().trim(); + return string.isEmpty() ? null : string; + } + + public String getDescription() { + var text = getText(); + var summary = getSummary(); + if (summary == null) { + return text; + } + return summary.equals(text) ? null : text.replaceAll(summary, "").trim(); + } + + public String getText() { + return getText(JavaDocSupport.forward(javadoc, JAVADOC_TAG).toList(), false); + } + + protected String getText(List nodes, boolean stripLeading) { + var builder = new StringBuilder(); + 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()); + } + } + } + return builder.isEmpty() ? null : builder.toString().trim(); + } + + protected String toString(DetailNode node) { + return DetailNodeTreeStringPrinter.printTree(node, "", ""); + } + public static final DetailNode EMPTY_NODE = new DetailNode() { @Override @@ -52,7 +138,7 @@ public DetailNode getParent() { @Override public int getIndex() { - return 0; + return JavadocTokenTypes.TEXT; } }; @@ -133,88 +219,4 @@ public boolean hasChildren() { return false; } }; - - protected final JavaDocContext context; - protected final DetailNode javadoc; - private static final Predicate JAVADOC_TAG = - JavaDocSupport.javadocToken(JavadocTokenTypes.JAVADOC_TAG); - - public JavaDocNode(JavaDocContext ctx, DetailAST node) { - this.context = ctx; - this.javadoc = toJavaDocNode(node); - } - - static DetailNode toJavaDocNode(DetailAST node) { - return node == EMPTY_AST - ? EMPTY_NODE - : new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); - } - - public String getSummary() { - var builder = new StringBuilder(); - for (var node : forward(javadoc, JAVADOC_TAG).toList()) { - if (node.getType() == JavadocTokenTypes.TEXT) { - var text = node.getText(); - var trimmed = text.trim(); - if (trimmed.isEmpty()) { - if (!builder.isEmpty()) { - builder.append(text); - } - } else { - builder.append(text); - } - } else if (node.getType() == JavadocTokenTypes.NEWLINE && !builder.isEmpty()) { - break; - } - var index = builder.indexOf("."); - if (index > 0) { - builder.setLength(index + 1); - break; - } - } - var string = builder.toString().trim(); - return string.isEmpty() ? null : string; - } - - public String getDescription() { - var text = getText(); - var summary = getSummary(); - if (summary == null) { - return text; - } - return summary.equals(text) ? null : text.replaceAll(summary, "").trim(); - } - - protected String getText() { - return getText(JavaDocSupport.forward(javadoc, JAVADOC_TAG).toList(), false); - } - - protected String getText(List nodes, boolean stripLeading) { - var builder = new StringBuilder(); - 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()); - } - } - } - return builder.isEmpty() ? null : builder.toString().trim(); - } - - @Override - public String toString() { - return toString(javadoc); - } - - protected 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 5037de76cc..1ef99d687e 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 @@ -5,76 +5,130 @@ */ package io.jooby.internal.openapi.javadoc; +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 java.io.File; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; +import java.nio.file.Paths; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import java.util.function.Predicate; +import com.puppycrawl.tools.checkstyle.JavaParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.TokenTypes; public class JavaDocParser { - private static final Predicate HAS_CLASS = - it -> backward(it).anyMatch(tokens(TokenTypes.CLASS_DEF)); + private final List baseDir; + private final Map cache = new HashMap<>(); - private static final Predicate SINGLE_LINE_COMMENT = - it -> backward(it).anyMatch(tokens(TokenTypes.SINGLE_LINE_COMMENT)); + public JavaDocParser(Path baseDir) { + this(List.of(baseDir)); + } + + public JavaDocParser(List baseDir) { + this.baseDir = baseDir; + } + + public Optional parse(String typeName) { + return Optional.ofNullable(traverse(resolveType(typeName)).get(typeName)); + } - private final JavaDocContext context; + 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, + modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_PRIVATE)), + (scope, comment) -> { + var counter = new AtomicInteger(0); + counter.addAndGet(comment == JavaDocNode.EMPTY_AST ? 0 : 1); + var classDoc = new ClassDoc(this, scope, comment); - public JavaDocParser(JavaDocContext context) { - this.context = context; + traverse( + scope, + 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 (member.getType() == TokenTypes.VARIABLE_DEF) { + classDoc.addField(new FieldDoc(this, member, memberComment)); + } else { + classDoc.addMethod(new MethodDoc(this, member, memberComment)); + } + } + }); + + if (classDoc.isRecord()) { + // complement with record parameter + } + if (counter.get() > 0) { + classes.put(classDoc.getName(), classDoc); + } + }); + return classes; } - public Optional parse(Path filePath) throws Exception { - ClassDoc result = null; - var tree = context.resolve(filePath); - for (var comment : - forward(tree) - .filter(tokens(TokenTypes.COMMENT_CONTENT)) - .filter(HAS_CLASS) - .filter(SINGLE_LINE_COMMENT.negate()) - .toList()) { - var classOrMethod = classOrMethod(comment); - if (classOrMethod.getType() == TokenTypes.METHOD_DEF) { - if (result == null) { - // No comment on class - result = - new ClassDoc( - context, - tree(tree) - .filter( - tokens( - TokenTypes.ENUM_DEF, - TokenTypes.CLASS_DEF, - TokenTypes.INTERFACE_DEF, - TokenTypes.RECORD_DEF)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Class not found " + tree)), - JavaDocNode.EMPTY_AST); - } - var method = new MethodDoc(context, classOrMethod, comment.getParent()); - result.addMethod(method); - } else { - // always as class - result = new ClassDoc(context, classOrMethod, comment.getParent()); + 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); } } - return Optional.ofNullable(result); } - private static DetailAST classOrMethod(DetailAST comment) { - return backward(comment) - .filter( - tokens( - TokenTypes.METHOD_DEF, - TokenTypes.ENUM_DEF, - TokenTypes.CLASS_DEF, - TokenTypes.INTERFACE_DEF, - TokenTypes.RECORD_DEF)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Invalid comment: " + comment.getText())); + public DetailAST resolve(Path path) { + return lookup(path) + .map( + it -> + cache.computeIfAbsent( + it, + throwingFunction( + filePath -> { + return parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); + }))) + .orElse(JavaDocNode.EMPTY_AST); + } + + private DetailAST resolveType(String typeName) { + var segments = typeName.split("\\."); + segments[segments.length - 1] = segments[segments.length - 1] + ".java"; + return resolve(Paths.get(String.join(File.separator, segments))); + } + + protected Optional lookup(Path path) { + return baseDir.stream() + .map(parentDir -> parentDir.resolve(path)) + .filter(Files::exists) + .findFirst(); } } 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 83bfcf9a3d..5fe7a9c68d 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 @@ -9,43 +9,26 @@ import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.stream.Stream; import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; public class MethodDoc extends JavaDocNode { - private final DetailAST node; + public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { + super(ctx, node, javadoc); + } - public MethodDoc(JavaDocContext ctx, DetailAST node, DetailAST javadoc) { - super(ctx, javadoc); - this.node = node; + MethodDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { + super(ctx, node, javadoc); } public String getName() { return node.findFirstToken(TokenTypes.IDENT).getText(); } - public List getParameterTypes() { - var result = new ArrayList(); - for (var parameterDef : tree(node).filter(tokens(TokenTypes.PARAMETER_DEF)).toList()) { - var typeNode = - tree(parameterDef) - .filter(tokens(TokenTypes.TYPE)) - .findFirst() - .orElseThrow( - () -> new IllegalStateException("Parameter type not found: " + parameterDef)); - if (typeNode.getFirstChild().getType() == TokenTypes.DOT) { - result.add(typeNode.getFirstChild().getLastChild().getText()); - } else { - result.add(typeNode.getFirstChild().getText()); - } - } - return result; - } - public List getParameterNames() { var result = new ArrayList(); var index = 0; @@ -68,11 +51,7 @@ public String getParameterDoc(String name) { public String getParameterDoc(String name, String in) { if (in != null) { - var tree = context.resolveType(in); - if (tree == JavaDocNode.EMPTY_AST) { - return null; - } - return getPropertyDoc(tree, name); + return context.parse(in).map(bean -> bean.getPropertyDoc(name)).orElse(null); } return tree(javadoc) // must be a tag @@ -112,108 +91,4 @@ public String getReturnDoc() { .map(it -> getText(tree(it).toList(), true)) .orElse(null); } - - private String getPropertyDoc(DetailAST bean, String name) { - String comment; - var isRecord = tree(bean).anyMatch(tokens(TokenTypes.RECORD_DEF)); - if (isRecord) { - comment = commentFromRecord(bean, name); - } else { - comment = commentFromGetter(bean, name); - if (comment == null) { - comment = commentFromField(bean, name); - } - } - return comment; - } - - private String commentFromRecord(DetailAST bean, String name) { - var commentNode = - tree(bean) - .filter(tokens(TokenTypes.RECORD_DEF)) - .findFirst() - .flatMap(it -> Optional.ofNullable(commentFromMember(it))) - .map(JavaDocNode::toJavaDocNode) - .orElse(null); - if (commentNode == null) { - return null; - } - - for (var tag : tree(commentNode).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { - var isParam = tree(tag).anyMatch(javadocToken(JavadocTokenTypes.PARAM_LITERAL)); - var matchesName = - tree(tag) - .filter(javadocToken(JavadocTokenTypes.PARAMETER_NAME)) - .findFirst() - .filter(it -> it.getText().equals(name)) - .isPresent(); - if (isParam && matchesName) { - return getText( - tree(tag) - .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) - .flatMap(it -> Stream.of(it.getChildren())) - .toList(), - true); - } - } - - return null; - } - - private String commentFromGetter(DetailAST bean, String name) { - var methods = JavaDocSupport.forward(bean).filter(tokens(TokenTypes.METHOD_DEF)).toList(); - var getterName = "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1); - for (var method : methods) { - var noArgs = tree(method).noneMatch(tokens(TokenTypes.PARAMETER_DEF)); - var isPublic = tree(method).anyMatch(tokens(TokenTypes.LITERAL_PUBLIC)); - var methodName = - children(method) - .filter(tokens(TokenTypes.IDENT)) - .map(DetailAST::getText) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Method name not found")); - if (noArgs && isPublic && (methodName.equals(getterName) || methodName.equals(name))) { - var comment = commentFromMember(method); - if (comment != null) { - return textFromComment(comment); - } - } - } - return null; - } - - private String textFromComment(DetailAST comment) { - return comment == null ? null : new JavaDocNode(context, comment).getText(); - } - - private String commentFromField(DetailAST bean, String name) { - for (var field : - JavaDocSupport.forward(bean).filter(tokens(TokenTypes.VARIABLE_DEF)).toList()) { - var isInstance = tree(field).noneMatch(tokens(TokenTypes.LITERAL_STATIC)); - var fieldName = - children(field) - .filter(tokens(TokenTypes.IDENT)) - .map(DetailAST::getText) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Field name not found")); - if (isInstance && fieldName.equals(name)) { - var comment = commentFromMember(field); - if (comment != null) { - return textFromComment(comment); - } - } - } - return null; - } - - private static DetailAST commentFromMember(DetailAST member) { - var modifiers = tree(member).filter(tokens(TokenTypes.MODIFIERS)).findFirst().orElse(null); - if (modifiers != null) { - return tree(modifiers) - .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) - .findFirst() - .orElse(null); - } - return null; - } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 95e64d3c4f..04f4ad1063 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -8,13 +8,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -26,7 +20,7 @@ import io.jooby.Router; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.*; -import io.jooby.internal.openapi.javadoc.JavaDocContext; +import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Yaml; import io.swagger.v3.oas.models.OpenAPI; @@ -150,16 +144,31 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { public @NonNull OpenAPI generate(@NonNull String classname) { ClassLoader classLoader = Optional.ofNullable(this.classLoader).orElseGet(getClass()::getClassLoader); + ClassSource source = new ClassSource(classLoader); /* Create OpenAPI from template and make sure min required information is present: */ OpenAPIExt openapi = OpenApiTemplate.fromTemplate(basedir, classLoader, templateName).orElseGet(OpenAPIExt::new); + var mainType = TypeFactory.fromJavaName(classname); + var javadoc = new JavaDocParser(sources); + + if (openapi.getInfo() == null) { + var info = new Info(); + openapi.setInfo(info); + javadoc + .parse(classname) + .ifPresent( + doc -> { + Optional.ofNullable(doc.getSummary()).ifPresent(info::setTitle); + Optional.ofNullable(doc.getDescription()).ifPresent(info::setDescription); + Optional.ofNullable(doc.getVersion()).ifPresent(info::setVersion); + }); + } + RouteParser routes = new RouteParser(); - ParserContext ctx = - new ParserContext( - source, TypeFactory.fromJavaName(classname), new JavaDocContext(sources), debug); + ParserContext ctx = new ParserContext(source, mainType, javadoc, debug); List operations = routes.parse(ctx, openapi); String contextPath = ContextPathParser.parse(ctx); diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java index d9f05f3b30..3077349f50 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java @@ -5,10 +5,17 @@ */ package issues.i3729.api; +/** Author address. */ public class Address { + /** Street name. */ private String street; + private String city; + + /** State. */ private String state; + + /** Two digit country code. */ private String country; public String getStreet() { @@ -19,6 +26,11 @@ public void setStreet(String street) { this.street = street; } + /** + * City name. + * + * @return City name. + */ public String getCity() { return city; } 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 96ff596b78..b37f9029c2 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 @@ -17,9 +17,9 @@ public void shouldGenerateDoc(OpenAPIResult result) { assertEquals( "openapi: 3.0.1\n" + "info:\n" - + " title: Library API\n" - + " description: Library API description\n" - + " version: \"1.0\"\n" + + " title: Library API.\n" + + " description: \"Available data: Books and authors.\"\n" + + " version: 4.0.0\n" + "paths:\n" + " /api/library/{isbn}:\n" + " summary: Library API.\n" @@ -50,6 +50,7 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " summary: Creates a new book.\n" + " operationId: createBook\n" + " requestBody:\n" + + " description: Book to create.\n" + " content:\n" + " application/json:\n" + " schema:\n" @@ -69,49 +70,66 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " properties:\n" + " street:\n" + " type: string\n" + + " description: Street name.\n" + " city:\n" + " type: string\n" + + " description: City name.\n" + " state:\n" + " type: string\n" + + " description: State.\n" + " country:\n" + " type: string\n" + + " description: Two digit country code.\n" + + " description: Author address.\n" + " Book:\n" + " type: object\n" + " properties:\n" + " isbn:\n" + " type: string\n" + + " description: Book ISBN. Method.\n" + " title:\n" + " type: string\n" + + " description: Book's title.\n" + " publicationDate:\n" + " type: string\n" + + " description: Publication date. Format mm-dd-yyyy.\n" + " format: date\n" + " text:\n" + " type: string\n" + " type:\n" + " type: string\n" + + " description: |-\n" + + " Book type.\n" + + " - Fiction: Fiction books are based on imaginary characters and events," + + " while non-fiction books are based o n real people and events.\n" + + " - NonFiction: Non-fiction genres include biography, autobiography," + + " history, self-help, and true crime.\n" + " enum:\n" - + " - Book\n" - + " - Manual\n" + + " - Fiction\n" + + " - NonFiction\n" + " authors:\n" + " uniqueItems: true\n" + " type: array\n" + " items:\n" + " $ref: \"#/components/schemas/Author\"\n" + + " description: Book model.\n" + " Author:\n" + " type: object\n" + " properties:\n" + " ssn:\n" + " type: string\n" + + " description: Social security number.\n" + " name:\n" + " type: string\n" + + " description: Author's name.\n" + " address:\n" + " $ref: \"#/components/schemas/Address\"\n" + " books:\n" + " uniqueItems: true\n" + " type: array\n" + + " description: Published books.\n" + " items:\n" + " $ref: \"#/components/schemas/Book\"\n", result.toYaml()); - System.out.println(result.toYaml()); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java index a91e03c754..6ab8d98f55 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java @@ -9,6 +9,13 @@ import io.jooby.Jooby; +/** + * Library API. + * + *

Available data: Books and authors. + * + * @version 4.0.0 + */ public class AppLibrary extends Jooby { { diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java index 9bd057d961..0901c5853e 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java @@ -8,12 +8,17 @@ import java.util.Set; public class Author { + /* + * Social security number. + */ String ssn; + /** Author's name. */ String name; Address address; + /** Published books. */ Set books; public String getSsn() { diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java index 0ff9e6ea3a..bfc4c2bc3e 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java @@ -8,19 +8,29 @@ import java.time.LocalDate; import java.util.Set; +/** Book model. */ public class Book { - String isbn; + /** Book ISBN. */ + private String isbn; + /** Book's title. */ String title; + /** Publication date. Format mm-dd-yyyy. */ LocalDate publicationDate; String text; - Type type = Type.Book; + /** Book type. */ + Type type = Type.Fiction; Set authors; + /** + * Book ISBN. Method. + * + * @return Book ISBN. Method. + */ public String getIsbn() { return isbn; } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java index c258527143..1711a5998d 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java @@ -5,9 +5,17 @@ */ package issues.i3729.api; -/** Book type. */ +/** + * Books can be broadly categorized into fiction and non-fiction. With many genres and subgenres + * within each. + */ public enum Type { - Book, + /** + * Fiction books are based on imaginary characters and events, while non-fiction books are based o + * n real people and events. + */ + Fiction, - Manual + /** Non-fiction genres include biography, autobiography, history, self-help, and true crime. */ + NonFiction; } diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 016d751058..46f9081ec7 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -11,23 +11,23 @@ import java.nio.file.Paths; import java.util.List; import java.util.function.Consumer; +import javadoc.input.EnumDoc; import org.junit.jupiter.api.Test; -import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; -import com.puppycrawl.tools.checkstyle.JavaParser; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.javadoc.ClassDoc; -import io.jooby.internal.openapi.javadoc.JavaDocContext; +import io.jooby.internal.openapi.javadoc.FieldDoc; import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.jooby.internal.openapi.javadoc.MethodDoc; +import issues.i3729.api.Book; public class JavaDocParserTest { @Test public void apiDoc() throws Exception { withDoc( - Paths.get("javadoc", "input", "ApiDoc.java"), + javadoc.input.ApiDoc.class, doc -> { assertEquals("ApiDoc", doc.getSimpleName()); assertEquals("javadoc.input.ApiDoc", doc.getName()); @@ -40,7 +40,7 @@ public void apiDoc() throws Exception { withMethod( doc, "hello", - List.of("List", "int", "List", "String"), + List.of("name", "age", "list", "str"), method -> { assertEquals("This is the Hello /endpoint", method.getSummary()); assertEquals("Operation description", method.getDescription()); @@ -54,7 +54,7 @@ public void apiDoc() throws Exception { withMethod( doc, "search", - List.of("QueryBeanDoc"), + List.of("query"), method -> { assertEquals("Search database.", method.getSummary()); assertEquals("Search DB", method.getDescription()); @@ -71,12 +71,13 @@ public void apiDoc() throws Exception { withMethod( doc, "recordBean", - List.of("RecordBeanDoc"), + List.of("query"), method -> { assertEquals("Record database.", method.getSummary()); assertNull(method.getDescription()); assertEquals( - "Person id.", method.getParameterDoc("id", "javadoc.input.RecordBeanDoc")); + "Person id. Unique person identifier.", + method.getParameterDoc("id", "javadoc.input.RecordBeanDoc")); assertEquals( "Person name. Example: edgar.", method.getParameterDoc("name", "javadoc.input.RecordBeanDoc")); @@ -85,7 +86,7 @@ public void apiDoc() throws Exception { withMethod( doc, "enumParam", - List.of("EnumDoc"), + List.of("query"), method -> { assertEquals("Enum database.", method.getSummary()); assertEquals("Enum doc.", method.getParameterDoc("query")); @@ -95,20 +96,20 @@ public void apiDoc() throws Exception { @Test public void ignoreStatementComment() throws Exception { - var result = newParser().parse(Paths.get("issues", "i1580", "Controller1580.java")); + var result = newParser().parse("issues.i1580.Controller1580"); assertTrue(result.isEmpty()); } @Test public void noDoc() throws Exception { - var result = newParser().parse(Paths.get("javadoc", "input", "NoDoc.java")); + var result = newParser().parse("javadoc.input.NoDoc"); assertTrue(result.isEmpty()); } @Test public void noClassDoc() throws Exception { withDoc( - Paths.get("javadoc", "input", "NoClassDoc.java"), + javadoc.input.NoClassDoc.class, doc -> { assertNull(doc.getSummary()); assertNull(doc.getDescription()); @@ -116,7 +117,7 @@ public void noClassDoc() throws Exception { withMethod( doc, "hello", - List.of("String"), + List.of("name"), methodDoc -> { assertEquals("Method Doc.", methodDoc.getSummary()); assertNull(methodDoc.getDescription()); @@ -124,43 +125,128 @@ public void noClassDoc() throws Exception { }); } + @Test + public void shouldParseEnum() throws Exception { + withDoc( + EnumDoc.class, + doc -> { + assertEquals("Enum summary.", doc.getSummary()); + assertEquals("Enum desc.", doc.getDescription()); + assertEquals( + "Enum summary.\n" + " - Foo: Foo doc.\n" + " - Bar: Bar doc.", doc.getText()); + }); + } + @Test public void shouldParseBean() throws Exception { withDoc( - Paths.get("javadoc", "input", "QueryBeanDoc.java"), + Book.class, doc -> { - assertNull(doc.getSummary()); + assertEquals("Book model.", doc.getSummary()); + assertNull(doc.getDescription()); + + // bean like + assertEquals("Book's title.", doc.getPropertyDoc("title")); + }); + + withDoc( + javadoc.input.QueryBeanDoc.class, + doc -> { + assertEquals("Search options.", doc.getSummary()); assertNull(doc.getDescription()); withMethod( doc, - "hello", - List.of("String"), + "getFq", + List.of(), methodDoc -> { - assertEquals("Method Doc.", methodDoc.getSummary()); + assertEquals("Filter query.", methodDoc.getSummary()); + assertEquals("Works like internal filter.", methodDoc.getDescription()); + }); + + // bean like + assertEquals("Filter query. Works like internal filter.", doc.getPropertyDoc("fq")); + withField( + doc, + "fq", + field -> { + assertEquals("The field comment.", field.getSummary()); + }); + assertEquals("Offset, used for paging.", doc.getPropertyDoc("offset")); + }); + } + + @Test + public void shouldRecord() throws Exception { + withDoc( + javadoc.input.RecordBeanDoc.class, + doc -> { + assertEquals("Record documentation.", doc.getSummary()); + assertNull(doc.getDescription()); + + withMethod( + doc, + "id", + List.of(), + methodDoc -> { + assertEquals("Person id.", methodDoc.getSummary()); + assertEquals("Unique person identifier.", methodDoc.getDescription()); + }); + + // bean like + assertEquals("Person id. Unique person identifier.", doc.getPropertyDoc("id")); + withField( + doc, + "id", + field -> { + assertEquals("Person id.", field.getSummary()); + assertEquals("Unique person identifier.", field.getDescription()); + ; + }); + }); + } + + @Test + public void shouldVerifyJavaDocScope() throws Exception { + withDoc( + javadoc.input.ScopeDoc.class, + doc -> { + assertEquals("Class", doc.getSummary()); + assertNull(doc.getDescription()); + + withMethod( + doc, + "getName", + List.of(), + methodDoc -> { + assertEquals("Method", methodDoc.getSummary()); + assertNull(methodDoc.getDescription()); + }); + + withField( + doc, + "name", + methodDoc -> { + assertEquals("Field", methodDoc.getSummary()); assertNull(methodDoc.getDescription()); }); }); } private JavaDocParser newParser() { - return new JavaDocParser(new JavaDocContext(baseDir())); + return new JavaDocParser(baseDir()); } private Path baseDir() { return Paths.get(System.getProperty("user.dir")).resolve("src").resolve("test").resolve("java"); } - private void withDoc(Path path, Consumer consumer) throws Exception { + private void withDoc(Class typeName, Consumer consumer) throws Exception { try { - var result = newParser().parse(path); + var result = newParser().parse(typeName.getName()); assertFalse(result.isEmpty()); consumer.accept(result.get()); } catch (Throwable cause) { - var stringAst = - AstTreeStringPrinter.printFileAst( - baseDir().resolve(path).toFile(), JavaParser.Options.WITH_COMMENTS); - cause.addSuppressed(new RuntimeException("\n" + stringAst)); throw SneakyThrows.propagate(cause); } } @@ -171,4 +257,10 @@ private void withMethod( assertTrue(method.isPresent()); consumer.accept(method.get()); } + + private void withField(ClassDoc doc, String name, Consumer consumer) { + var method = doc.getField(name); + assertTrue(method.isPresent()); + consumer.accept(method.get()); + } } diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java new file mode 100644 index 0000000000..e1ac3e7104 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -0,0 +1,40 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import javadoc.input.NoDoc; + +import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; +import com.puppycrawl.tools.checkstyle.api.CheckstyleException; + +public class PrintAstTree { + public static void main(String[] args) throws CheckstyleException, IOException { + var baseDir = + Paths.get(System.getProperty("user.dir")) + .resolve("modules") + .resolve("jooby-openapi") + .resolve("src") + .resolve("test") + .resolve("java"); + var stringAst = + AstTreeStringPrinter.printJavaAndJavadocTree(baseDir.resolve(toPath(NoDoc.class)).toFile()); + System.out.println(stringAst); + } + + private static Path toPath(Class typeName) { + return toPath(typeName.getName()); + } + + private static Path toPath(String typeName) { + var segments = typeName.split("\\."); + segments[segments.length - 1] = segments[segments.length - 1] + ".java"; + return Paths.get(String.join(File.separator, segments)); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java deleted file mode 100644 index f275b90072..0000000000 --- a/modules/jooby-openapi/src/test/java/javadoc/PrinteAstTree.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package javadoc; - -import java.io.IOException; -import java.nio.file.Paths; - -import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; -import com.puppycrawl.tools.checkstyle.api.CheckstyleException; - -public class PrinteAstTree { - public static void main(String[] args) throws CheckstyleException, IOException { - var baseDir = - Paths.get(System.getProperty("user.dir")).resolve("modules").resolve("jooby-openapi"); - var input = Paths.get("src", "test", "java", "javadoc", "input", "EnumDoc.java"); - var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree(baseDir.resolve(input).toFile()); - System.out.println(stringAst); - } -} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java index a5190ec41e..06dc4c4385 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java @@ -5,7 +5,7 @@ */ package javadoc.input; -/** Cras dictum. */ +/** Enum summary. Enum desc. */ public enum EnumDoc { /** Foo doc. */ Foo, diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/InterfaceDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/InterfaceDoc.java new file mode 100644 index 0000000000..172bfb13de --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/InterfaceDoc.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +/** Interface documentation. */ +public interface InterfaceDoc { + + /** + * Name. + * + * @return name. + */ + String getName(); +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java index ece28f5518..2d4d3c86d4 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java @@ -13,13 +13,13 @@ public class QueryBeanDoc { public static final int DEFAULT_OFFSET = 0; - /** This comment will be ignored. */ + /** The field comment. */ private String fq; /** Offset, used for paging. */ @Min(0) // Something - private int offset = DEFAULT_OFFSET; + int offset = DEFAULT_OFFSET; private int limit; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java index ed2aa22a3e..affb347ff6 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java @@ -10,7 +10,7 @@ /** * Record documentation. * - * @param id Person id. + * @param id Person id. Unique person identifier. * @param name Person name. Example: edgar. */ public record RecordBeanDoc(String id, @NotEmpty String name) {} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ScopeDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ScopeDoc.java new file mode 100644 index 0000000000..d79c5ec5cd --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ScopeDoc.java @@ -0,0 +1,48 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc.input; + +// Ignored +/** + * Class + * + * @version 3.40.1 + */ +// Ignored +public class ScopeDoc { + + /** Nested */ + public static class Nested { + /** Nested field. */ + private String nestedType; + } + + // Ignored + /** Field */ + // Ignored + private String name; + + /** + * Ignored. + * + * @param name + */ + public ScopeDoc(String name) { + this.name = name; + } + + /** + * Method + * + * @return Method. + */ + public String getName() { // ignored + /** ignored */ + return name; + // yet ignored + } + // Still ignored. +} From d63792fd352f455ab82868b8fc950be1e89ff3b8 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 31 Jul 2025 19:58:46 -0300 Subject: [PATCH 08/17] openapi: more clean up + support return doc + throws - ref #3733 --- .../internal/openapi/AnnotationParser.java | 24 ++++--- .../internal/openapi/RequestBodyExt.java | 1 + .../jooby/internal/openapi/RouteParser.java | 7 +- .../internal/openapi/javadoc/MethodDoc.java | 71 ++++++++++++++++++- .../internal/openapi/javadoc/ThrowsDoc.java | 30 ++++++++ .../java/issues/i3729/api/ApiDocTest.java | 52 +++++++++++++- .../test/java/issues/i3729/api/BookQuery.java | 15 ++++ .../java/issues/i3729/api/LibraryApi.java | 28 ++++++-- .../test/java/javadoc/JavaDocParserTest.java | 3 +- .../src/test/java/javadoc/PrintAstTree.java | 5 +- 10 files changed, 213 insertions(+), 23 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ThrowsDoc.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.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 68defb52c3..49d64a14e9 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 @@ -290,6 +290,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty methodDoc -> { operationExt.setSummary(methodDoc.getSummary()); operationExt.setDescription(methodDoc.getDescription()); + // Parameters for (var parameterName : parameterNames) { var paramExt = operationExt.getParameters().stream() @@ -311,14 +312,21 @@ public static List parse(ParserContext ctx, String prefix, Type ty } } } - for (var parameter : operationExt.getParameters()) { - var paramExt = (ParameterExt) parameter; - var paramDoc = - methodDoc.getParameterDoc( - paramExt.getName(), paramExt.getContainerType()); - if (paramDoc != null) { - 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()); } }); }); @@ -445,7 +453,7 @@ private static List routerArguments( if (paramType == ParamType.BODY) { RequestBodyExt body = new RequestBodyExt(); - body.setRequired(required); + body.setRequired(true); body.setJavaType(javaType); requestBody.accept(body); } else if (paramType == ParamType.FORM) { 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 e4a7628e9b..7171650660 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 @@ -10,6 +10,7 @@ import io.swagger.v3.oas.models.parameters.RequestBody; public class RequestBodyExt extends RequestBody { + @JsonIgnore private String javaType; @JsonIgnore private String contentType = MediaType.JSON; 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 e32aa4ae5c..a6c146a28f 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 @@ -47,6 +47,7 @@ import io.jooby.Router; import io.jooby.SneakyThrows; import io.jooby.annotation.OpenApiRegister; +import io.swagger.v3.core.util.Json; import io.swagger.v3.oas.models.media.ComposedSchema; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.Schema; @@ -171,11 +172,15 @@ private List checkParameters(ParserContext ctx, List param for (Object e : ref.schema.getProperties().entrySet()) { String name = (String) ((Map.Entry) e).getKey(); Schema s = (Schema) ((Map.Entry) e).getValue(); + var schemaMap = Json.mapper().convertValue(s, Map.class); + schemaMap.remove("description"); + var schemaNoDesc = Json.mapper().convertValue(schemaMap, Schema.class); ParameterExt p = new ParameterExt(); p.setContainerType(javaType); p.setName(name); p.setIn(parameter.getIn()); - p.setSchema(s); + p.setSchema(schemaNoDesc); + p.setDescription(parameter.getDescription()); params.add(p); } 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 5fe7a9c68d..deae7c1150 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 @@ -7,18 +7,81 @@ import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import java.util.stream.Stream; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import io.jooby.StatusCode; public class MethodDoc extends JavaDocNode { + + private Map throwList; + public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { super(ctx, node, javadoc); + throwList = throwList(this.javadoc); + } + + private Map throwList(DetailNode javadoc) { + var result = new LinkedHashMap(); + for (var tag : tree(javadoc).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { + var isThrows = tree(tag).anyMatch(javadocToken(JavadocTokenTypes.THROWS_LITERAL)); + if (isThrows) { + var text = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .map(it -> getText(List.of(it.getChildren()), true)) + .orElse(null); + var statusCode = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .flatMap( + it -> + tree(it) + .filter(javadocToken(JavadocTokenTypes.HTML_TAG_NAME)) + .filter(tagName -> tagName.getText().equals("code")) + .flatMap( + tagName -> + backward(tagName) + .filter(javadocToken(JavadocTokenTypes.HTML_TAG)) + .findFirst() + .stream()) + .flatMap( + htmlTag -> + children(htmlTag) + .filter(javadocToken(JavadocTokenTypes.TEXT)) + .findFirst() + .stream()) + .map(DetailNode::getText) + .map( + value -> { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + }) + .filter(Objects::nonNull) + .filter(code -> code >= 400 && code <= 600) + .map(StatusCode::valueOf) + .findFirst()) + .orElse(null); + // var className = tree(tag).filter(javadocToken(JavadocTokenTypes.CLASS_NAME)) + // .findFirst() + // .map(DetailNode::getText) + // .orElse(null); + if (statusCode != null) { + var throwsDoc = new ThrowsDoc(statusCode, text); + result.putIfAbsent(statusCode, throwsDoc); + } + } + } + return result; } MethodDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { @@ -91,4 +154,8 @@ public String getReturnDoc() { .map(it -> getText(tree(it).toList(), true)) .orElse(null); } + + 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 new file mode 100644 index 0000000000..0fe1520e38 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ThrowsDoc.java @@ -0,0 +1,30 @@ +/* + * 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/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index b37f9029c2..7ed44593fb 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 @@ -37,17 +37,50 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " type: string\n" + " responses:\n" + " \"200\":\n" - + " description: Success\n" + + " description: A matching book.\n" + " content:\n" + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Book\"\n" + + " \"404\":\n" + + " description: \"Not Found: If a book doesn't exist.\"\n" + + " \"400\":\n" + + " description: \"Bad Request: For bad ISBN code.\"\n" + " /api/library:\n" + " summary: Library API.\n" + " description: \"Contains all operations for creating, updating and fetching" + " books.\"\n" + + " get:\n" + + " summary: Query books.\n" + + " operationId: query\n" + + " parameters:\n" + + " - name: title\n" + + " in: query\n" + + " description: Book's param query.\n" + + " schema:\n" + + " type: string\n" + + " - name: author\n" + + " in: query\n" + + " description: Book's param query.\n" + + " schema:\n" + + " type: string\n" + + " - name: isbn\n" + + " in: query\n" + + " description: Book's param query.\n" + + " schema:\n" + + " type: string\n" + + " responses:\n" + + " \"200\":\n" + + " description: Matching books.\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: array\n" + + " items:\n" + + " $ref: \"#/components/schemas/Book\"\n" + " post:\n" + " summary: Creates a new book.\n" + + " description: Book can be created or updated.\n" + " operationId: createBook\n" + " requestBody:\n" + " description: Book to create.\n" @@ -55,16 +88,29 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Book\"\n" - + " required: false\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" - + " description: Success\n" + + " description: Saved book.\n" + " content:\n" + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Book\"\n" + "components:\n" + " schemas:\n" + + " BookQuery:\n" + + " type: object\n" + + " properties:\n" + + " title:\n" + + " type: string\n" + + " description: Book's title. Optional.\n" + + " author:\n" + + " type: string\n" + + " description: Book's author. Optional.\n" + + " isbn:\n" + + " type: string\n" + + " description: Book's isbn. Optional.\n" + + " description: Query books by complex filters.\n" + " Address:\n" + " type: object\n" + " properties:\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java new file mode 100644 index 0000000000..8fbdeaf7ff --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java @@ -0,0 +1,15 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +/** + * Query books by complex filters. + * + * @param title Book's title. Optional. + * @param author Book's author. Optional. + * @param isbn Book's isbn. Optional. + */ +public record BookQuery(String title, String author, String isbn) {} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java index ecf33e7acc..4ff0d1a57b 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -5,10 +5,11 @@ */ package issues.i3729.api; -import io.jooby.annotation.GET; -import io.jooby.annotation.POST; -import io.jooby.annotation.Path; -import io.jooby.annotation.PathParam; +import java.util.List; + +import io.jooby.annotation.*; +import io.jooby.exception.BadRequestException; +import io.jooby.exception.NotFoundException; /** * Library API. @@ -22,16 +23,31 @@ public class LibraryApi { * Find a book by isbn. * * @param isbn Book isbn. Like IK-1900. - * @return A book + * @return A matching book. + * @throws NotFoundException 404 If a book doesn't exist. + * @throws BadRequestException 400 For bad ISBN code. */ @GET("/{isbn}") - public Book bookByIsbn(@PathParam String isbn) { + public Book bookByIsbn(@PathParam String isbn) throws NotFoundException, BadRequestException { return new Book(); } + /** + * Query books. + * + * @param query Book's param query. + * @return Matching books. + */ + @GET + public List query(@QueryParam BookQuery query) { + return List.of(new Book()); + } + /** * Creates a new book. * + *

Book can be created or updated. + * * @param book Book to create. * @return Saved book. */ diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 46f9081ec7..4125930623 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -133,7 +133,8 @@ public void shouldParseEnum() throws Exception { assertEquals("Enum summary.", doc.getSummary()); assertEquals("Enum desc.", doc.getDescription()); assertEquals( - "Enum summary.\n" + " - Foo: Foo doc.\n" + " - Bar: Bar doc.", doc.getText()); + "Enum summary.\n" + " - Foo: Foo doc.\n" + " - Bar: Bar doc.", + doc.getEnumDescription(doc.getSummary())); }); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java index e1ac3e7104..8d0734612c 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -9,10 +9,10 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import javadoc.input.NoDoc; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; +import issues.i3729.api.LibraryApi; public class PrintAstTree { public static void main(String[] args) throws CheckstyleException, IOException { @@ -24,7 +24,8 @@ public static void main(String[] args) throws CheckstyleException, IOException { .resolve("test") .resolve("java"); var stringAst = - AstTreeStringPrinter.printJavaAndJavadocTree(baseDir.resolve(toPath(NoDoc.class)).toFile()); + AstTreeStringPrinter.printJavaAndJavadocTree( + baseDir.resolve(toPath(LibraryApi.class)).toFile()); System.out.println(stringAst); } From 974a896279e5cce1a608eeb5f3b0c9efe8606683 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 31 Jul 2025 20:02:42 -0300 Subject: [PATCH 09/17] openapi: fix/updates some test output #ref 3733 --- modules/jooby-openapi/src/test/java/issues/Issue1580.java | 2 +- modules/jooby-openapi/src/test/java/issues/Issue1581.java | 2 +- .../jooby-openapi/src/test/java/issues/i1795/Issue1795.java | 2 +- .../jooby-openapi/src/test/java/issues/i3729/Issue3729.java | 5 ++++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/jooby-openapi/src/test/java/issues/Issue1580.java b/modules/jooby-openapi/src/test/java/issues/Issue1580.java index 47b8c23498..5c607eae49 100644 --- a/modules/jooby-openapi/src/test/java/issues/Issue1580.java +++ b/modules/jooby-openapi/src/test/java/issues/Issue1580.java @@ -35,7 +35,7 @@ public void shouldGenerateDefaultResponse(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Data1580\"\n" - + " required: false\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" diff --git a/modules/jooby-openapi/src/test/java/issues/Issue1581.java b/modules/jooby-openapi/src/test/java/issues/Issue1581.java index 6514f6618f..955d5981b3 100644 --- a/modules/jooby-openapi/src/test/java/issues/Issue1581.java +++ b/modules/jooby-openapi/src/test/java/issues/Issue1581.java @@ -35,7 +35,7 @@ public void shouldGenerateDefaultResponse(OpenAPIResult result) { + " application/json:\n" + " schema:\n" + " $ref: \"#/components/schemas/Data1580\"\n" - + " required: false\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i1795/Issue1795.java b/modules/jooby-openapi/src/test/java/issues/i1795/Issue1795.java index 95085df8a0..7e5cc43653 100644 --- a/modules/jooby-openapi/src/test/java/issues/i1795/Issue1795.java +++ b/modules/jooby-openapi/src/test/java/issues/i1795/Issue1795.java @@ -53,7 +53,7 @@ public void shouldGetRequestBody(OpenAPIResult result) { + " type: array\n" + " items:\n" + " type: string\n" - + " required: false\n" + + " required: true\n" + " responses:\n" + " \"200\":\n" + " description: Success\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java b/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java index 2549512d07..3660069c4b 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java @@ -22,6 +22,9 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " version: \"1.0\"\n" + "paths:\n" + " /3729/{id}:\n" + + " summary: Playing with API doc.\n" + + " description: Sed eget orci imperdiet massa ultrices congue. Etiam ornare velit\n" + + " eu justo efficitur.\n" + " get:\n" + " summary: Find a user by ID.\n" + " description: Finds a user by ID or throws a 404\n" @@ -41,7 +44,7 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " type: boolean\n" + " responses:\n" + " \"200\":\n" - + " description: Success\n" + + " description: Found user.\n" + " content:\n" + " application/json:\n" + " schema:\n" From 8c628505c7ad537c516a3538d75b0b2ab11cfea7 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 31 Jul 2025 21:46:06 -0300 Subject: [PATCH 10/17] openapi: fix/update maven/gradle plugins - ref #3733 --- .../java/io/jooby/gradle/OpenAPITask.java | 26 ++++++++++--------- .../main/java/io/jooby/maven/OpenAPIMojo.java | 8 +++++- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java index 2a3b1ac6c8..362cb96ac3 100644 --- a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java +++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java @@ -13,6 +13,8 @@ import org.gradle.api.tasks.TaskAction; import edu.umd.cs.findbugs.annotations.Nullable; + +import java.io.File; import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -44,27 +46,27 @@ public void generate() throws Throwable { String mainClass = Optional.ofNullable(this.mainClass) .orElseGet(() -> computeMainClassName(projects)); - - Path outputDir = classes(getProject(), false); - // Reduce lookup to current project: See https://github.com/jooby-project/jooby/issues/2756 - String metaInf = - outputDir - .resolve("META-INF") - .resolve("services") - .resolve("io.jooby.MvcFactory") - .toAbsolutePath() - .toString(); + var sources = projects.stream() + .flatMap(project -> { + var sourceSet = sourceSet(project, false); + return sourceSet.stream() + .flatMap(it -> it.getAllSource().getSrcDirs().stream()) + .map(File::toPath); + }) + .distinct() + .toList(); Path outputDir = classes(getProject(), false); ClassLoader classLoader = createClassLoader(projects); getLogger().info("Generating OpenAPI: " + mainClass); getLogger().debug("Using classloader: " + classLoader); getLogger().debug("Output directory: " + outputDir); - getLogger().debug("META-INF: " + metaInf); + getLogger().debug("Source directories: " + sources); - OpenAPIGenerator tool = new OpenAPIGenerator(metaInf); + OpenAPIGenerator tool = new OpenAPIGenerator(); tool.setClassLoader(classLoader); tool.setOutputDir(outputDir); + tool.setSources(sources); trim(includes).ifPresent(tool::setIncludes); trim(excludes).ifPresent(tool::setExcludes); diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java index 217b86ae01..bea9fec348 100644 --- a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java @@ -49,15 +49,21 @@ protected void doExecute(@NonNull List projects, @NonNull String m throws Exception { ClassLoader classLoader = createClassLoader(projects); Path outputDir = Paths.get(project.getBuild().getOutputDirectory()); - // Reduce lookup to current project: See https://github.com/jooby-project/jooby/issues/2756 + var sources = + projects.stream() + .map(project -> Paths.get(project.getBuild().getSourceDirectory())) + .distinct() + .toList(); getLog().info("Generating OpenAPI: " + mainClass); getLog().debug("Using classloader: " + classLoader); getLog().debug("Output directory: " + outputDir); + getLog().debug("Source directories: " + sources); OpenAPIGenerator tool = new OpenAPIGenerator(); tool.setClassLoader(classLoader); tool.setOutputDir(outputDir); + tool.setSources(sources); trim(includes).ifPresent(tool::setIncludes); trim(excludes).ifPresent(tool::setExcludes); From 826c0116231e8ee5411d458271348d068a9657b4 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 1 Aug 2025 14:29:26 -0300 Subject: [PATCH 11/17] open-api: parse open-api extensions - it uses the java doc tag: `@x-extension-name value` --- .../internal/openapi/AnnotationParser.java | 6 + .../jooby/internal/openapi/OperationExt.java | 21 ++-- .../javadoc/ExtensionJavaDocParser.java | 116 ++++++++++++++++++ .../internal/openapi/javadoc/JavaDocNode.java | 54 +++++++- .../io/jooby/openapi/OpenAPIGenerator.java | 4 + .../java/issues/i3729/api/ApiDocTest.java | 7 ++ .../java/issues/i3729/api/AppLibrary.java | 2 + .../java/issues/i3729/api/LibraryApi.java | 3 + .../javadoc/ExtensionJavaDocParserTest.java | 75 +++++++++++ .../src/test/java/javadoc/PrintAstTree.java | 4 +- .../src/test/java/javadoc/input/ApiDoc.java | 4 + 11 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java create mode 100644 modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.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 49d64a14e9..a6b11a430b 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,6 +279,9 @@ public static List parse(ParserContext ctx, String prefix, Type ty doc -> { operationExt.setPathDescription(doc.getDescription()); operationExt.setPathSummary(doc.getSummary()); + if (!doc.getExtensions().isEmpty()) { + operationExt.setPathExtensions(doc.getExtensions()); + } var parameterNames = Optional.ofNullable(operationExt.getNode().parameters) .orElse(List.of()) @@ -290,6 +293,9 @@ public static List parse(ParserContext ctx, String prefix, Type ty methodDoc -> { operationExt.setSummary(methodDoc.getSummary()); operationExt.setDescription(methodDoc.getDescription()); + if (!methodDoc.getExtensions().isEmpty()) { + operationExt.setExtensions(methodDoc.getExtensions()); + } // Parameters for (var parameterName : parameterNames) { var paramExt = 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 68299f183c..e16c533e48 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 @@ -8,12 +8,7 @@ import static io.jooby.internal.openapi.StatusCodeParser.isSuccessCode; import static java.util.Optional.ofNullable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -40,6 +35,7 @@ public class OperationExt extends io.swagger.v3.oas.models.Operation { @JsonIgnore private List responseCodes = new ArrayList<>(); @JsonIgnore private String pathSummary; @JsonIgnore private String pathDescription; + @JsonIgnore private Map pathExtensions; @JsonIgnore private List globalTags = new ArrayList<>(); @JsonIgnore private ClassNode application; @JsonIgnore private ClassNode controller; @@ -172,6 +168,14 @@ public void setPathSummary(String pathSummary) { this.pathSummary = pathSummary; } + public Map getPathExtensions() { + return pathExtensions; + } + + public void setPathExtensions(Map pathExtensions) { + this.pathExtensions = pathExtensions; + } + public void addTag(Tag tag) { this.globalTags.add(tag); addTagsItem(tag.getName()); @@ -224,7 +228,7 @@ public OperationExt copy(String pattern) { copy.setTags(getTags()); copy.setResponses(getResponses()); - /** Redo path keys: */ + /* Redo path keys: */ List keys = Router.pathKeys(pattern); List newParameters = new ArrayList<>(); List parameters = getParameters(); @@ -247,12 +251,15 @@ public OperationExt copy(String pattern) { copy.setServers(getServers()); copy.setCallbacks(getCallbacks()); copy.setExternalDocs(getExternalDocs()); + copy.setExtensions(getExtensions()); copy.setSecurity(getSecurity()); copy.setPathDescription(getPathDescription()); copy.setPathSummary(getPathSummary()); copy.setGlobalTags(getGlobalTags()); copy.setApplication(getApplication()); copy.setController(getController()); + copy.setPathDescription(getPathDescription()); + copy.setPathExtensions(getPathExtensions()); return copy; } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java new file mode 100644 index 0000000000..e18d707e47 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java @@ -0,0 +1,116 @@ +/* + * 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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class ExtensionJavaDocParser { + @SuppressWarnings("unchecked") + public static Map parse(List properties) { + // The root of our final tree structure. + var root = new LinkedHashMap(); + + for (int i = 0; i < properties.size(); i += 2) { + var keyPath = properties.get(i); + var value = properties.get(i + 1); + var keys = keyPath.split("\\."); + + Map currentNode = root; + for (int j = 0; j < keys.length - 1; j++) { + String key = keys[j]; + Object nextNode = + currentNode.computeIfAbsent(key, k -> new LinkedHashMap()); + currentNode = (Map) nextNode; + } + var finalKey = keys[keys.length - 1]; + @SuppressWarnings("unchecked") + List values = + (List) currentNode.computeIfAbsent(finalKey, k -> new ArrayList()); + values.add(value); + } + return (Map) restructureNode(root); + } + + /** + * Recursively traverses the tree and restructures nodes where appropriate. If a map contains only + * list-of-string values of the same size, it "zips" them into a list of maps (objects). + * + * @param node The current node (Map or List) to process. + * @return The restructured node. + */ + @SuppressWarnings("unchecked") + private static Object restructureNode(Object node) { + if (!(node instanceof Map)) { + // This is a leaf (already a List), so return it as is. + return node; + } + + Map map = (Map) node; + Map restructuredMap = new LinkedHashMap<>(); + + // First, recursively restructure all children of the current map. + for (Map.Entry entry : map.entrySet()) { + var value = restructureNode(entry.getValue()); + var propertyKey = entry.getKey(); + restructuredMap.put(propertyKey, value); + } + + // Now, check if the current node itself should be restructured. + if (restructuredMap.isEmpty()) { + return restructuredMap; + } + + // Check if all values in the map are lists of strings. + boolean canBeZipped = true; + int listSize = -1; + + for (var value : restructuredMap.values()) { + if (!(value instanceof List) + || ((List) value).isEmpty() + || !(((List) value).getFirst() instanceof String)) { + canBeZipped = false; + break; + } + List list = (List) value; + if (listSize == -1) { + listSize = list.size(); + } else if (listSize != list.size()) { + // If lists have different sizes, they can't be zipped together. + canBeZipped = false; + break; + } + } + + // If the conditions are met, perform the "zip" operation. + if (canBeZipped) { + List> listOfObjects = new ArrayList<>(); + for (int i = 0; i < listSize; i++) { + Map objectMap = new LinkedHashMap<>(); + for (Map.Entry entry : restructuredMap.entrySet()) { + objectMap.put(nameNoDash(entry.getKey()), ((List) entry.getValue()).get(i)); + } + listOfObjects.add(objectMap); + } + if (listOfObjects.size() == 1 + && restructuredMap.keySet().stream().noneMatch(ExtensionJavaDocParser::startsWithDash)) { + return listOfObjects.getFirst(); + } + return listOfObjects; + } + return restructuredMap; + } + + private static boolean startsWithDash(String name) { + return name.charAt(0) == '-'; + } + + private static String nameNoDash(String name) { + return startsWithDash(name) ? name.substring(1) : name; + } +} 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 a541f3a49b..e0216cd804 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,17 +5,19 @@ */ package io.jooby.internal.openapi.javadoc; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.forward; +import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; -import java.util.List; +import java.util.*; import java.util.function.Predicate; +import com.fasterxml.jackson.core.JsonProcessingException; import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter; import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; +import io.swagger.util.Yaml; public class JavaDocNode { private static final Predicate JAVADOC_TAG = @@ -24,6 +26,7 @@ public class JavaDocNode { protected final JavaDocParser context; protected final DetailAST node; protected final DetailNode javadoc; + private final Map extensions; public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) { this(ctx, node, toJavaDocNode(comment)); @@ -33,6 +36,37 @@ protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { this.context = ctx; this.node = node; this.javadoc = javadoc; + if (this.javadoc != EMPTY_NODE) { + this.extensions = parseExtensions(this.javadoc); + } else { + this.extensions = Map.of(); + } + } + + private Map parseExtensions(DetailNode node) { + var values = new ArrayList(); + for (var tag : tree(node).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { + var extension = + tree(tag) + .filter( + javadocToken(JavadocTokenTypes.CUSTOM_NAME) + .and(it -> it.getText().startsWith("@x-"))) + .findFirst() + .map(DetailNode::getText) + .orElse(null); + if (extension != null) { + extension = extension.substring(1).trim(); + var extensionValue = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .map(it -> getText(List.of(it.getChildren()), false)) + .orElse(null); + values.add(extension); + values.add(extensionValue); + } + } + return ExtensionJavaDocParser.parse(values); } static DetailNode toJavaDocNode(DetailAST node) { @@ -41,6 +75,10 @@ static DetailNode toJavaDocNode(DetailAST node) { : new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree(); } + public Map getExtensions() { + return extensions; + } + public String getSummary() { var builder = new StringBuilder(); for (var node : forward(javadoc, JAVADOC_TAG).toList()) { @@ -219,4 +257,16 @@ public boolean hasChildren() { return false; } }; + + public static void main(String[] args) throws JsonProcessingException { + var badges = + Yaml.mapper() + .readValue( + "x-badges:\n" + + " - name: 'Beta'\n" + + " position: before\n" + + " color: purple", + Map.class); + System.out.println(badges); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 04f4ad1063..5f10d40b16 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -164,6 +164,9 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { Optional.ofNullable(doc.getSummary()).ifPresent(info::setTitle); Optional.ofNullable(doc.getDescription()).ifPresent(info::setDescription); Optional.ofNullable(doc.getVersion()).ifPresent(info::setVersion); + if (!doc.getExtensions().isEmpty()) { + info.setExtensions(doc.getExtensions()); + } }); } @@ -215,6 +218,7 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { pathItem.operation(PathItem.HttpMethod.valueOf(operation.getMethod()), operation); Optional.ofNullable(operation.getPathSummary()).ifPresent(pathItem::setSummary); Optional.ofNullable(operation.getPathDescription()).ifPresent(pathItem::setDescription); + Optional.ofNullable(operation.getPathExtensions()).ifPresent(pathItem::setExtensions); // global tags operation.getGlobalTags().forEach(tag -> globalTags.put(tag.getName(), tag)); 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 7ed44593fb..44a3e2a515 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 @@ -20,6 +20,9 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " title: Library API.\n" + " description: \"Available data: Books and authors.\"\n" + " version: 4.0.0\n" + + " x-logo:\n" + + " url: https://redocly.github.io/redoc/museum-logo.png\n" + + " altText: Museum logo\n" + "paths:\n" + " /api/library/{isbn}:\n" + " summary: Library API.\n" @@ -78,6 +81,10 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " type: array\n" + " items:\n" + " $ref: \"#/components/schemas/Book\"\n" + + " x-badges:\n" + + " - name: Beta\n" + + " position: before\n" + + " color: purple\n" + " post:\n" + " summary: Creates a new book.\n" + " description: Book can be created or updated.\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java index 6ab8d98f55..755d80f04c 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java @@ -15,6 +15,8 @@ *

Available data: Books and authors. * * @version 4.0.0 + * @x-logo.url https://redocly.github.io/redoc/museum-logo.png + * @x-logo.altText Museum logo */ public class AppLibrary extends Jooby { diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java index 4ff0d1a57b..eef93c2b8f 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -37,6 +37,9 @@ public Book bookByIsbn(@PathParam String isbn) throws NotFoundException, BadRequ * * @param query Book's param query. * @return Matching books. + * @x-badges.-name Beta + * @x-badges.position before + * @x-badges.color purple */ @GET public List query(@QueryParam BookQuery query) { diff --git a/modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.java new file mode 100644 index 0000000000..b45d01bdbb --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/ExtensionJavaDocParserTest.java @@ -0,0 +1,75 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package javadoc; + +import static io.jooby.internal.openapi.javadoc.ExtensionJavaDocParser.parse; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +public class ExtensionJavaDocParserTest { + + @Test + public void shouldParseMapLike() { + assertEquals( + Map.of("x-badges", Map.of("icon", Map.of("name", "Beta", "color", "Blue"))), + parse(List.of("x-badges.icon.name", "Beta", "x-badges.icon.color", "Blue"))); + assertEquals( + Map.of("x-badges", Map.of("name", "Beta", "color", "Blue")), + parse(List.of("x-badges.name", "Beta", "x-badges.color", "Blue"))); + } + + @Test + public void shouldParseListOfMap() { + assertEquals( + Map.of( + "x-badges", + Map.of( + "icon", + List.of( + Map.of("name", "Beta", "color", "Blue"), + Map.of("name", "Final", "color", "Red")))), + parse( + List.of( + "x-badges.icon.name", + "Beta", + "x-badges.icon.color", + "Blue", + "x-badges.icon.name", + "Final", + "x-badges.icon.color", + "Red"))); + assertEquals( + Map.of( + "x-badges", + List.of( + Map.of("name", "Beta", "color", "Blue"), Map.of("name", "Final", "color", "Red"))), + parse( + List.of( + "x-badges.name", + "Beta", + "x-badges.color", + "Blue", + "x-badges.name", + "Final", + "x-badges.color", + "Red"))); + } + + @Test + public void shouldForceArrayOnSingleElements() { + // properties starting with `-` must be always array + assertEquals( + Map.of("x-badges", Map.of("icon", List.of(Map.of("name", "Beta", "color", "Blue")))), + parse(List.of("x-badges.icon.-name", "Beta", "x-badges.icon.color", "Blue"))); + assertEquals( + Map.of("x-badges", List.of(Map.of("name", "Beta", "color", "Blue"))), + parse(List.of("x-badges.-name", "Beta", "x-badges.color", "Blue"))); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java index 8d0734612c..4cb088c810 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -9,10 +9,10 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; +import javadoc.input.ApiDoc; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; -import issues.i3729.api.LibraryApi; public class PrintAstTree { public static void main(String[] args) throws CheckstyleException, IOException { @@ -25,7 +25,7 @@ public static void main(String[] args) throws CheckstyleException, IOException { .resolve("java"); var stringAst = AstTreeStringPrinter.printJavaAndJavadocTree( - baseDir.resolve(toPath(LibraryApi.class)).toFile()); + baseDir.resolve(toPath(ApiDoc.class)).toFile()); System.out.println(stringAst); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java index fc8666f0f9..20212bc6b3 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -17,6 +17,10 @@ * *

Proin sit amet lectus interdum, porta libero quis, fringilla metus. Integer viverra ante id * vestibulum congue. Nam et tortor at magna tempor congue. + * + * @x-badges.name Beta + * @x-badges.position before + * @x-badges.color purple */ @Path("/api") public class ApiDoc { From bfd463437cca25cdceb7c5ee56fcdfb3368207e3 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 1 Aug 2025 17:18:46 -0300 Subject: [PATCH 12/17] open-api: add @tag javadoc support - ref #3729 --- .../internal/openapi/AnnotationParser.java | 14 ++++ .../internal/openapi/javadoc/JavaDocNode.java | 65 ++++++++++++---- .../io/jooby/openapi/OpenAPIGenerator.java | 18 ++--- .../java/issues/i3729/api/ApiDocTest.java | 74 ++++++++++++++----- .../java/issues/i3729/api/LibraryApi.java | 17 +++++ .../src/test/java/javadoc/input/ApiDoc.java | 1 + 6 files changed, 146 insertions(+), 43 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 a6b11a430b..892d5aadd8 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 @@ -33,6 +33,7 @@ import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.tags.Tag; import jakarta.inject.Named; import jakarta.inject.Provider; @@ -279,6 +280,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty doc -> { operationExt.setPathDescription(doc.getDescription()); operationExt.setPathSummary(doc.getSummary()); + tags(doc.getTags()).forEach(operationExt::addTag); if (!doc.getExtensions().isEmpty()) { operationExt.setPathExtensions(doc.getExtensions()); } @@ -296,6 +298,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty if (!methodDoc.getExtensions().isEmpty()) { operationExt.setExtensions(methodDoc.getExtensions()); } + tags(methodDoc.getTags()).forEach(operationExt::addTag); // Parameters for (var parameterName : parameterNames) { var paramExt = @@ -343,6 +346,17 @@ public static List parse(ParserContext ctx, String prefix, Type ty return result; } + private static List tags(Map tags) { + List result = new ArrayList<>(); + for (var tagNode : tags.entrySet()) { + var tag = new Tag(); + tag.setName(tagNode.getKey()); + tag.setDescription(tagNode.getValue()); + result.add(tag); + } + return result; + } + private static Map methods(ParserContext ctx, ClassNode node) { Map methods = new LinkedHashMap<>(); if (node.superName != null && !node.superName.equals(TypeFactory.OBJECT.getInternalName())) { 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 e0216cd804..077fd69b0a 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 @@ -6,27 +6,27 @@ package io.jooby.internal.openapi.javadoc; import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; +import static io.jooby.internal.openapi.javadoc.JavaDocSupport.javadocToken; import java.util.*; import java.util.function.Predicate; -import com.fasterxml.jackson.core.JsonProcessingException; import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter; import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; -import io.swagger.util.Yaml; public class JavaDocNode { private static final Predicate JAVADOC_TAG = - JavaDocSupport.javadocToken(JavadocTokenTypes.JAVADOC_TAG); + javadocToken(JavadocTokenTypes.JAVADOC_TAG); protected final JavaDocParser context; protected final DetailAST node; protected final DetailNode javadoc; private final Map extensions; + private final Map tags; public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) { this(ctx, node, toJavaDocNode(comment)); @@ -38,14 +38,55 @@ protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { this.javadoc = javadoc; if (this.javadoc != EMPTY_NODE) { this.extensions = parseExtensions(this.javadoc); + this.tags = parseTags(this.javadoc); } else { this.extensions = Map.of(); + this.tags = Map.of(); } } + private Map parseTags(DetailNode node) { + var result = new LinkedHashMap(); + for (var docTag : tree(node).filter(JAVADOC_TAG).toList()) { + var tag = + tree(docTag) + .filter( + javadocToken(JavadocTokenTypes.CUSTOM_NAME) + .and(it -> it.getText().equals("@tag"))) + .findFirst() + .orElse(null); + if (tag != null) { + var tagText = + tree(docTag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .map(it -> getText(List.of(it.getChildren()), false)) + .orElse(null); + if (tagText != null) { + var dot = tagText.indexOf("."); + var tagName = tagText; + String tagDescription = null; + if (dot > 0) { + tagName = tagText.substring(0, dot); + if (dot + 1 < tagText.length()) { + tagDescription = tagText.substring(dot + 1).trim(); + if (tagDescription.isBlank()) { + tagDescription = null; + } + } + } + if (!tagName.trim().isEmpty()) { + result.put(tagName, tagDescription); + } + } + } + } + return result; + } + private Map parseExtensions(DetailNode node) { var values = new ArrayList(); - for (var tag : tree(node).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { + for (var tag : tree(node).filter(JAVADOC_TAG).toList()) { var extension = tree(tag) .filter( @@ -105,6 +146,10 @@ public String getSummary() { return string.isEmpty() ? null : string; } + public Map getTags() { + return tags; + } + public String getDescription() { var text = getText(); var summary = getSummary(); @@ -257,16 +302,4 @@ public boolean hasChildren() { return false; } }; - - public static void main(String[] args) throws JsonProcessingException { - var badges = - Yaml.mapper() - .readValue( - "x-badges:\n" - + " - name: 'Beta'\n" - + " position: before\n" - + " color: purple", - Map.class); - System.out.println(badges); - } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 5f10d40b16..1834660aff 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -221,16 +221,16 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { Optional.ofNullable(operation.getPathExtensions()).ifPresent(pathItem::setExtensions); // global tags - operation.getGlobalTags().forEach(tag -> globalTags.put(tag.getName(), tag)); + operation + .getGlobalTags() + .forEach( + tag -> { + if (tag.getDescription() != null || tag.getExtensions() != null) { + globalTags.put(tag.getName(), tag); + } + }); } - globalTags - .values() - .forEach( - tag -> { - if (tag.getDescription() != null || tag.getExtensions() != null) { - openapi.addTagsItem(tag); - } - }); + globalTags.values().forEach(openapi::addTagsItem); openapi.setOperations(operations); openapi.setPaths(paths); 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 44a3e2a515..89ed73010b 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 @@ -23,12 +23,21 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " x-logo:\n" + " url: https://redocly.github.io/redoc/museum-logo.png\n" + " altText: Museum logo\n" + + "tags:\n" + + "- name: Library\n" + + " description: Access to all books.\n" + + "- name: Author\n" + + " description: Oxxx\n" + "paths:\n" + " /api/library/{isbn}:\n" + " summary: Library API.\n" + " description: \"Contains all operations for creating, updating and fetching" + " books.\"\n" + " get:\n" + + " tags:\n" + + " - Library\n" + + " - Book\n" + + " - Author\n" + " summary: Find a book by isbn.\n" + " operationId: bookByIsbn\n" + " parameters:\n" @@ -49,11 +58,37 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " description: \"Not Found: If a book doesn't exist.\"\n" + " \"400\":\n" + " description: \"Bad Request: For bad ISBN code.\"\n" + + " /api/library/{id}:\n" + + " summary: Library API.\n" + + " description: \"Contains all operations for creating, updating and fetching" + + " books.\"\n" + + " get:\n" + + " tags:\n" + + " - Library\n" + + " - Author\n" + + " summary: Author by Id.\n" + + " operationId: author\n" + + " parameters:\n" + + " - name: id\n" + + " in: path\n" + + " description: ID.\n" + + " required: true\n" + + " schema:\n" + + " type: string\n" + + " responses:\n" + + " \"200\":\n" + + " description: An author\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: \"#/components/schemas/Author\"\n" + " /api/library:\n" + " summary: Library API.\n" + " description: \"Contains all operations for creating, updating and fetching" + " books.\"\n" + " get:\n" + + " tags:\n" + + " - Library\n" + " summary: Query books.\n" + " operationId: query\n" + " parameters:\n" @@ -86,6 +121,9 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " position: before\n" + " color: purple\n" + " post:\n" + + " tags:\n" + + " - Library\n" + + " - Author\n" + " summary: Creates a new book.\n" + " description: Book can be created or updated.\n" + " operationId: createBook\n" @@ -105,6 +143,23 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " $ref: \"#/components/schemas/Book\"\n" + "components:\n" + " schemas:\n" + + " Author:\n" + + " type: object\n" + + " properties:\n" + + " ssn:\n" + + " type: string\n" + + " description: Social security number.\n" + + " name:\n" + + " type: string\n" + + " description: Author's name.\n" + + " address:\n" + + " $ref: \"#/components/schemas/Address\"\n" + + " books:\n" + + " uniqueItems: true\n" + + " type: array\n" + + " description: Published books.\n" + + " items:\n" + + " $ref: \"#/components/schemas/Book\"\n" + " BookQuery:\n" + " type: object\n" + " properties:\n" @@ -165,24 +220,7 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " type: array\n" + " items:\n" + " $ref: \"#/components/schemas/Author\"\n" - + " description: Book model.\n" - + " Author:\n" - + " type: object\n" - + " properties:\n" - + " ssn:\n" - + " type: string\n" - + " description: Social security number.\n" - + " name:\n" - + " type: string\n" - + " description: Author's name.\n" - + " address:\n" - + " $ref: \"#/components/schemas/Address\"\n" - + " books:\n" - + " uniqueItems: true\n" - + " type: array\n" - + " description: Published books.\n" - + " items:\n" - + " $ref: \"#/components/schemas/Book\"\n", + + " description: Book model.\n", result.toYaml()); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java index eef93c2b8f..a8c69e81ab 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -15,6 +15,8 @@ * Library API. * *

Contains all operations for creating, updating and fetching books. + * + * @tag Library. Access to all books. */ @Path("/api/library") public class LibraryApi { @@ -26,12 +28,26 @@ public class LibraryApi { * @return A matching book. * @throws NotFoundException 404 If a book doesn't exist. * @throws BadRequestException 400 For bad ISBN code. + * @tag Book + * @tag Author */ @GET("/{isbn}") public Book bookByIsbn(@PathParam String isbn) throws NotFoundException, BadRequestException { return new Book(); } + /** + * Author by Id. + * + * @param id ID. + * @return An author + * @tag Author. Oxxx + */ + @GET("/{id}") + public Author author(@PathParam String id) { + return new Author(); + } + /** * Query books. * @@ -53,6 +69,7 @@ public List query(@QueryParam BookQuery query) { * * @param book Book to create. * @return Saved book. + * @tag Author */ @POST public Book createBook(Book book) { diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java index 20212bc6b3..5047bf3bab 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -21,6 +21,7 @@ * @x-badges.name Beta * @x-badges.position before * @x-badges.color purple + * @tag ApiTag */ @Path("/api") public class ApiDoc { From 0b2d4d22813d70db95009ed923545796382ff8b4 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 1 Aug 2025 18:47:37 -0300 Subject: [PATCH 13/17] open-api: parse javadoc - code cleanup -ref #3729 --- .../internal/openapi/javadoc/ClassDoc.java | 57 ++++--- .../internal/openapi/javadoc/FieldDoc.java | 5 - .../internal/openapi/javadoc/JavaDocNode.java | 76 +--------- .../internal/openapi/javadoc/JavaDocTag.java | 142 ++++++++++++++++++ .../internal/openapi/javadoc/MethodDoc.java | 63 +------- .../test/java/javadoc/JavaDocParserTest.java | 2 +- .../src/test/java/javadoc/input/ApiDoc.java | 2 +- 7 files changed, 184 insertions(+), 163 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.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 ef22251abc..39a7d79491 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 @@ -62,28 +62,41 @@ public String getEnumDescription(String text) { } private void defaultRecordMembers() { - for (var tag : tree(javadoc).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { - var isParam = tree(tag).anyMatch(javadocToken(JavadocTokenTypes.PARAM_LITERAL)); - var name = - tree(tag).filter(javadocToken(JavadocTokenTypes.PARAMETER_NAME)).findFirst().orElse(null); - if (isParam && name != null) { - /* Virtual Field */ - var memberDoc = - tree(tag) - .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) - .findFirst() - .orElse(EMPTY_NODE); - var field = - new FieldDoc( - context, createVirtualMember(name.getText(), TokenTypes.VARIABLE_DEF), memberDoc); - addField(field); - /* Virtual method */ - var method = - new MethodDoc( - context, createVirtualMember(name.getText(), TokenTypes.METHOD_DEF), memberDoc); - addMethod(method); - } - } + JavaDocTag.javaDocTag( + javadoc, + tag -> { + var isParam = tree(tag).anyMatch(javadocToken(JavadocTokenTypes.PARAM_LITERAL)); + var name = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.PARAMETER_NAME)) + .findFirst() + .orElse(null); + return isParam && name != null; + }, + (tag, value) -> { + var name = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.PARAMETER_NAME)) + .findFirst() + .orElse(null); + // name is never null bc previous filter + Objects.requireNonNull(name, "name is null"); + /* Virtual Field */ + var memberDoc = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .orElse(EMPTY_NODE); + var field = + new FieldDoc( + context, createVirtualMember(name.getText(), TokenTypes.VARIABLE_DEF), memberDoc); + addField(field); + /* Virtual method */ + var method = + new MethodDoc( + context, createVirtualMember(name.getText(), TokenTypes.METHOD_DEF), memberDoc); + addMethod(method); + }); } private void defaultEnumMembers() { 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 8e217876f3..6a8455ac17 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 @@ -21,9 +21,4 @@ public FieldDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { public String getName() { return node.findFirstToken(TokenTypes.IDENT).getText(); } - - @Override - public String getText() { - return super.getText(); - } } 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 077fd69b0a..b6faf994c8 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 @@ -36,78 +36,8 @@ protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { this.context = ctx; this.node = node; this.javadoc = javadoc; - if (this.javadoc != EMPTY_NODE) { - this.extensions = parseExtensions(this.javadoc); - this.tags = parseTags(this.javadoc); - } else { - this.extensions = Map.of(); - this.tags = Map.of(); - } - } - - private Map parseTags(DetailNode node) { - var result = new LinkedHashMap(); - for (var docTag : tree(node).filter(JAVADOC_TAG).toList()) { - var tag = - tree(docTag) - .filter( - javadocToken(JavadocTokenTypes.CUSTOM_NAME) - .and(it -> it.getText().equals("@tag"))) - .findFirst() - .orElse(null); - if (tag != null) { - var tagText = - tree(docTag) - .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) - .findFirst() - .map(it -> getText(List.of(it.getChildren()), false)) - .orElse(null); - if (tagText != null) { - var dot = tagText.indexOf("."); - var tagName = tagText; - String tagDescription = null; - if (dot > 0) { - tagName = tagText.substring(0, dot); - if (dot + 1 < tagText.length()) { - tagDescription = tagText.substring(dot + 1).trim(); - if (tagDescription.isBlank()) { - tagDescription = null; - } - } - } - if (!tagName.trim().isEmpty()) { - result.put(tagName, tagDescription); - } - } - } - } - return result; - } - - private Map parseExtensions(DetailNode node) { - var values = new ArrayList(); - for (var tag : tree(node).filter(JAVADOC_TAG).toList()) { - var extension = - tree(tag) - .filter( - javadocToken(JavadocTokenTypes.CUSTOM_NAME) - .and(it -> it.getText().startsWith("@x-"))) - .findFirst() - .map(DetailNode::getText) - .orElse(null); - if (extension != null) { - extension = extension.substring(1).trim(); - var extensionValue = - tree(tag) - .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) - .findFirst() - .map(it -> getText(List.of(it.getChildren()), false)) - .orElse(null); - values.add(extension); - values.add(extensionValue); - } - } - return ExtensionJavaDocParser.parse(values); + this.tags = JavaDocTag.tags(javadoc); + this.extensions = JavaDocTag.extensions(javadoc); } static DetailNode toJavaDocNode(DetailAST node) { @@ -163,7 +93,7 @@ public String getText() { return getText(JavaDocSupport.forward(javadoc, JAVADOC_TAG).toList(), false); } - protected String getText(List nodes, boolean stripLeading) { + protected static String getText(List nodes, boolean stripLeading) { var builder = new StringBuilder(); for (var node : nodes) { if (node.getType() == JavadocTokenTypes.TEXT) { 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 new file mode 100644 index 0000000000..7ca15689c2 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java @@ -0,0 +1,142 @@ +/* + * 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 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 java.util.*; +import java.util.function.Predicate; + +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; +import io.jooby.SneakyThrows.Consumer2; +import io.jooby.SneakyThrows.Consumer3; +import io.jooby.StatusCode; + +public class JavaDocTag { + private static final Predicate CUSTOM_TAG = + javadocToken(JavadocTokenTypes.CUSTOM_NAME); + private static final Predicate TAG = + CUSTOM_TAG.and(it -> it.getText().equals("@tag")); + private static final Predicate EXTENSION = + CUSTOM_TAG.and(it -> it.getText().startsWith("@x-")); + private static final Predicate THROWS = + it -> tree(it).anyMatch(javadocToken(JavadocTokenTypes.THROWS_LITERAL)); + + public static Map throwList(DetailNode node) { + var result = new LinkedHashMap(); + javaDocTag( + node, + THROWS, + (tag, text) -> { + var statusCode = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .flatMap( + it -> + tree(it) + .filter(javadocToken(JavadocTokenTypes.HTML_TAG_NAME)) + .filter(tagName -> tagName.getText().equals("code")) + .flatMap( + tagName -> + backward(tagName) + .filter(javadocToken(JavadocTokenTypes.HTML_TAG)) + .findFirst() + .stream()) + .flatMap( + htmlTag -> + children(htmlTag) + .filter(javadocToken(JavadocTokenTypes.TEXT)) + .findFirst() + .stream()) + .map(DetailNode::getText) + .map( + value -> { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + }) + .filter(Objects::nonNull) + .filter(code -> code >= 400 && code <= 600) + .map(StatusCode::valueOf) + .findFirst()) + .orElse(null); + if (statusCode != null) { + var throwsDoc = new ThrowsDoc(statusCode, text); + result.putIfAbsent(statusCode, throwsDoc); + } + }); + return result; + } + + public static Map extensions(DetailNode node) { + var values = new ArrayList(); + javaDocTag( + node, + EXTENSION, + (tag, value) -> { + // Strip '@' + values.add(tag.getText().substring(1)); + values.add(value); + }); + return ExtensionJavaDocParser.parse(values); + } + + public static Map tags(DetailNode node) { + var result = new LinkedHashMap(); + javaDocTag( + node, + TAG, + (tag, value) -> { + var dot = value.indexOf("."); + var tagName = value; + String tagDescription = null; + if (dot > 0) { + tagName = value.substring(0, dot); + if (dot + 1 < value.length()) { + tagDescription = value.substring(dot + 1).trim(); + if (tagDescription.isBlank()) { + tagDescription = null; + } + } + } + if (!tagName.trim().isEmpty()) { + result.put(tagName, tagDescription); + } + }); + return result; + } + + public static void javaDocTag( + DetailNode tree, Predicate filter, Consumer2 consumer) { + javaDocTag(tree, filter, (tag, value, text) -> consumer.accept(tag, text)); + } + + public static void javaDocTag( + DetailNode tree, + Predicate filter, + Consumer3 consumer) { + if (tree != JavaDocNode.EMPTY_NODE) { + for (var tag : tree(tree).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { + var tagName = tree(tag).filter(filter).findFirst().orElse(null); + if (tagName != null) { + var tagValue = + tree(tag) + .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) + .findFirst() + .orElse(null); + var tagText = tagValue == null ? null : getText(List.of(tagValue.getChildren()), true); + consumer.accept(tagName, tagValue, tagText); + } + } + } + } +} 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 deae7c1150..ec665b3665 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 @@ -22,66 +22,7 @@ public class MethodDoc extends JavaDocNode { public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { super(ctx, node, javadoc); - throwList = throwList(this.javadoc); - } - - private Map throwList(DetailNode javadoc) { - var result = new LinkedHashMap(); - for (var tag : tree(javadoc).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) { - var isThrows = tree(tag).anyMatch(javadocToken(JavadocTokenTypes.THROWS_LITERAL)); - if (isThrows) { - var text = - tree(tag) - .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) - .findFirst() - .map(it -> getText(List.of(it.getChildren()), true)) - .orElse(null); - var statusCode = - tree(tag) - .filter(javadocToken(JavadocTokenTypes.DESCRIPTION)) - .findFirst() - .flatMap( - it -> - tree(it) - .filter(javadocToken(JavadocTokenTypes.HTML_TAG_NAME)) - .filter(tagName -> tagName.getText().equals("code")) - .flatMap( - tagName -> - backward(tagName) - .filter(javadocToken(JavadocTokenTypes.HTML_TAG)) - .findFirst() - .stream()) - .flatMap( - htmlTag -> - children(htmlTag) - .filter(javadocToken(JavadocTokenTypes.TEXT)) - .findFirst() - .stream()) - .map(DetailNode::getText) - .map( - value -> { - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - return null; - } - }) - .filter(Objects::nonNull) - .filter(code -> code >= 400 && code <= 600) - .map(StatusCode::valueOf) - .findFirst()) - .orElse(null); - // var className = tree(tag).filter(javadocToken(JavadocTokenTypes.CLASS_NAME)) - // .findFirst() - // .map(DetailNode::getText) - // .orElse(null); - if (statusCode != null) { - var throwsDoc = new ThrowsDoc(statusCode, text); - result.putIfAbsent(statusCode, throwsDoc); - } - } - } - return result; + throwList = JavaDocTag.throwList(this.javadoc); } MethodDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { @@ -118,7 +59,7 @@ public String getParameterDoc(String name, String in) { } return tree(javadoc) // must be a tag - .filter(it -> it.getType() == JavadocTokenTypes.JAVADOC_TAG) + .filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)) .filter( it -> { var children = children(it).toList(); diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 4125930623..1179301471 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -45,7 +45,7 @@ public void apiDoc() throws Exception { assertEquals("This is the Hello /endpoint", method.getSummary()); assertEquals("Operation description", method.getDescription()); assertEquals("Person name.", method.getParameterDoc("name")); - assertEquals("Person age.", method.getParameterDoc("age")); + assertEquals("Person age. Multi line doc.", method.getParameterDoc("age")); assertEquals("This line has a break.", method.getParameterDoc("list")); assertEquals("Some string.", method.getParameterDoc("str")); assertEquals("Welcome message 200.", method.getReturnDoc()); diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java index 5047bf3bab..55853cdce2 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -32,7 +32,7 @@ public class ApiDoc { *

Operation description * * @param name Person name. - * @param age Person age. + * @param age Person age. Multi line doc. * @param list This line has a break. * @param str Some string. * @return Welcome message 200. From e53dd2f2e54bb74afc96596f18a2cc3f9365f57e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 1 Aug 2025 19:22:00 -0300 Subject: [PATCH 14/17] open-api: parse javadoc #3729 - parse @server --- .../internal/openapi/javadoc/ClassDoc.java | 7 ++++ .../internal/openapi/javadoc/JavaDocTag.java | 34 +++++++++++++++++++ .../io/jooby/openapi/OpenAPIGenerator.java | 1 + .../java/issues/i3729/api/ApiDocTest.java | 2 ++ .../java/issues/i3729/api/AppLibrary.java | 1 + .../test/java/javadoc/JavaDocParserTest.java | 7 ++++ .../src/test/java/javadoc/JavadocPoc.java | 8 ----- .../src/test/java/javadoc/input/ApiDoc.java | 4 +++ 8 files changed, 56 insertions(+), 8 deletions(-) delete mode 100644 modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.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 39a7d79491..78ce9174ce 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 @@ -17,10 +17,12 @@ import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; import com.puppycrawl.tools.checkstyle.utils.TokenUtil; +import io.swagger.v3.oas.models.servers.Server; public class ClassDoc extends JavaDocNode { private final Map fields = new LinkedHashMap<>(); private final Map methods = new LinkedHashMap<>(); + private final List servers; public ClassDoc(JavaDocParser ctx, DetailAST node, DetailAST javaDoc) { super(ctx, node, javaDoc); @@ -29,6 +31,11 @@ public ClassDoc(JavaDocParser ctx, DetailAST node, DetailAST javaDoc) { } else if (isEnum()) { defaultEnumMembers(); } + this.servers = JavaDocTag.servers(this.javadoc); + } + + public List getServers() { + return servers; } public String getVersion() { 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 7ca15689c2..80c7582f1e 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 @@ -17,17 +17,51 @@ import io.jooby.SneakyThrows.Consumer2; import io.jooby.SneakyThrows.Consumer3; import io.jooby.StatusCode; +import io.swagger.v3.oas.models.servers.Server; public class JavaDocTag { private static final Predicate CUSTOM_TAG = javadocToken(JavadocTokenTypes.CUSTOM_NAME); private static final Predicate TAG = CUSTOM_TAG.and(it -> it.getText().equals("@tag")); + private static final Predicate SERVER = + CUSTOM_TAG.and(it -> it.getText().startsWith("@server.")); private static final Predicate EXTENSION = CUSTOM_TAG.and(it -> it.getText().startsWith("@x-")); private static final Predicate THROWS = it -> tree(it).anyMatch(javadocToken(JavadocTokenTypes.THROWS_LITERAL)); + @SuppressWarnings("unchecked") + public static List servers(DetailNode node) { + var values = new ArrayList(); + javaDocTag( + node, + SERVER, + (tag, value) -> { + values.add(tag.getText().substring(1)); + values.add(value); + }); + List result = new ArrayList<>(); + if (!values.isEmpty()) { + var serverMap = ExtensionJavaDocParser.parse(values); + var servers = serverMap.get("server"); + if (!(servers instanceof List)) { + servers = List.of(servers); + } + ((List) servers) + .forEach( + it -> { + if (it instanceof Map hash) { + var server = new Server(); + server.setDescription((String) hash.get("description")); + server.setUrl((String) hash.get("url")); + result.add(server); + } + }); + } + return result; + } + public static Map throwList(DetailNode node) { var result = new LinkedHashMap(); javaDocTag( diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 1834660aff..e6674df22b 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -167,6 +167,7 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { if (!doc.getExtensions().isEmpty()) { info.setExtensions(doc.getExtensions()); } + doc.getServers().forEach(openapi::addServersItem); }); } 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 89ed73010b..a310e17f92 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 @@ -23,6 +23,8 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " x-logo:\n" + " url: https://redocly.github.io/redoc/museum-logo.png\n" + " altText: Museum logo\n" + + "servers:\n" + + "- url: https://api.fake-museum-example.com/v1\n" + "tags:\n" + "- name: Library\n" + " description: Access to all books.\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java index 755d80f04c..82c568d4b3 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java @@ -15,6 +15,7 @@ *

Available data: Books and authors. * * @version 4.0.0 + * @server.url https://api.fake-museum-example.com/v1 * @x-logo.url https://redocly.github.io/redoc/museum-logo.png * @x-logo.altText Museum logo */ diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index 1179301471..c0214dcf84 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -37,6 +37,13 @@ public void apiDoc() throws Exception { + " ante id vestibulum congue. Nam et tortor at magna tempor congue.", doc.getDescription()); + var servers = doc.getServers(); + assertEquals(2, servers.size()); + assertEquals("https://api.example.com/v1", servers.get(0).getUrl()); + assertEquals("Production server (uses live data)", servers.get(0).getDescription()); + assertEquals("https://sandbox-api.example.com:8443/v1", servers.get(1).getUrl()); + assertEquals("Sandbox server (uses test data)", servers.get(1).getDescription()); + withMethod( doc, "hello", diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.java b/modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.java deleted file mode 100644 index fc9747c1cd..0000000000 --- a/modules/jooby-openapi/src/test/java/javadoc/JavadocPoc.java +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package javadoc; - -public class JavadocPoc {} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java index 55853cdce2..101c04da71 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java @@ -22,6 +22,10 @@ * @x-badges.position before * @x-badges.color purple * @tag ApiTag + * @server.url https://api.example.com/v1 + * @server.description Production server (uses live data) + * @server.url https://sandbox-api.example.com:8443/v1 + * @server.description Sandbox server (uses test data) */ @Path("/api") public class ApiDoc { From e07c92836bacde6b9610354443e393278574b8dd Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 1 Aug 2025 19:53:20 -0300 Subject: [PATCH 15/17] open-api: parse javadoc #3729 - better support for tags --- .../internal/openapi/AnnotationParser.java | 16 +---- .../javadoc/ExtensionJavaDocParser.java | 6 +- .../internal/openapi/javadoc/JavaDocNode.java | 5 +- .../internal/openapi/javadoc/JavaDocTag.java | 63 ++++++++++++++----- .../java/issues/i3729/api/LibraryApi.java | 3 +- 5 files changed, 60 insertions(+), 33 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 892d5aadd8..723c5be74c 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 @@ -33,7 +33,6 @@ import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; -import io.swagger.v3.oas.models.tags.Tag; import jakarta.inject.Named; import jakarta.inject.Provider; @@ -280,7 +279,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty doc -> { operationExt.setPathDescription(doc.getDescription()); operationExt.setPathSummary(doc.getSummary()); - tags(doc.getTags()).forEach(operationExt::addTag); + doc.getTags().forEach(operationExt::addTag); if (!doc.getExtensions().isEmpty()) { operationExt.setPathExtensions(doc.getExtensions()); } @@ -298,7 +297,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty if (!methodDoc.getExtensions().isEmpty()) { operationExt.setExtensions(methodDoc.getExtensions()); } - tags(methodDoc.getTags()).forEach(operationExt::addTag); + methodDoc.getTags().forEach(operationExt::addTag); // Parameters for (var parameterName : parameterNames) { var paramExt = @@ -346,17 +345,6 @@ public static List parse(ParserContext ctx, String prefix, Type ty return result; } - private static List tags(Map tags) { - List result = new ArrayList<>(); - for (var tagNode : tags.entrySet()) { - var tag = new Tag(); - tag.setName(tagNode.getKey()); - tag.setDescription(tagNode.getValue()); - result.add(tag); - } - return result; - } - private static Map methods(ParserContext ctx, ClassNode node) { Map methods = new LinkedHashMap<>(); if (node.superName != null && !node.superName.equals(TypeFactory.OBJECT.getInternalName())) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java index e18d707e47..3c19e85d95 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java @@ -34,7 +34,11 @@ public static Map parse(List properties) { (List) currentNode.computeIfAbsent(finalKey, k -> new ArrayList()); values.add(value); } - return (Map) restructureNode(root); + var result = restructureNode(root); + if (result instanceof Map) { + return (Map) result; + } + throw new IllegalStateException("DD"); } /** 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 b6faf994c8..ed1ec82139 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 @@ -17,6 +17,7 @@ import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes; import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; +import io.swagger.v3.oas.models.tags.Tag; public class JavaDocNode { private static final Predicate JAVADOC_TAG = @@ -26,7 +27,7 @@ public class JavaDocNode { protected final DetailAST node; protected final DetailNode javadoc; private final Map extensions; - private final Map tags; + private final List tags; public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) { this(ctx, node, toJavaDocNode(comment)); @@ -76,7 +77,7 @@ public String getSummary() { return string.isEmpty() ? null : string; } - public Map getTags() { + public List getTags() { return tags; } 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 80c7582f1e..c67cb86ddd 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 @@ -18,12 +18,13 @@ import io.jooby.SneakyThrows.Consumer3; import io.jooby.StatusCode; import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.tags.Tag; public class JavaDocTag { private static final Predicate CUSTOM_TAG = javadocToken(JavadocTokenTypes.CUSTOM_NAME); private static final Predicate TAG = - CUSTOM_TAG.and(it -> it.getText().equals("@tag")); + CUSTOM_TAG.and(it -> it.getText().startsWith("@tag.") || it.getText().equals("@tag")); private static final Predicate SERVER = CUSTOM_TAG.and(it -> it.getText().startsWith("@server.")); private static final Predicate EXTENSION = @@ -124,31 +125,63 @@ public static Map extensions(DetailNode node) { return ExtensionJavaDocParser.parse(values); } - public static Map tags(DetailNode node) { - var result = new LinkedHashMap(); + public static List tags(DetailNode node) { + var result = new ArrayList(); + var values = new ArrayList(); javaDocTag( node, TAG, (tag, value) -> { - var dot = value.indexOf("."); - var tagName = value; - String tagDescription = null; - if (dot > 0) { - tagName = value.substring(0, dot); - if (dot + 1 < value.length()) { - tagDescription = value.substring(dot + 1).trim(); - if (tagDescription.isBlank()) { - tagDescription = null; + if (tag.getText().equals("@tag")) { + // Process single line tag: + // - @tag Book. Book Operations + // - @tag Book + var dot = value.indexOf("."); + var tagName = value; + String tagDescription = null; + if (dot > 0) { + tagName = value.substring(0, dot); + if (dot + 1 < value.length()) { + tagDescription = value.substring(dot + 1).trim(); + if (tagDescription.isBlank()) { + tagDescription = null; + } } } - } - if (!tagName.trim().isEmpty()) { - result.put(tagName, tagDescription); + if (!tagName.trim().isEmpty()) { + + result.add(createTag(tagName, tagDescription)); + } + } else { + values.add(tag.getText().substring(1)); + values.add(value); } }); + if (!values.isEmpty()) { + var tagMap = ExtensionJavaDocParser.parse(values); + var tags = tagMap.get("tag"); + if (!(tags instanceof List)) { + tags = List.of(tags); + } + ((List) tags) + .forEach( + e -> { + if (e instanceof Map hash) { + result.add( + createTag((String) hash.get("name"), (String) hash.get("description"))); + } + }); + } return result; } + private static Tag createTag(String tagName, String tagDescription) { + Tag tag = new Tag(); + tag.setName(tagName); + tag.setDescription(tagDescription); + return tag; + } + public static void javaDocTag( DetailNode tree, Predicate filter, Consumer2 consumer) { javaDocTag(tree, filter, (tag, value, text) -> consumer.accept(tag, text)); diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java index a8c69e81ab..5e75b61d2e 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -16,7 +16,8 @@ * *

Contains all operations for creating, updating and fetching books. * - * @tag Library. Access to all books. + * @tag.name Library + * @tag.description Access to all books. */ @Path("/api/library") public class LibraryApi { From 3681e10a483e129629a4cd657a030cb93e24c3e6 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 1 Aug 2025 20:04:00 -0300 Subject: [PATCH 16/17] openapi: code cleanup #ref 3729 --- .../io/jooby/internal/openapi/javadoc/JavaDocTag.java | 8 ++++---- ...ExtensionJavaDocParser.java => MiniYamlDocParser.java} | 6 +++--- .../src/test/java/javadoc/ExtensionJavaDocParserTest.java | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) rename modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/{ExtensionJavaDocParser.java => MiniYamlDocParser.java} (95%) 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 c67cb86ddd..f3f9774fe4 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 @@ -42,9 +42,9 @@ public static List servers(DetailNode node) { values.add(tag.getText().substring(1)); values.add(value); }); - List result = new ArrayList<>(); + var result = new ArrayList(); if (!values.isEmpty()) { - var serverMap = ExtensionJavaDocParser.parse(values); + var serverMap = MiniYamlDocParser.parse(values); var servers = serverMap.get("server"); if (!(servers instanceof List)) { servers = List.of(servers); @@ -122,7 +122,7 @@ public static Map extensions(DetailNode node) { values.add(tag.getText().substring(1)); values.add(value); }); - return ExtensionJavaDocParser.parse(values); + return MiniYamlDocParser.parse(values); } public static List tags(DetailNode node) { @@ -158,7 +158,7 @@ public static List tags(DetailNode node) { } }); if (!values.isEmpty()) { - var tagMap = ExtensionJavaDocParser.parse(values); + var tagMap = MiniYamlDocParser.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/ExtensionJavaDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java similarity index 95% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java rename to modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java index 3c19e85d95..cb41bd937d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ExtensionJavaDocParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java @@ -10,7 +10,7 @@ import java.util.List; import java.util.Map; -public class ExtensionJavaDocParser { +public class MiniYamlDocParser { @SuppressWarnings("unchecked") public static Map parse(List properties) { // The root of our final tree structure. @@ -38,7 +38,7 @@ public static Map parse(List properties) { if (result instanceof Map) { return (Map) result; } - throw new IllegalStateException("DD"); + throw new IllegalArgumentException("Unable to parse: " + properties); } /** @@ -102,7 +102,7 @@ private static Object restructureNode(Object node) { listOfObjects.add(objectMap); } if (listOfObjects.size() == 1 - && restructuredMap.keySet().stream().noneMatch(ExtensionJavaDocParser::startsWithDash)) { + && restructuredMap.keySet().stream().noneMatch(MiniYamlDocParser::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 b45d01bdbb..fa0b1e048e 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.ExtensionJavaDocParser.parse; +import static io.jooby.internal.openapi.javadoc.MiniYamlDocParser.parse; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; From b43847d75d04be422c33564a8c02f7b7d6251a52 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 2 Aug 2025 20:46:28 -0300 Subject: [PATCH 17/17] open-api: expand query doc from beans - #3729 --- .../internal/openapi/AnnotationParser.java | 7 +------ .../jooby/internal/openapi/ParameterExt.java | 11 ----------- .../io/jooby/internal/openapi/RouteParser.java | 9 ++++++++- .../internal/openapi/javadoc/MethodDoc.java | 7 ------- .../test/java/issues/i3729/api/ApiDocTest.java | 8 ++++---- .../test/java/issues/i3729/api/BookQuery.java | 2 +- .../test/java/javadoc/JavaDocParserTest.java | 18 +++++------------- 7 files changed, 19 insertions(+), 43 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 723c5be74c..40473debed 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 @@ -306,12 +306,7 @@ public static List parse(ParserContext ctx, String prefix, Type ty .findFirst() .map(ParameterExt.class::cast) .orElse(null); - var paramDoc = - methodDoc.getParameterDoc( - parameterName, - Optional.ofNullable(paramExt) - .map(ParameterExt::getContainerType) - .orElse(null)); + var paramDoc = methodDoc.getParameterDoc(parameterName); if (paramDoc != null) { if (paramExt == null) { operationExt.getRequestBody().setDescription(paramDoc); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java index ac2b7dbf58..16c05e9a1e 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java @@ -10,9 +10,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore; public class ParameterExt extends io.swagger.v3.oas.models.parameters.Parameter { - /* keep track of expanded query bean parameters. */ - @JsonIgnore private String containerType; - @JsonIgnore private String javaType; @JsonIgnore private Object defaultValue; @@ -27,14 +24,6 @@ public String getJavaType() { return javaType; } - public void setContainerType(String containerType) { - this.containerType = containerType; - } - - public String getContainerType() { - return containerType; - } - public Object getDefaultValue() { if (defaultValue != null) { if (javaType.equals(boolean.class.getName())) { 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 a6c146a28f..42d30e3aa5 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 @@ -169,6 +169,7 @@ private List checkParameters(ParserContext ctx, List param .isPresent(); if (expand) { SchemaRef ref = ctx.schemaRef(javaType).get(); + var doc = ctx.javadoc().parse(javaType).orElse(null); for (Object e : ref.schema.getProperties().entrySet()) { String name = (String) ((Map.Entry) e).getKey(); Schema s = (Schema) ((Map.Entry) e).getValue(); @@ -176,11 +177,17 @@ private List checkParameters(ParserContext ctx, List param schemaMap.remove("description"); var schemaNoDesc = Json.mapper().convertValue(schemaMap, Schema.class); ParameterExt p = new ParameterExt(); - p.setContainerType(javaType); p.setName(name); p.setIn(parameter.getIn()); p.setSchema(schemaNoDesc); + // default doc p.setDescription(parameter.getDescription()); + if (doc != null) { + var propertyDoc = doc.getPropertyDoc(name); + if (propertyDoc != null) { + p.setDescription(propertyDoc); + } + } params.add(p); } 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 ec665b3665..620523f2f8 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 @@ -50,13 +50,6 @@ public List getParameterNames() { } public String getParameterDoc(String name) { - return getParameterDoc(name, null); - } - - public String getParameterDoc(String name, String in) { - if (in != null) { - return context.parse(in).map(bean -> bean.getPropertyDoc(name)).orElse(null); - } return tree(javadoc) // must be a tag .filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)) 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 a310e17f92..d88a481843 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 @@ -96,17 +96,17 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " parameters:\n" + " - name: title\n" + " in: query\n" - + " description: Book's param query.\n" + + " description: Book's title.\n" + " schema:\n" + " type: string\n" + " - name: author\n" + " in: query\n" - + " description: Book's param query.\n" + + " description: Book's author. Optional.\n" + " schema:\n" + " type: string\n" + " - name: isbn\n" + " in: query\n" - + " description: Book's param query.\n" + + " description: Book's isbn. Optional.\n" + " schema:\n" + " type: string\n" + " responses:\n" @@ -167,7 +167,7 @@ public void shouldGenerateDoc(OpenAPIResult result) { + " properties:\n" + " title:\n" + " type: string\n" - + " description: Book's title. Optional.\n" + + " description: Book's title.\n" + " author:\n" + " type: string\n" + " description: Book's author. Optional.\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java index 8fbdeaf7ff..51cc1f1493 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java @@ -8,7 +8,7 @@ /** * Query books by complex filters. * - * @param title Book's title. Optional. + * @param title Book's title. * @param author Book's author. Optional. * @param isbn Book's isbn. Optional. */ diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java index c0214dcf84..1d92be4ab3 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -65,13 +65,9 @@ public void apiDoc() throws Exception { method -> { assertEquals("Search database.", method.getSummary()); assertEquals("Search DB", method.getDescription()); - assertEquals( - "Filter query. Works like internal filter.", - method.getParameterDoc("fq", "javadoc.input.QueryBeanDoc")); - assertEquals( - "Offset, used for paging.", - method.getParameterDoc("offset", "javadoc.input.QueryBeanDoc")); - assertNull(method.getParameterDoc("limit", "javadoc.input.QueryBeanDoc")); + assertNull(method.getParameterDoc("fq")); + assertNull(method.getParameterDoc("offset")); + assertNull(method.getParameterDoc("limit")); assertNull(method.getReturnDoc()); }); @@ -82,12 +78,8 @@ public void apiDoc() throws Exception { method -> { assertEquals("Record database.", method.getSummary()); assertNull(method.getDescription()); - assertEquals( - "Person id. Unique person identifier.", - method.getParameterDoc("id", "javadoc.input.RecordBeanDoc")); - assertEquals( - "Person name. Example: edgar.", - method.getParameterDoc("name", "javadoc.input.RecordBeanDoc")); + assertNull(method.getParameterDoc("id")); + assertNull(method.getParameterDoc("name")); }); withMethod(