diff --git a/README.md b/README.md index 889d33d0..2c737522 100644 --- a/README.md +++ b/README.md @@ -527,10 +527,51 @@ summary: Top Page It can be configured in `build.sbt`. This setting allows you to set the `${controllerName}.${methodName}` to name the operationId. -``` +```sbt swaggerOperationIdNamingFully := true ``` +#### Need a schema description? + +Using [runtime-scaladoc-reader](https://github.com/takezoe/runtime-scaladoc-reader), a description can be generated from Scaladoc comments written in the case class. + +⚠️ Schema generation from documentation comments is very useful, but **should never be used** if the scope of scaladoc documentation is different from the scope of OpenAPI documentation. + +Add the required dependencies and Compiler Plugin to `build.sbt` and configure it for use. + +```sbt +embedScaladoc := true +addCompilerPlugin("com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3") +libraryDependencies += "com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3" +``` + +For example, a case class might be written as follows. + +```scala +/** + * @param name e.g. Sunday, Monday, TuesDay... + */ +case class DayOfWeek(name: String) +``` + +The generated JSON will look like this. + +```json +{ + "DayOfWeek": { + "properties": { + "name": { + "type": "string", + "description": "e.g. Sunday, Monday, TuesDay..." + } + }, + "required": [ + "name" + ] + } +} +``` + #### Is play java supported? you can generate models definition from java POJO by setting the flag: 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..2483c8b9 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( @@ -13,10 +21,11 @@ final case class DefinitionGenerator( mappings: CustomMappings = Nil, swaggerPlayJava: Boolean = false, _mapper: ObjectMapper = new ObjectMapper(), - namingStrategy: NamingStrategy = NamingStrategy.None + namingStrategy: NamingStrategy = NamingStrategy.None, + embedScaladoc: Boolean = false )(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 +40,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 +71,33 @@ final case class DefinitionGenerator( case m: MethodSymbol if m.isPrimaryConstructor ⇒ m }.toList.flatMap(_.paramLists).headOption.getOrElse(Nil) - fields.map { field ⇒ + val paramDescriptions = if (embedScaladoc) { + 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 + + (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 + } else { + Map.empty[String, String] + } + + 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 +108,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)) } } @@ -142,11 +199,13 @@ object DefinitionGenerator { def apply( domainNameSpace: String, customParameterTypeMappings: CustomMappings, - namingStrategy: NamingStrategy + namingStrategy: NamingStrategy, + embedScaladoc: Boolean )(implicit cl: ClassLoader): DefinitionGenerator = DefinitionGenerator( PrefixDomainModelQualifier(domainNameSpace), customParameterTypeMappings, - namingStrategy = namingStrategy + namingStrategy = namingStrategy, + embedScaladoc = embedScaladoc ) } 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..8c0dbc7d 100644 --- a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala +++ b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala @@ -38,13 +38,14 @@ object SwaggerSpecGenerator { ) } - def apply(swaggerV3: Boolean, operationIdFully: Boolean, domainNameSpaces: String*)(implicit + def apply(swaggerV3: Boolean, operationIdFully: Boolean, embedScaladoc: Boolean, domainNameSpaces: String*)(implicit cl: ClassLoader): SwaggerSpecGenerator = { SwaggerSpecGenerator( NamingStrategy.None, PrefixDomainModelQualifier(domainNameSpaces: _*), swaggerV3 = swaggerV3, - operationIdFully = operationIdFully + operationIdFully = operationIdFully, + embedScaladoc = embedScaladoc ) } def apply(outputTransformers: Seq[OutputTransformer], domainNameSpaces: String*)(implicit @@ -69,7 +70,8 @@ final case class SwaggerSpecGenerator( swaggerV3: Boolean = false, swaggerPlayJava: Boolean = false, apiVersion: Option[String] = None, - operationIdFully: Boolean = false + operationIdFully: Boolean = false, + embedScaladoc: Boolean = false )(implicit cl: ClassLoader) { import SwaggerSpecGenerator.{MissingBaseSpecException, baseSpecFileName, customMappingsFileName} @@ -177,39 +179,10 @@ final case class SwaggerSpecGenerator( paths: ListMap[String, JsObject], baseJson: JsObject = Json.obj() ): JsObject = { - implicit class JsValueUpdate(jsValue: JsValue) { - def update(target: String)(f: JsValue => JsObject): JsValue = jsValue.result match { - case JsDefined(obj: JsObject) => - JsObject(obj.update(target)(f)) - - case JsDefined(arr: JsArray) => - JsArray(arr.value.map(_.update(target)(f))) - - case JsDefined(js) => js - - case _ => JsNull - } - } - - implicit class JsObjectUpdate(jsObject: JsObject) { - def update(target: String)(f: JsValue => JsObject): collection.Seq[(String, JsValue)] = jsObject.fields.flatMap { - case (k, v) if k == target => f(v).fields - case (k, v) => Seq(k -> v.update(target)(f)) - } - } val refKey = "$ref" - val pathsJson = JsObject(paths.values.reduce((acc, p) ⇒ JsObject(acc.fields ++ p.fields)).update(refKey) { - case JsString(v) => - val pattern = "^([^#]+)(?:#(?:/[a-zA-Z])+)?$".r - v match { - case pattern(path) if PathValidator.isValid(path) => - readCfgFile[JsObject](path).getOrElse(JsObject(Seq(refKey -> JsString(v)))) - case _ => JsObject(Seq(refKey -> JsString(v))) - } - case v => JsObject(Seq(refKey -> v)) - }) + val pathsJson = paths.values.reduce((acc, p) ⇒ JsObject(acc.fields ++ p.fields)) val mainRefs = (pathsJson ++ baseJson) \\ refKey val customMappingRefs = for { @@ -231,7 +204,8 @@ final case class SwaggerSpecGenerator( modelQualifier = modelQualifier, mappings = customMappings, swaggerPlayJava = swaggerPlayJava, - namingStrategy = namingStrategy + namingStrategy = namingStrategy, + embedScaladoc = embedScaladoc ).allDefinitions(referredClasses) } @@ -280,7 +254,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 +305,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 + ) ) } @@ -510,11 +496,48 @@ final case class SwaggerSpecGenerator( case _ ⇒ Nil } - for { + val commentsJsonOpt = for { leadingSpace ← commentDocLines.headOption.flatMap("""^(\s*)""".r.findFirstIn) comment = commentDocLines.map(_.drop(leadingSpace.length)).mkString("\n") result ← tryParseJson(comment) orElse tryParseYaml(comment) } yield result + + commentsJsonOpt.map { commentsJson => + implicit class JsValueUpdate(jsValue: JsValue) { + def update(target: String)(f: JsValue => JsObject): JsValue = jsValue.result match { + case JsDefined(obj: JsObject) => + JsObject(obj.update(target)(f)) + + case JsDefined(arr: JsArray) => + JsArray(arr.value.map(_.update(target)(f))) + + case JsDefined(js) => js + + case _ => JsNull + } + } + + implicit class JsObjectUpdate(jsObject: JsObject) { + def update(target: String)(f: JsValue => JsObject): collection.Seq[(String, JsValue)] = + jsObject.fields.flatMap { + case (k, v) if k == target => f(v).fields + case (k, v) => Seq(k -> v.update(target)(f)) + } + } + + val refKey = "$ref" + + JsObject(commentsJson.update(refKey) { + case JsString(v) => + val pattern = "^([^#]+)(?:#(?:/[a-zA-Z])+)?$".r + v match { + case pattern(path) if PathValidator.isValid(path) => + readCfgFile[JsObject](path).getOrElse(JsObject(Seq(refKey -> JsString(v)))) + case _ => JsObject(Seq(refKey -> JsString(v))) + } + case v => JsObject(Seq(refKey -> v)) + }) + } } val paramsFromComment = jsonFromComment.flatMap(jc ⇒ (jc \ "parameters").asOpt[JsArray]).map(amendBodyParam) diff --git a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala index ad4c4fdf..bd4df510 100644 --- a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala +++ b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala @@ -9,12 +9,13 @@ import play.api.libs.json.{JsValue, Json} object SwaggerSpecRunner extends App { implicit def cl: ClassLoader = getClass.getClassLoader - val targetFile :: routesFile :: domainNameSpaceArgs :: outputTransformersArgs :: swaggerV3String :: apiVersion :: swaggerPrettyJson :: swaggerPlayJavaString :: namingStrategy :: operationIdNamingFullyString :: Nil = + val targetFile :: routesFile :: domainNameSpaceArgs :: outputTransformersArgs :: swaggerV3String :: apiVersion :: swaggerPrettyJson :: swaggerPlayJavaString :: namingStrategy :: operationIdNamingFullyString :: embedScaladocString :: Nil = args.toList private def fileArg = Paths.get(targetFile) private def swaggerJson = { val swaggerV3 = java.lang.Boolean.parseBoolean(swaggerV3String) val swaggerOperationIdNamingFully = java.lang.Boolean.parseBoolean(operationIdNamingFullyString) + val embedScaladoc = java.lang.Boolean.parseBoolean(embedScaladocString) val swaggerPlayJava = java.lang.Boolean.parseBoolean(swaggerPlayJavaString) val domainModelQualifier = PrefixDomainModelQualifier(domainNameSpaceArgs.split(","): _*) val transformersStrs: Seq[String] = if (outputTransformersArgs.isEmpty) Seq() else outputTransformersArgs.split(",") @@ -36,7 +37,8 @@ object SwaggerSpecRunner extends App { swaggerV3 = swaggerV3, swaggerPlayJava = swaggerPlayJava, apiVersion = Some(apiVersion), - operationIdFully = swaggerOperationIdNamingFully + operationIdFully = swaggerOperationIdNamingFully, + embedScaladoc = embedScaladoc ).generate(routesFile).get if (swaggerPrettyJson.toBoolean) Json.prettyPrint(swaggerSpec) diff --git a/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala b/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala index bceae979..75408216 100644 --- a/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala @@ -69,7 +69,12 @@ class DefinitionGeneratorSpec extends Specification { "generate properties" >> { - val result = DefinitionGenerator("com.iheart.playSwagger", Nil, NamingStrategy.None).definition[Foo].properties + val result = DefinitionGenerator( + "com.iheart.playSwagger", + Nil, + NamingStrategy.None, + embedScaladoc = false + ).definition[Foo].properties result.length === 7 @@ -118,7 +123,9 @@ class DefinitionGeneratorSpec extends Specification { "generate properties using snake case naming strategy" >> { val result = - DefinitionGenerator("com.iheart.playSwagger", Nil, NamingStrategy.SnakeCase).definition[Foo].properties + DefinitionGenerator("com.iheart.playSwagger", Nil, NamingStrategy.SnakeCase, embedScaladoc = false).definition[ + Foo + ].properties result.length === 7 @@ -167,7 +174,9 @@ class DefinitionGeneratorSpec extends Specification { "generate properties using kebab case naming strategy" >> { val result = - DefinitionGenerator("com.iheart.playSwagger", Nil, NamingStrategy.KebabCase).definition[Foo].properties + DefinitionGenerator("com.iheart.playSwagger", Nil, NamingStrategy.KebabCase, embedScaladoc = false).definition[ + Foo + ].properties result.length === 7 @@ -214,14 +223,14 @@ class DefinitionGeneratorSpec extends Specification { } "read class in Object" >> { - val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None).definition( + val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( "com.iheart.playSwagger.MyObject.MyInnerClass" ) result.properties.head.name === "bar" } "read alias type in Object" >> { - val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None).definition( + val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( "com.iheart.playSwagger.MyObject.MyInnerClass" ) @@ -233,14 +242,16 @@ class DefinitionGeneratorSpec extends Specification { "read sequence items" >> { val result = - DefinitionGenerator("com.iheart", Nil, NamingStrategy.None).definition("com.iheart.playSwagger.FooWithSeq") + DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + "com.iheart.playSwagger.FooWithSeq" + ) result.properties.head.asInstanceOf[GenSwaggerParameter].items.get.asInstanceOf[ GenSwaggerParameter ].referenceType === Some("com.iheart.playSwagger.SeqItem") } "read primitive sequence items" >> { - val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None).definition( + val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( "com.iheart.playSwagger.WithListOfPrimitive" ) result.properties.head.asInstanceOf[GenSwaggerParameter].items.get.asInstanceOf[ @@ -251,7 +262,9 @@ class DefinitionGeneratorSpec extends Specification { "read Optional items " >> { val result = - DefinitionGenerator("com.iheart", Nil, NamingStrategy.None).definition("com.iheart.playSwagger.FooWithOption") + DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + "com.iheart.playSwagger.FooWithOption" + ) result.properties.head.asInstanceOf[GenSwaggerParameter].referenceType must beSome( "com.iheart.playSwagger.OptionItem" ) @@ -260,7 +273,9 @@ class DefinitionGeneratorSpec extends Specification { "with dates" >> { "no override" >> { val result = - DefinitionGenerator("com.iheart", Nil, NamingStrategy.None).definition("com.iheart.playSwagger.WithDate") + DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + "com.iheart.playSwagger.WithDate" + ) val prop = result.properties.head.asInstanceOf[GenSwaggerParameter] prop.`type` must beSome("integer") prop.format must beSome("epoch") @@ -275,7 +290,9 @@ class DefinitionGeneratorSpec extends Specification { ) ) val result = - DefinitionGenerator("com.iheart", mappings, NamingStrategy.None).definition("com.iheart.playSwagger.WithDate") + DefinitionGenerator("com.iheart", mappings, NamingStrategy.None, embedScaladoc = false).definition( + "com.iheart.playSwagger.WithDate" + ) val prop = result.properties.head.asInstanceOf[CustomSwaggerParameter] prop.specAsParameter === customJson } @@ -289,7 +306,7 @@ class DefinitionGeneratorSpec extends Specification { specAsParameter = customJson ) ) - val result = DefinitionGenerator("com.iheart", mappings, NamingStrategy.None).definition( + val result = DefinitionGenerator("com.iheart", mappings, NamingStrategy.None, embedScaladoc = false).definition( "com.iheart.playSwagger.WithOptionalDate" ) val prop = result.properties.head.asInstanceOf[CustomSwaggerParameter] @@ -304,7 +321,7 @@ class DefinitionGeneratorSpec extends Specification { `type` = "com.iheart.playSwagger.WrappedString", specAsParameter = customJson ) - val generator = DefinitionGenerator("com.iheart", List(customMapping), NamingStrategy.None) + val generator = DefinitionGenerator("com.iheart", List(customMapping), NamingStrategy.None, embedScaladoc = false) val definition = generator.definition[FooWithWrappedStringProperties] "support simple property types" >> { diff --git a/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala b/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala index 38d4c265..22d8c65f 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) @@ -113,7 +116,7 @@ class SwaggerSpecGeneratorIntegrationSpec extends Specification { (json \ "paths" \ "/player/{pid}/context/{bid}").asOpt[JsObject] must beSome } - lazy val json = SwaggerSpecGenerator(false, false, "com.iheart").generate("test.routes").get + lazy val json = SwaggerSpecGenerator(false, false, false, "com.iheart").generate("test.routes").get lazy val pathJson = json \ "paths" lazy val definitionsJson = json \ "definitions" lazy val postBodyJson = (pathJson \ "/post-body" \ "post").as[JsObject] @@ -509,6 +512,19 @@ class SwaggerSpecGeneratorIntegrationSpec extends Specification { (dayOfWeekJson.get \ "properties" \ "name" \ "type").as[String] === "string" } + "embedded scaladoc strings" >> { + lazy val json = SwaggerSpecGenerator(false, false, embedScaladoc = true, "com.iheart").generate("test.routes").get + lazy val definitionsJson = json \ "definitions" + lazy val dayOfWeekJson = (definitionsJson \ "com.iheart.playSwagger.DayOfWeek").asOpt[JsObject] + dayOfWeekJson must beSome[JsObject] + (dayOfWeekJson.get \ "properties" \ "name" \ "description").as[String] === "e.g. Sunday, Monday, TuesDay..." + } + + "don't embedded scaladoc strings" >> { + dayOfWeekJson must beSome[JsObject] + (dayOfWeekJson.get \ "properties" \ "name" \ "description").asOpt[String] === None + } + "parse mixin referenced external file" >> { lazy val subjectJson = (pathJson \ "/api/subjects/dow/{subject}" \ "get").as[JsObject] @@ -676,7 +692,7 @@ class SwaggerSpecGeneratorIntegrationSpec extends Specification { } "fully operation id" >> { - lazy val json = SwaggerSpecGenerator(false, true, "com.iheart").generate("test.routes").get + lazy val json = SwaggerSpecGenerator(false, true, false, "com.iheart").generate("test.routes").get lazy val addTrackJson = (json \ "paths" \ "/api/station/playedTracks" \ "post").as[JsObject] (addTrackJson \ "operationId").as[String] ==== "LiveMeta.addPlayedTracks" } @@ -711,7 +727,7 @@ class SwaggerSpecGeneratorIntegrationSpec extends Specification { } "integration v3" >> { - lazy val json = SwaggerSpecGenerator(true, false, "com.iheart").generate("testV3.routes").get + lazy val json = SwaggerSpecGenerator(true, false, false, "com.iheart").generate("testV3.routes").get lazy val componentSchemasJson = json \ "components" \ "schemas" lazy val trackJson = (componentSchemasJson \ "com.iheart.playSwagger.Track").as[JsObject] diff --git a/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerKeys.scala b/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerKeys.scala index 64a9d9d0..820944d8 100644 --- a/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerKeys.scala +++ b/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerKeys.scala @@ -36,4 +36,10 @@ trait SwaggerKeys { "swaggerOperationIdNaming", "Either use the operationId of the generated json as the method name" ) + + val embedScaladoc: SettingKey[Boolean] = + SettingKey[Boolean]( + "embedScaladoc", + "Output schema description using scaladoc of case class" + ) } diff --git a/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala b/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala index 71ac000d..41b52ccd 100755 --- a/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala +++ b/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala @@ -37,6 +37,7 @@ object SwaggerPlugin extends AutoPlugin { swaggerPlayJava := false, swaggerNamingStrategy := "none", swaggerOperationIdNamingFully := false, + embedScaladoc := false, swagger := Def.task[File] { (swaggerTarget.value).mkdirs() val file = swaggerTarget.value / swaggerFileName.value @@ -48,8 +49,9 @@ object SwaggerPlugin extends AutoPlugin { swaggerAPIVersion.value :: swaggerPrettyJson.value.toString :: swaggerPlayJava.value.toString :: - swaggerNamingStrategy.value.toString :: + swaggerNamingStrategy.value :: swaggerOperationIdNamingFully.value.toString :: + embedScaladoc.value.toString :: Nil val swaggerClasspath = data((fullClasspath in Runtime).value) ++ update.value.select(configurationFilter(SwaggerConfig.name)) diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/app/namespace1/Artist.scala b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/app/namespace1/Artist.scala new file mode 100644 index 00000000..dd283bd7 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/app/namespace1/Artist.scala @@ -0,0 +1,10 @@ +package namespace1 + +/** + * @param name Fully Name + * @param age expressed in the Western style of counting fully completed years + */ +case class Artist( + name: String, + age: Int, + birthdate: java.time.LocalDate) diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/app/namespace2/Track.scala b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/app/namespace2/Track.scala new file mode 100644 index 00000000..56bf6921 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/app/namespace2/Track.scala @@ -0,0 +1,14 @@ +package namespace2 + +import namespace1.Artist + +/** + * @param name This Track Name + */ +case class Track( + name: String, + genre: Option[String], + artist: Artist, + related: Seq[Artist], + numbers: Seq[Int], + length: java.time.Duration) diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/build.sbt b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/build.sbt new file mode 100755 index 00000000..4a8e2fea --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/build.sbt @@ -0,0 +1,190 @@ +import spray.json._ +import DefaultJsonProtocol._ + +logLevel in update := sbt.Level.Warn + +enablePlugins(PlayScala, SwaggerPlugin) + +name := "app" + +version := "1.0.1-BETA1" + +scalaVersion := "2.12.16" + +swaggerDomainNameSpaces := Seq("namespace1", "namespace2") + +swaggerRoutesFile := "my-routes" + +swaggerOutputTransformers := Seq(envOutputTransformer) + +swaggerPlayJava := false + +embedScaladoc := true + +addCompilerPlugin("com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3") +libraryDependencies += "com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3" + +val pathVal = System.getenv("PATH") + +TaskKey[Unit]("check") := { + + def uniform(jsString: String): String = jsString.parseJson.prettyPrint + + val expected = uniform( + s""" + |{ + | "paths":{ + | "/tracks/{trackId}":{ + | "get":{ + | "operationId":"versioned", + | "tags":[ + | "${swaggerRoutesFile.value}" + | ], + | "summary":"Get the track metadata", + | "responses":{ + | "200":{ + | "summary": "${pathVal.replace("\\", "\\\\")}", + | "schema":{ + | "$$ref":"#/definitions/namespace2.Track" + | } + | } + | }, + | "parameters":[ + | { + | "in":"path", + | "name":"trackId", + | "type":"asset", + | "required":true + | } + | ] + | } + | } + | }, + | "definitions":{ + | "namespace1.Artist":{ + | "properties":{ + | "name":{ + | "type":"string", + | "description":"Fully Name" + | }, + | "age":{ + | "type":"integer", + | "format":"int32", + | "description":"expressed in the Western style of counting fully completed years" + | }, + | "birthdate":{ + | "type":"string", + | "format":"date" + | } + | }, + | "required":[ + | "name", + | "age", + | "birthdate" + | ] + | }, + | "namespace2.Track":{ + | "properties":{ + | "name":{ + | "type":"string", + | "description": "This Track Name" + | }, + | "genre":{ + | "type":"string", + | "x-nullable": true + | }, + | "artist":{ + | "$$ref":"#/definitions/namespace1.Artist" + | }, + | "related":{ + | "type":"array", + | "items":{ + | "$$ref":"#/definitions/namespace1.Artist" + | } + | }, + | "numbers":{ + | "type":"array", + | "items":{ + | "type":"integer", + | "format":"int32" + | } + | }, + | "length":{ + | "type":"integer" + | } + | }, + | "required":[ + | "name", + | "artist", + | "related", + | "numbers", + | "length" + | ] + | } + | }, + | "swagger":"2.0", + | "info":{ + | "version":"1.0.1-BETA1", + | "title":"Poweramp API", + | "description":"My API is the best" + | }, + | "tags":[ + | { + | "name":"${swaggerRoutesFile.value}" + | } + | ] + |} + """.stripMargin + ) + + val result = uniform(IO.read(swaggerTarget.value / swaggerFileName.value)) + + if (result != expected) { + val rs = result.split('\n') + val ep = expected.split('\n') + val compare = rs.zip(ep).map { + case (resultLine, expectedLine) => + if (resultLine != expectedLine) + "DIFF >>>>>>>>>>>\n" + + s"Result > $resultLine\n" + + s"Expect < $expectedLine" + else + s"Result > $resultLine" + }.mkString("\n") + + val left = ep.takeRight(ep.size - rs.size).mkString("\n") + + sys.error( + s"""Swagger.json is off. + $compare + + >>>>> extra expected lines: + $left + """.stripMargin + ) + } +} + +TaskKey[Unit]("unzip1") := { + val from = new File(s"target/scala-2.12/app_2.12-${version.value}.jar") + val to = new File("target/jar") + IO.unzip(from, to) +} + +TaskKey[Unit]("unzip2") := { + val from = new File(s"target/universal/app-${version.value}.zip") + val to = new File("target/dist") + IO.unzip(from, to) +} + +TaskKey[Unit]("unzip3") := { + val from = new File(s"target/dist/app-${version.value}/lib/app.app-${version.value}-sans-externalized.jar") + val to = new File("target/dist/jar") + IO.unzip(from, to) +} + +TaskKey[Unit]("unzip4") := { + val from = new File(s"target/universal/stage/lib/app.app-${version.value}-sans-externalized.jar") + val to = new File("target/jar") + IO.unzip(from, to) +} diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/my-routes b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/my-routes new file mode 100644 index 00000000..0be65855 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/my-routes @@ -0,0 +1,10 @@ + +### +# summary: Get the track metadata +# responses: +# 200: +# summary: ${PATH} +# schema: +# $ref: '#/definitions/namespace2.Track' +### +GET /tracks/:trackId controllers.Assets.versioned(path="/public/assets", trackId: Asset) diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/swagger-custom-mappings.yml b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/swagger-custom-mappings.yml new file mode 100755 index 00000000..5fe96ef2 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/swagger-custom-mappings.yml @@ -0,0 +1,8 @@ +--- + - type: java\.time\.LocalDate + specAsParameter: + - type: string + format: date + - type: java\.time\.Duration + specAsParameter: + - type: integer diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/swagger.yml b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/swagger.yml new file mode 100644 index 00000000..4ad40889 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/conf/swagger.yml @@ -0,0 +1,5 @@ +--- + swagger: "2.0" + info: + title: "Poweramp API" + description: "My API is the best" diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/project/build.properties b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/project/build.properties new file mode 100755 index 00000000..0837f7a1 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.3.13 diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/project/plugins.sbt b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/project/plugins.sbt new file mode 100644 index 00000000..945bc394 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/project/plugins.sbt @@ -0,0 +1,14 @@ +logLevel in update := sbt.Level.Warn + +addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.3.2") +addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.8.16") + +{ + val pluginVersion = System.getProperty("plugin.version") + if (pluginVersion == null) + throw new RuntimeException("""|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) + else addSbtPlugin("com.iheart" %% "sbt-play-swagger" % pluginVersion) +} + +libraryDependencies += "io.spray" %% "spray-json" % "1.3.3" diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/test b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/test new file mode 100644 index 00000000..fe088703 --- /dev/null +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs-from-scala-doc/test @@ -0,0 +1,25 @@ +> swagger + +> check +> clean +> dist + + +$ absent target/jar/public/swagger.json +> unzip1 +$ exists target/jar/public/swagger.json + + +$ absent target/dist/jar/public/swagger.json +> unzip2 +> unzip3 +$ exists target/dist/jar/public/swagger.json + + +> clean +> stage + +$ absent target/jar/public/swagger.json +> unzip4 +$ exists target/jar/public/swagger.json +