From 8ece38a8b0b6d3ca3255338c64e16607dd8f7560 Mon Sep 17 00:00:00 2001 From: javakky Date: Mon, 5 Sep 2022 15:25:44 +0900 Subject: [PATCH] feat: Generate Description from Scaladoc --- build.sbt | 7 ++- .../playSwagger/DefinitionGenerator.scala | 58 ++++++++++++++++++- .../scala/com/iheart/playSwagger/Domain.scala | 7 ++- .../playSwagger/SwaggerParameterMapper.scala | 8 ++- .../playSwagger/SwaggerSpecGenerator.scala | 18 +++++- .../SwaggerSpecGeneratorSpec.scala | 8 +++ 6 files changed, 94 insertions(+), 12 deletions(-) diff --git a/build.sbt b/build.sbt index 82855b44..4fb3299c 100644 --- a/build.sbt +++ b/build.sbt @@ -35,7 +35,12 @@ lazy val playSwagger = project.in(file("core")) Dependencies.enumeratum ++ Dependencies.refined ++ Dependencies.test ++ - Dependencies.yaml, + Dependencies.yaml ++ Seq( + "com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3", + "org.scalameta" %% "scalameta" % "4.5.13", + "net.steppschuh.markdowngenerator" % "markdowngenerator" % "1.3.1.1" + ), + addCompilerPlugin("com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3"), scalaVersion := scalaV, crossScalaVersions := Seq(scalaVersion.value, "2.13.8"), scalacOptions ++= (CrossVersion.partialVersion(scalaVersion.value) match { diff --git a/core/src/main/scala/com/iheart/playSwagger/DefinitionGenerator.scala b/core/src/main/scala/com/iheart/playSwagger/DefinitionGenerator.scala index 4d03c513..d5a8d335 100644 --- a/core/src/main/scala/com/iheart/playSwagger/DefinitionGenerator.scala +++ b/core/src/main/scala/com/iheart/playSwagger/DefinitionGenerator.scala @@ -1,11 +1,19 @@ package com.iheart.playSwagger import scala.collection.JavaConverters +import scala.meta.internal.parsers.ScaladocParser +import scala.meta.internal.{Scaladoc => iScaladoc} import scala.reflect.runtime.universe._ import com.fasterxml.jackson.databind.{BeanDescription, ObjectMapper} +import com.github.takezoe.scaladoc.Scaladoc import com.iheart.playSwagger.Domain.{CustomMappings, Definition, GenSwaggerParameter, SwaggerParameter} import com.iheart.playSwagger.SwaggerParameterMapper.mapParam +import net.steppschuh.markdowngenerator.MarkdownElement +import net.steppschuh.markdowngenerator.table.Table +import net.steppschuh.markdowngenerator.text.Text +import net.steppschuh.markdowngenerator.text.code.CodeBlock +import net.steppschuh.markdowngenerator.text.heading.Heading import play.routes.compiler.Parameter final case class DefinitionGenerator( @@ -16,7 +24,7 @@ final case class DefinitionGenerator( namingStrategy: NamingStrategy = NamingStrategy.None )(implicit cl: ClassLoader) { - private val refinedTypePattern = raw"(eu\.timepit\.refined\.api\.Refined(?:\[.+\])?)".r + private val refinedTypePattern = raw"(eu\.timepit\.refined\.api\.Refined(?:\[.+])?)".r def dealiasParams(t: Type): Type = { t.toString match { @@ -31,6 +39,28 @@ final case class DefinitionGenerator( } } + private def scalaDocToMarkdown: PartialFunction[iScaladoc.Term, MarkdownElement] = { + case value: iScaladoc.Text => + new Text(value.parts.map { + case word: iScaladoc.Word => word.value + case link: iScaladoc.Link => s"[${link.anchor.mkString(" ")}](${link.ref})}" + case code: iScaladoc.CodeExpr => s"`${code.code}`" + }.mkString(" ")) + case code: iScaladoc.CodeBlock => new CodeBlock(code, "scala") + case code: iScaladoc.MdCodeBlock => + new CodeBlock(code.code.mkString("\n"), code.info.mkString(":")) + case head: iScaladoc.Heading => new Heading(head, 1) + case table: iScaladoc.Table => + val builder = new Table.Builder().withAlignments(Table.ALIGN_RIGHT, Table.ALIGN_LEFT).addRow( + table.header.cols: _* + ) + table.rows.foreach(row => builder.addRow(row.cols: _*)) + builder.build() + // TODO: Support List + // https://github.com/Steppschuh/Java-Markdown-Generator/pull/13 + case _ => new Text("") + } + def definition: ParametricType ⇒ Definition = { case parametricType @ ParametricType(tpe, reifiedTypeName, _, _) ⇒ val properties = if (swaggerPlayJava) { @@ -40,7 +70,29 @@ final case class DefinitionGenerator( case m: MethodSymbol if m.isPrimaryConstructor ⇒ m }.toList.flatMap(_.paramLists).headOption.getOrElse(Nil) - fields.map { field ⇒ + val scaladoc = for { + annotation <- tpe.typeSymbol.annotations + if typeOf[Scaladoc] == annotation.tree.tpe + value <- annotation.tree.children.tail.headOption + docTree <- value.children.tail.headOption + docString = docTree.toString().tail.init.replace("\\n", "\n") + doc <- ScaladocParser.parse(docString) + } yield doc + + val paramDescriptions = (for { + doc <- scaladoc + paragraph <- doc.para + term <- paragraph.terms + tag <- term match { + case iScaladoc.Tag(iScaladoc.TagType.Param, Some(iScaladoc.Word(key)), Seq(text)) => + Some(key -> text) + case _ => None + } + } yield tag).map { + case (name, term) => name -> scalaDocToMarkdown(term).toString + }.toMap + + fields.map { field: Symbol ⇒ // TODO: find a better way to get the string representation of typeSignature val name = namingStrategy(field.name.decodedName.toString) @@ -51,7 +103,7 @@ final case class DefinitionGenerator( val typeName = parametricType.resolve(rawTypeName) // passing None for 'fixed' and 'default' here, since we're not dealing with route parameters val param = Parameter(name, typeName, None, None) - mapParam(param, modelQualifier, mappings) + mapParam(param, modelQualifier, mappings, paramDescriptions.get(field.name.decodedName.toString)) } } diff --git a/core/src/main/scala/com/iheart/playSwagger/Domain.scala b/core/src/main/scala/com/iheart/playSwagger/Domain.scala index 2c87fb83..958a2f98 100644 --- a/core/src/main/scala/com/iheart/playSwagger/Domain.scala +++ b/core/src/main/scala/com/iheart/playSwagger/Domain.scala @@ -17,6 +17,7 @@ object Domain { def required: Boolean def nullable: Option[Boolean] def default: Option[JsValue] + def description: Option[String] def update(required: Boolean, nullable: Boolean, default: Option[JsValue]): SwaggerParameter } @@ -31,7 +32,8 @@ object Domain { default: Option[JsValue] = None, example: Option[JsValue] = None, items: Option[SwaggerParameter] = None, - enum: Option[Seq[String]] = None + enum: Option[Seq[String]] = None, + description: Option[String] = None ) extends SwaggerParameter { def update(_required: Boolean, _nullable: Boolean, _default: Option[JsValue]): GenSwaggerParameter = copy(required = _required, nullable = Some(_nullable), default = _default) @@ -43,7 +45,8 @@ object Domain { specAsProperty: Option[JsObject], required: Boolean = true, nullable: Option[Boolean] = None, - default: Option[JsValue] = None + default: Option[JsValue] = None, + description: Option[String] = None ) extends SwaggerParameter { def update(_required: Boolean, _nullable: Boolean, _default: Option[JsValue]): CustomSwaggerParameter = copy(required = _required, nullable = Some(_nullable), default = _default) diff --git a/core/src/main/scala/com/iheart/playSwagger/SwaggerParameterMapper.scala b/core/src/main/scala/com/iheart/playSwagger/SwaggerParameterMapper.scala index 7554244d..e1457cff 100644 --- a/core/src/main/scala/com/iheart/playSwagger/SwaggerParameterMapper.scala +++ b/core/src/main/scala/com/iheart/playSwagger/SwaggerParameterMapper.scala @@ -15,7 +15,8 @@ object SwaggerParameterMapper { def mapParam( parameter: Parameter, modelQualifier: DomainModelQualifier = PrefixDomainModelQualifier(), - customMappings: CustomMappings = Nil + customMappings: CustomMappings = Nil, + description: Option[String] = None )(implicit cl: ClassLoader): SwaggerParameter = { def removeKnownPrefixes(name: String) = @@ -57,14 +58,15 @@ object SwaggerParameterMapper { tp: String, format: Option[String] = None, enum: Option[Seq[String]] = None - ) = + ): GenSwaggerParameter = GenSwaggerParameter( parameter.name, `type` = Some(tp), format = format, required = defaultValueO.isEmpty, default = defaultValueO, - enum = enum + enum = enum, + description = description ) val enumParamMF: MappingFunction = { diff --git a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala index 119d3002..0f145fcd 100644 --- a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala +++ b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala @@ -280,7 +280,8 @@ final case class SwaggerSpecGenerator( (under \ 'default).writeNullable[JsValue] ~ (under \ 'example).writeNullable[JsValue] ~ (under \ "items").writeNullable[SwaggerParameter](propWrites) ~ - (under \ "enum").writeNullable[Seq[String]] + (under \ "enum").writeNullable[Seq[String]] ~ + (__ \ "description").writeNullable[String] )(unlift(GenSwaggerParameter.unapply)) } @@ -330,9 +331,20 @@ final case class SwaggerSpecGenerator( (__ \ 'example).writeNullable[JsValue] ~ (__ \ "$ref").writeNullable[String] ~ (__ \ "items").lazyWriteNullable[SwaggerParameter](propWrites) ~ - (__ \ "enum").writeNullable[Seq[String]] + (__ \ "enum").writeNullable[Seq[String]] ~ + (__ \ "description").writeNullable[String] )(p ⇒ - (p.`type`, p.format, p.nullable, p.default, p.example, p.referenceType.map(referencePrefix + _), p.items, p.enum) + ( + p.`type`, + p.format, + p.nullable, + p.default, + p.example, + p.referenceType.map(referencePrefix + _), + p.items, + p.enum, + p.description + ) ) } diff --git a/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala b/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala index 38d4c265..8ee126f9 100644 --- a/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala @@ -19,6 +19,9 @@ case class Keeper(internalFieldName1: String, internalFieldName2: Int) case class Subject(name: String) +/** + * @param name e.g. Sunday, Monday, TuesDay... + */ case class DayOfWeek(name: String) case class PolymorphicContainer(item: PolymorphicItem) @@ -509,6 +512,11 @@ class SwaggerSpecGeneratorIntegrationSpec extends Specification { (dayOfWeekJson.get \ "properties" \ "name" \ "type").as[String] === "string" } + "embedded scaladoc strings" >> { + dayOfWeekJson must beSome[JsObject] + (dayOfWeekJson.get \ "properties" \ "name" \ "description").as[String] === "e.g. Sunday, Monday, TuesDay..." + } + "parse mixin referenced external file" >> { lazy val subjectJson = (pathJson \ "/api/subjects/dow/{subject}" \ "get").as[JsObject]