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-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 2cbe5aafff..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,23 +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 - String metaInf = - outputDir - .resolve("META-INF") - .resolve("services") - .resolve("io.jooby.MvcFactory") - .toAbsolutePath() - .toString(); + 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("META-INF: " + metaInf); + getLog().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-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 854db1f58e..3c278d76da 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/AnnotationParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java index 9b3001e5cc..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 @@ -20,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; @@ -251,9 +248,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,7 +268,75 @@ 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 javaDocParser = ctx.javadoc(); + for (OperationExt operationExt : result) { + operationExt.setController(classNode); + try { + var className = operationExt.getControllerName().replace("/", "."); + javaDocParser + .parse(className) + .ifPresent( + doc -> { + operationExt.setPathDescription(doc.getDescription()); + operationExt.setPathSummary(doc.getSummary()); + doc.getTags().forEach(operationExt::addTag); + if (!doc.getExtensions().isEmpty()) { + operationExt.setPathExtensions(doc.getExtensions()); + } + var parameterNames = + Optional.ofNullable(operationExt.getNode().parameters) + .orElse(List.of()) + .stream() + .map(p -> p.name) + .toList(); + doc.getMethod(operationExt.getOperationId(), parameterNames) + .ifPresent( + methodDoc -> { + operationExt.setSummary(methodDoc.getSummary()); + operationExt.setDescription(methodDoc.getDescription()); + if (!methodDoc.getExtensions().isEmpty()) { + operationExt.setExtensions(methodDoc.getExtensions()); + } + methodDoc.getTags().forEach(operationExt::addTag); + // Parameters + for (var parameterName : parameterNames) { + var paramExt = + operationExt.getParameters().stream() + .filter(p -> p.getName().equals(parameterName)) + .findFirst() + .map(ParameterExt.class::cast) + .orElse(null); + var paramDoc = methodDoc.getParameterDoc(parameterName); + if (paramDoc != null) { + if (paramExt == null) { + operationExt.getRequestBody().setDescription(paramDoc); + } else { + paramExt.setDescription(paramDoc); + } + } + } + // return types + var defaultResponse = operationExt.getDefaultResponse(); + if (defaultResponse != null) { + defaultResponse.setDescription(methodDoc.getReturnDoc()); + } + for (var throwsDoc : methodDoc.getThrows().values()) { + var response = + operationExt.getResponse( + Integer.toString(throwsDoc.getStatusCode().value())); + if (response == null) { + response = + operationExt.addResponse( + Integer.toString(throwsDoc.getStatusCode().value())); + } + response.setDescription(throwsDoc.getText()); + } + }); + }); + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } + } return result; } @@ -382,7 +456,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/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/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/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 644a950ad6..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,14 +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.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; @@ -100,28 +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 JavaDocParser javadocParser; - public ParserContext(ClassSource source, Type router, Set debug) { - this(source, new HashMap<>(), router, debug); + public ParserContext( + ClassSource source, Type router, JavaDocParser javadocParser, Set debug) { + this(source, new HashMap<>(), router, javadocParser, debug); } private ParserContext( - ClassSource source, Map nodes, Type router, Set debug) { + ClassSource source, + Map nodes, + Type router, + JavaDocParser javadocParser, + Set debug) { this.router = router; this.source = source; this.debug = Optional.ofNullable(debug).orElse(Collections.emptySet()); this.nodes = nodes; + 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); } @@ -163,6 +170,10 @@ public Collection schemas() { return schemas.values().stream().map(ref -> ref.schema).collect(Collectors.toList()); } + public JavaDocParser javadoc() { + return javadocParser; + } + public Schema schema(Class type) { if (isVoid(type.getName())) { return null; @@ -261,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); } @@ -269,20 +280,106 @@ public Schema schema(Class type) { new SchemaRef( resolvedSchema.schema, RefUtils.constructRef(resolvedSchema.schema.getName())); schemas.put(type.getName(), schemaRef); - + 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); } } + for (var e : resolvedSchema.referencedSchemasByType.entrySet()) { + var qualifiedTypeName = toClass(e.getKey()); + if (qualifiedTypeName instanceof Class classType) { + document(classType, e.getValue(), resolvedSchema); + } + } } } return schemaRef.toSchema(); } + 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) { return Optional.ofNullable(schemas.get(type)); } @@ -429,7 +526,7 @@ public boolean process(AbstractInsnNode instruction) { } public ParserContext newContext(Type router) { - return new ParserContext(source, nodes, router, 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/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/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/RouteParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java index 4caad4f0ff..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 @@ -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; @@ -50,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; @@ -59,23 +57,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 = @@ -185,13 +169,25 @@ 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(); + var schemaMap = Json.mapper().convertValue(s, Map.class); + schemaMap.remove("description"); + var schemaNoDesc = Json.mapper().convertValue(schemaMap, Schema.class); ParameterExt p = new ParameterExt(); p.setName(name); p.setIn(parameter.getIn()); - p.setSchema(s); + 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); } @@ -279,28 +275,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/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 new file mode 100644 index 0000000000..78ce9174ce --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -0,0 +1,235 @@ +/* + * 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.*; +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; +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); + if (isRecord()) { + defaultRecordMembers(); + } else if (isEnum()) { + defaultEnumMembers(); + } + this.servers = JavaDocTag.servers(this.javadoc); + } + + public List getServers() { + return servers; + } + + 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 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; + } + + private void defaultRecordMembers() { + 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() { + 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); + } + } + + 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() { + 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) + .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); + } + + @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..6a8455ac17 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java @@ -0,0 +1,24 @@ +/* + * 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(); + } +} 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..ed1ec82139 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java @@ -0,0 +1,236 @@ +/* + * 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 static io.jooby.internal.openapi.javadoc.JavaDocSupport.javadocToken; + +import java.util.*; +import java.util.function.Predicate; + +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.v3.oas.models.tags.Tag; + +public class JavaDocNode { + private static final Predicate JAVADOC_TAG = + javadocToken(JavadocTokenTypes.JAVADOC_TAG); + + protected final JavaDocParser context; + protected final DetailAST node; + protected final DetailNode javadoc; + private final Map extensions; + private final List tags; + + 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; + this.tags = JavaDocTag.tags(javadoc); + this.extensions = JavaDocTag.extensions(javadoc); + } + + static DetailNode toJavaDocNode(DetailAST node) { + return node == EMPTY_AST + ? EMPTY_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()) { + 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 List getTags() { + return tags; + } + + 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 static 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 + 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 JavadocTokenTypes.TEXT; + } + }; + + 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; + } + }; +} 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..1ef99d687e --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java @@ -0,0 +1,134 @@ +/* + * 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 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.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 final List baseDir; + private final Map cache = new HashMap<>(); + + 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)); + } + + 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); + + 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; + } + + private void traverse( + DetailAST tree, + Predicate types, + Predicate modifiers, + BiConsumer action) { + for (var node : tree(tree).filter(types).toList()) { + var mods = + tree(node) + .filter(tokens(TokenTypes.MODIFIERS)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Modifiers not found on " + node)); + if (modifiers.test(mods)) { + var docRoot = node.getType() == TokenTypes.VARIABLE_DEF ? mods.getParent() : mods; + var comment = + tree(docRoot) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(JavaDocNode.EMPTY_AST); + action.accept(node, comment); + } + } + } + + public DetailAST resolve(Path path) { + return lookup(path) + .map( + 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/JavaDocSupport.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java new file mode 100644 index 0000000000..6000c4ea52 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java @@ -0,0 +1,252 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; + +public final class JavaDocSupport { + + public static Predicate tokens(Integer... types) { + return tokens(Set.of(types)); + } + + private static Predicate tokens(Set types) { + return it -> types.contains(it.getType()); + } + + public static Predicate javadocToken(Integer... types) { + return javadocToken(Set.of(types)); + } + + private static Predicate javadocToken(Set types) { + return it -> types.contains(it.getType()); + } + + /** + * Traverse the tree from current node to parent (backward). + * + * @param node Starting point + * @return Stream. + */ + public static Stream backward(DetailAST node) { + return backward(ASTNode.ast(node)); + } + + /** + * Traverse the tree from the current node to children and sibling (forward). + * + * @param node Starting point + * @return Stream. + */ + public static Stream forward(DetailAST node) { + return forward(ASTNode.ast(node)); + } + + /** + * Traverse the tree from the current node to children and sibling (forward) but keeping the scope + * to the given node (root). + * + * @param node Root node. + * @return Stream. + */ + public static Stream tree(DetailAST node) { + return tree(ASTNode.ast(node)); + } + + public static Stream tree(DetailNode node) { + return tree(ASTNode.javadoc(node)); + } + + public static Stream children(DetailAST node) { + return stream(childrenIterator(ASTNode.ast(node))); + } + + public static Stream children(DetailNode node) { + return stream(childrenIterator(ASTNode.javadoc(node))); + } + + public static Stream backward(DetailNode node) { + return backward(ASTNode.javadoc(node)); + } + + public static Stream forward(DetailNode node) { + return forward(ASTNode.javadoc(node)); + } + + public static Stream forward(DetailNode node, Predicate stopOn) { + var nodes = forward(ASTNode.javadoc(node)).toList(); + var result = new ArrayList(); + for (var it : nodes) { + if (stopOn.test(it)) { + break; + } + result.add(it); + } + return result.stream(); + } + + private static Stream backward(ASTNode node) { + return stream(backwardIterator(node)); + } + + private static Stream forward(ASTNode node) { + return stream(forwardIterator(node)); + } + + private static Stream tree(ASTNode node) { + return stream(treeIterator(node)); + } + + private static Stream stream(Iterator iterator) { + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + } + + private static Iterator treeIterator(ASTNode node) { + return forwardIterator(node, false); + } + + private static Iterator forwardIterator(ASTNode node) { + return forwardIterator(node, true); + } + + private static Iterator childrenIterator(ASTNode node) { + return new Iterator<>() { + Function, ASTNode> direction = null; + ASTNode it = node; + + @Override + public boolean hasNext() { + if (direction == null) { + direction = ASTNode::getFirstChild; + } else { + direction = ASTNode::getNextSibling; + } + return direction.apply(it) != null; + } + + @Override + public T next() { + it = direction.apply(it); + return it.getNode(); + } + }; + } + + private static Iterator backwardIterator(ASTNode node) { + return new Iterator<>() { + ASTNode it = node; + + @Override + public boolean hasNext() { + return it.getParent() != null; + } + + @Override + public T next() { + it = it.getParent(); + return it.getNode(); + } + }; + } + + private static Iterator forwardIterator(ASTNode node, boolean full) { + return new Iterator<>() { + ASTNode it = node; + final Stack> stack = new Stack<>(); + + @Override + public boolean hasNext() { + return it != null; + } + + @Override + public T next() { + if (it.getNextSibling() != null) { + if (full || it != node) { + stack.push(it.getNextSibling()); + } + } + var current = it; + var child = it.getFirstChild(); + if (child == null) { + if (!stack.isEmpty()) { + it = stack.pop(); + } else { + it = null; + } + } else { + it = child; + } + return current.getNode(); + } + }; + } + + private interface ASTNode { + ASTNode getFirstChild(); + + ASTNode getNextSibling(); + + ASTNode getParent(); + + Node getNode(); + + static ASTNode ast(DetailAST node) { + return ast(node, DetailAST::getParent, DetailAST::getFirstChild, DetailAST::getNextSibling); + } + + static ASTNode javadoc(DetailNode node) { + return ast( + node, DetailNode::getParent, JavadocUtil::getFirstChild, JavadocUtil::getNextSibling); + } + + static ASTNode ast( + N node, Function parent, Function child, Function sibling) { + return new ASTNode<>() { + @Override + public ASTNode getParent() { + var parentNode = parent.apply(node); + if (parentNode == null) { + return null; + } + return ast(parentNode, parent, child, sibling); + } + + @Override + public ASTNode getFirstChild() { + var childNode = child.apply(node); + if (childNode == null) { + return null; + } + return ast(childNode, parent, child, sibling); + } + + @Override + public N getNode() { + return node; + } + + @Override + public ASTNode getNextSibling() { + var siblingNode = sibling.apply(node); + if (siblingNode == null) { + return null; + } + return ast(siblingNode, parent, child, sibling); + } + }; + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java new file mode 100644 index 0000000000..f3f9774fe4 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java @@ -0,0 +1,209 @@ +/* + * 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; +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().startsWith("@tag.") || 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); + }); + var result = new ArrayList(); + if (!values.isEmpty()) { + var serverMap = MiniYamlDocParser.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( + 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 MiniYamlDocParser.parse(values); + } + + public static List tags(DetailNode node) { + var result = new ArrayList(); + var values = new ArrayList(); + javaDocTag( + node, + TAG, + (tag, value) -> { + 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.add(createTag(tagName, tagDescription)); + } + } else { + values.add(tag.getText().substring(1)); + values.add(value); + } + }); + if (!values.isEmpty()) { + var tagMap = MiniYamlDocParser.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)); + } + + 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 new file mode 100644 index 0000000000..620523f2f8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java @@ -0,0 +1,95 @@ +/* + * 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.*; +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 = JavaDocTag.throwList(this.javadoc); + } + + MethodDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { + super(ctx, node, javadoc); + } + + public String getName() { + return node.findFirstToken(TokenTypes.IDENT).getText(); + } + + 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(javadocToken(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); + } + + 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); + } + + public Map getThrows() { + return throwList; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java new file mode 100644 index 0000000000..cb41bd937d --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MiniYamlDocParser.java @@ -0,0 +1,120 @@ +/* + * 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 MiniYamlDocParser { + @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); + } + var result = restructureNode(root); + if (result instanceof Map) { + return (Map) result; + } + throw new IllegalArgumentException("Unable to parse: " + properties); + } + + /** + * 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(MiniYamlDocParser::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/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/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 99ebc1670c..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 @@ -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,6 +20,7 @@ import io.jooby.Router; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.*; +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; @@ -100,21 +95,7 @@ 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"); - } + private List sources; /** * Export an {@link OpenAPI} model to the given format. @@ -163,14 +144,35 @@ public OpenAPIGenerator() { 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); - RouteParser routes = new RouteParser(metaInf); - ParserContext ctx = new ParserContext(source, TypeFactory.fromJavaName(classname), debug); + 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); + if (!doc.getExtensions().isEmpty()) { + info.setExtensions(doc.getExtensions()); + } + doc.getServers().forEach(openapi::addServersItem); + }); + } + + RouteParser routes = new RouteParser(); + ParserContext ctx = new ParserContext(source, mainType, javadoc, debug); List operations = routes.parse(ctx, openapi); String contextPath = ContextPathParser.parse(ctx); @@ -195,7 +197,7 @@ public OpenAPIGenerator() { 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(); @@ -217,18 +219,19 @@ public OpenAPIGenerator() { 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)); + 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); @@ -342,6 +345,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 659b1cab4a..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,16 +78,14 @@ 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); + 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/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/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()); - } -} diff --git a/modules/jooby-openapi/src/test/java/issues/i2403/App2403.java b/modules/jooby-openapi/src/test/java/issues/i3729/App3729.java similarity index 67% rename from modules/jooby-openapi/src/test/java/issues/i2403/App2403.java rename to modules/jooby-openapi/src/test/java/issues/i3729/App3729.java index f3342f4fc3..76708ec5d2 100644 --- a/modules/jooby-openapi/src/test/java/issues/i2403/App2403.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/App3729.java @@ -3,14 +3,14 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package issues.i2403; +package issues.i3729; import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; import io.jooby.Jooby; -public class App2403 extends Jooby { +public class App3729 extends Jooby { { - mvc(toMvcExtension(Controller2403Copy.class)); + 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..3660069c4b --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/Issue3729.java @@ -0,0 +1,56 @@ +/* + * 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" + + " 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" + + " 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: Found user.\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..3077349f50 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Address.java @@ -0,0 +1,57 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +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() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + /** + * City name. + * + * @return City name. + */ + 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..d88a481843 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -0,0 +1,228 @@ +/* + * 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: \"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" + + "servers:\n" + + "- url: https://api.fake-museum-example.com/v1\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" + + " - 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: 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/{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" + + " - name: title\n" + + " in: query\n" + + " description: Book's title.\n" + + " schema:\n" + + " type: string\n" + + " - name: author\n" + + " in: query\n" + + " description: Book's author. Optional.\n" + + " schema:\n" + + " type: string\n" + + " - name: isbn\n" + + " in: query\n" + + " description: Book's isbn. Optional.\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" + + " x-badges:\n" + + " - name: Beta\n" + + " 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" + + " requestBody:\n" + + " description: Book to create.\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: \"#/components/schemas/Book\"\n" + + " required: true\n" + + " responses:\n" + + " \"200\":\n" + + " description: Saved book.\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $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" + + " title:\n" + + " type: string\n" + + " description: Book's title.\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" + + " 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" + + " - Fiction\n" + + " - NonFiction\n" + + " authors:\n" + + " uniqueItems: true\n" + + " type: array\n" + + " items:\n" + + " $ref: \"#/components/schemas/Author\"\n" + + " description: Book model.\n", + 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..82c568d4b3 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java @@ -0,0 +1,27 @@ +/* + * 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; + +/** + * Library API. + * + *

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 + */ +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..0901c5853e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Author.java @@ -0,0 +1,55 @@ +/* + * 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 { + /* + * Social security number. + */ + String ssn; + + /** Author's name. */ + String name; + + Address address; + + /** Published books. */ + 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..bfc4c2bc3e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java @@ -0,0 +1,81 @@ +/* + * 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; + +/** Book model. */ +public class Book { + /** Book ISBN. */ + private String isbn; + + /** Book's title. */ + String title; + + /** Publication date. Format mm-dd-yyyy. */ + LocalDate publicationDate; + + String text; + + /** Book type. */ + Type type = Type.Fiction; + + Set authors; + + /** + * Book ISBN. Method. + * + * @return Book ISBN. Method. + */ + 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/BookQuery.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java new file mode 100644 index 0000000000..51cc1f1493 --- /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. + * @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 new file mode 100644 index 0000000000..5e75b61d2e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -0,0 +1,79 @@ +/* + * 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.List; + +import io.jooby.annotation.*; +import io.jooby.exception.BadRequestException; +import io.jooby.exception.NotFoundException; + +/** + * Library API. + * + *

Contains all operations for creating, updating and fetching books. + * + * @tag.name Library + * @tag.description Access to all books. + */ +@Path("/api/library") +public class LibraryApi { + + /** + * Find a book by isbn. + * + * @param isbn Book isbn. Like IK-1900. + * @return A matching book. + * @throws NotFoundException 404 If a book doesn't exist. + * @throws BadRequestException 400 For bad ISBN code. + * @tag Book + * @tag Author + */ + @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. + * + * @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) { + return List.of(new Book()); + } + + /** + * Creates a new book. + * + *

Book can be created or updated. + * + * @param book Book to create. + * @return Saved book. + * @tag Author + */ + @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..1711a5998d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Type.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +/** + * Books can be broadly categorized into fiction and non-fiction. With many genres and subgenres + * within each. + */ +public enum Type { + /** + * Fiction books are based on imaginary characters and events, while non-fiction books are based o + * n real people and events. + */ + Fiction, + + /** Non-fiction genres include biography, autobiography, history, self-help, and true crime. */ + NonFiction; +} 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..fa0b1e048e --- /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.MiniYamlDocParser.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/JavaDocParserTest.java b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java new file mode 100644 index 0000000000..1d92be4ab3 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java @@ -0,0 +1,266 @@ +/* + * 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 javadoc.input.EnumDoc; + +import org.junit.jupiter.api.Test; + +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.javadoc.ClassDoc; +import io.jooby.internal.openapi.javadoc.FieldDoc; +import io.jooby.internal.openapi.javadoc.JavaDocParser; +import io.jooby.internal.openapi.javadoc.MethodDoc; +import issues.i3729.api.Book; + +public class JavaDocParserTest { + + @Test + public void apiDoc() throws Exception { + withDoc( + javadoc.input.ApiDoc.class, + 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()); + + 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", + List.of("name", "age", "list", "str"), + method -> { + assertEquals("This is the Hello /endpoint", method.getSummary()); + assertEquals("Operation description", method.getDescription()); + assertEquals("Person name.", method.getParameterDoc("name")); + 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()); + }); + + withMethod( + doc, + "search", + List.of("query"), + method -> { + assertEquals("Search database.", method.getSummary()); + assertEquals("Search DB", method.getDescription()); + assertNull(method.getParameterDoc("fq")); + assertNull(method.getParameterDoc("offset")); + assertNull(method.getParameterDoc("limit")); + assertNull(method.getReturnDoc()); + }); + + withMethod( + doc, + "recordBean", + List.of("query"), + method -> { + assertEquals("Record database.", method.getSummary()); + assertNull(method.getDescription()); + assertNull(method.getParameterDoc("id")); + assertNull(method.getParameterDoc("name")); + }); + + withMethod( + doc, + "enumParam", + List.of("query"), + method -> { + assertEquals("Enum database.", method.getSummary()); + assertEquals("Enum doc.", method.getParameterDoc("query")); + }); + }); + } + + @Test + public void ignoreStatementComment() throws Exception { + var result = newParser().parse("issues.i1580.Controller1580"); + assertTrue(result.isEmpty()); + } + + @Test + public void noDoc() throws Exception { + var result = newParser().parse("javadoc.input.NoDoc"); + assertTrue(result.isEmpty()); + } + + @Test + public void noClassDoc() throws Exception { + withDoc( + javadoc.input.NoClassDoc.class, + doc -> { + assertNull(doc.getSummary()); + assertNull(doc.getDescription()); + + withMethod( + doc, + "hello", + List.of("name"), + methodDoc -> { + assertEquals("Method Doc.", methodDoc.getSummary()); + assertNull(methodDoc.getDescription()); + }); + }); + } + + @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.getEnumDescription(doc.getSummary())); + }); + } + + @Test + public void shouldParseBean() throws Exception { + withDoc( + Book.class, + doc -> { + 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, + "getFq", + List.of(), + methodDoc -> { + 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(baseDir()); + } + + private Path baseDir() { + return Paths.get(System.getProperty("user.dir")).resolve("src").resolve("test").resolve("java"); + } + + private void withDoc(Class typeName, Consumer consumer) throws Exception { + try { + var result = newParser().parse(typeName.getName()); + assertFalse(result.isEmpty()); + consumer.accept(result.get()); + } catch (Throwable cause) { + 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()); + } + + 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..4cb088c810 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java @@ -0,0 +1,41 @@ +/* + * 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.ApiDoc; + +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(ApiDoc.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/input/ApiDoc.java b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java new file mode 100644 index 0000000000..101c04da71 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.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.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. + * + * @x-badges.name Beta + * @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 { + + /** + * This is the Hello /endpoint + * + *

Operation description + * + * @param name Person name. + * @param age Person age. Multi line doc. + * @param list This line has a break. + * @param str Some string. + * @return Welcome message 200. + * @throws NullPointerException One something is null. + */ + @NonNull @GET + public String hello( + @QueryParam List> name, + @QueryParam int age, + @QueryParam List list, + @QueryParam java.lang.String str) { + return "hello"; + } + + /** + * Search database. + * + *

Search DB + * + * @param query + * @return + */ + @GET + 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..06dc4c4385 --- /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; + +/** Enum summary. Enum desc. */ +public enum EnumDoc { + /** Foo doc. */ + Foo, + + /** Bar doc. */ + Bar +} 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/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"; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403.java b/modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java similarity index 55% rename from modules/jooby-openapi/src/test/java/issues/i2403/Controller2403.java rename to modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java index f722181443..acc547d244 100644 --- a/modules/jooby-openapi/src/test/java/issues/i2403/Controller2403.java +++ b/modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java @@ -3,16 +3,18 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package issues.i2403; +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; -public class Controller2403 { - @GET - @Path("me") - public String me(@QueryParam String user) { - return user; +@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..2d4d3c86d4 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java @@ -0,0 +1,56 @@ +/* + * 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 jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; + +/** Search options. */ +public class QueryBeanDoc { + public static final int DEFAULT_OFFSET = 0; + + /** The field comment. */ + private String fq; + + /** Offset, used for paging. */ + @Min(0) + // Something + int offset = DEFAULT_OFFSET; + + private int limit; + + // Odd position of annotations + @NotEmpty + /** + * Filter query. Works like internal filter. + * + * @return Filter query. Works like internal filter. + */ + @NonNull 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; + } +} 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..affb347ff6 --- /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. 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. +}