diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb4c4d35..466a6813 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,9 +15,12 @@ jobs: distribution: temurin java-version: 11 cache: sbt - - run: sbt ci-release + - run: sbt clean ci-release env: PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} PGP_SECRET: ${{ secrets.PGP_SECRET }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + - name: "view git diff" + run: git diff + if: ${{ always() }} diff --git a/.scalafix.conf b/.scalafix.conf index 9c003c7e..1e48b19b 100644 --- a/.scalafix.conf +++ b/.scalafix.conf @@ -1,15 +1,32 @@ rules = [ + NamedParamOrder + JavaURLConstructorsWarn + OptionMapFlatMap + RemoveStringInterpolation + OptionMatchToRight + ReplaceFill + UnnecessaryCase + UnnecessaryMatch + ScalatestAssertThrowsToIntercept + MockitoThenToDo RemoveUnused NoAutoTupling NoValInForComprehension ProcedureSyntax - ExplicitResultTypes + fix.scala213.FinalObject + fix.scala213.Any2StringAdd + fix.scala213.Varargs + fix.scala213.ExplicitNullaryEtaExpansion OrganizeImports UnnecessarySemicolon ZeroIndexToHead CheckIsEmpty NonCaseException SingleConditionMatch + UnifyEmptyList + NamingConventionPackage + NeedMessageExtendsRuntimeException + MapToForeach ] RemoveUnused { diff --git a/.scalafmt.conf b/.scalafmt.conf index ebfe124b..d5158145 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = "3.7.10" +version = "3.7.17" style = default maxColumn = 120 align.openParenCallSite = false diff --git a/README.md b/README.md index b34cf359..f479934b 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,558 @@ +[![Scala CI](https://github.com/play-swagger/play-swagger/actions/workflows/scala.yml/badge.svg)](https://github.com/iheartradio/play-swagger/actions/workflows/scala.yml) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/play-swagger/sbt-play-swagger/badge.svg)](https://maven-badges.herokuapp.com/maven-central/play-swagger/sbt-play-swagger) + # Swagger API spec generator for Play -A library that generates swagger specs from route files and case class reflection, no code annotation needed. -**This repository is no longer maintained. Please find the new repository here: https://github.com/play-swagger/play-swagger** +A library that generates swagger specs from route files and case class reflection, no code annotation needed. + +## Principles in this lib + +1. No code pollution (e.g. annotation) +2. DRY (extract as much information from the code as possible) +3. When documenting an endpoint, it should be just swagger specification that you need to write. You shall not need to learn another API or spec format. + +Which translates to + +1. Write your [swagger specification](http://swagger.io/specification/) in your routes files as comments (json or yml) +2. Reference your case classes in your swagger spec and play-swagger will generate definitions +3. Override anything in either the swagger spec in comment or the base swagger spec file (swagger.yml or swagger.json in your conf) + +============================ +## Day-to-day usage + +For installation/get-started see the next section. + +#### A simple example + +In a `cards.routes` which is referenced in `routes` as +``` +-> /api/cards cards.Routes +``` +You can write the following swagger spec in comment (This example is in yml, but json is also supported). The comment has to start and end with `###`. + +If you don't write any comment here the endpoint is still going to be picked up by play-swagger, the parameters will be included but there will not be any response format. +This allows newly added endpoints to be automatically included in swagger with some basic information. +``` + ### + # summary: create a card + # tags: + # - Card Endpoints + # responses: + # 200: + # description: success + # schema: + # $ref: '#/definitions/com.iheart.api.Protocol.CardCreated' + ### + POST /users/:profileId/contexts/:contextName/cards controllers.api.Cards.createCard(profileId: Int, contextName: Option[String]) + +``` + +Note that everything in the comment is just standard swagger definition, and it $refs to a case class CardCreated, which is defined in a Protocol object, and it references another case class Card. Here is the source code: +```scala +package com.iheart.api + +object Protocol { + case class CardCreated(card: Card) + + case class Card(id: Int, name: String) +} +``` + +This will generate the path with summary, tags, parameters and a response with schema defined, which comes from the comments and case class reflection. +It also recursively generates definitions from your case class. +These schemas assumes that you are using a simple `Json.format[CardCreated]` to generate the json response out of this class. +If not, you will have to write the definition yourself in the base swagger spec and reference it here at the endpoint +(give it a different name than the full package name though, play-swagger will try to generate definitions for any $ref that starts with the domain package name). + +The result swagger specs will look like: + ![](http://amp-public-share.s3-website-us-east-1.amazonaws.com/shifu/play-swagger-sample.png) + + +============================ +## Get Started + +In short you need to add sbt-play-swagger plugin which generates swagger.json on package time, +then you just need to have a swagger UI instance to consumer that swagger spec. +You can find the setup in the example project as well. + + +#### Step 1 + +1. For play 2.8, Scala 2.13.x and Scala 2.12.x please use +```scala +addSbtPlugin("io.github.play-swagger" % "sbt-play-swagger" % "1.6.1") +``` + +2. For play 2.9, Scala 2.13.x please use (Code is in branch 1.7.0) +```scala +addSbtPlugin("io.github.play-swagger" % "sbt-play-swagger" % "1.7.0") +``` + +3. For play 3.0, Scala 2.13.x please use (Code is in branch 2.0.0 and scala 3 is not supported) +```scala +addSbtPlugin("io.github.play-swagger" % "sbt-play-swagger" % "2.0.0") +``` + +Then enable it for your Play app - in build.sbt add `SwaggerPlugin` to the root project like +```Scala +lazy val root = (project in file(".")).enablePlugins(PlayScala, SwaggerPlugin) //enable plugin +``` + +Also in build.sbt add domain package names for play-swagger to auto generate swagger definitions for domain classes mentioned in your routes +```Scala +swaggerDomainNameSpaces := Seq("models") +``` + +To be more specific, If you want to use the case class defined in the package `something.models` in swagger (accessed via `#ref:definitions/`), add the following in sbt. +```Scala +swaggerDomainNameSpaces := Seq("something.models") +``` + +Additionally, if you want to use other packages (e.g. `other.models`), you can do so like this. +```Scala +swaggerDomainNameSpaces := Seq("something.models","other.models") +``` + +This plugin adds a sbt task `swagger`, with which you can generate the `swagger.json` for testing purpose. + +This plugin will generate the `swagger.json`and make it available under path `assets/swagger.json` on `sbt package` and `sbt run`. + +Alternatively, you can create a controller that uses play-swagger lib to generate the json and serve it, this way you can manipulate the swagger.json at runtime. See [here](docs/AlternativeSetup.md) for details. + + +#### Step 2 +Add a base `swagger.yml` (or `swagger.json`) to your `resources` folder (for example, conf folder in the play application). This file needs to provide all the required fields according to swagger spec. + +E.g. +```yml +--- + swagger: "2.0" + info: + title: "Poweramp API" + description: "Power your music" + consumes: + - application/json + produces: + - application/json + +``` +Note that `info.version` is intentionally left out, playSwagger will automatically fillin the build version of the project. However if the version is set here it will be honored. You can also dynamically generate the version string in build file using the `swaggerAPIVersion` setting. + +#### Step 3a +Deploy a swagger ui and point to the swagger spec end point at 'assets/swagger.json', or + +#### Step 3b +Alternatively you can use swagger-ui webjar and have you play app serving the swagger ui: + +The query parameter `url` is disabled in 4.1.3 and later versions. ([GHSA-qrmm-w75w-3wpx](https://github.com/swagger-api/swagger-ui/security/advisories/GHSA-qrmm-w75w-3wpx)) +```scala +libraryDependencies += "org.webjars" % "swagger-ui" % "5.10.3" +``` + +Copy the `index.html` and `swagger-initializer.js` generated in `target/${project}/public/lib/main/swagger-ui/` and modify the js files as follows to create Swagger-UI can be used easily. +```js +window.onload = function() { + window.ui = SwaggerUIBundle({ + // edit url + url: "/assets/swagger.json", + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout" + }); +}; +``` + +For more information: [installation.md](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/installation.md) + +The sbt-play-swagger plugin will generate the swagger.json on `sbt run` or `sbt package`. + +============================ +## How to contribute + +If you have any questions/bug reports, please submit an issue on github. +With good unit tests coverage, it's pretty easy to add/modify this library as well. +Code contribution are more than welcome. Make sure that your code is tested and submit the pull request! + + +=========================== +## FAQ + +#### How to override? +To override any of the automatically generated field, you just need to write the same part in your comment or base swagger spec file. + +#### How to override type mappings? +To override the type mapping used for any type, create a swagger-custom-mappings.yml or swagger-custom-mappings.json in conf and add +an array of mappings. Each mapping consists of + +1. `type` a regex matching the type for which the custom specs is +1. `specAsParameter` a list of objects to be used when this type is used by a route path parameter or query string parameter. Being a list of json object allows you to expand a single parameter into multiple ones, but in other cases you just need to provide one json object. If you leave this one an empty array, the parameter with this type will be hidden. You must provide a list here, though. +1. `specAsProperty`, a json object to be used when the type is used as a property in a definition. If you leave this one empty, play-swagger will try to use the first element in `specAsParameter`, please note that although most of the fields are common between the two types of spec, a couple of them aren't. + +For example +```yaml +--- + - type: java\.time\.LocalDate + specAsParameter: + - type: string + format: date + - type: java\.time\.Duration + specAsParameter: [] #this hides the type from query and path parameter + specAsProperty: + $ref: "#/definitions/Duration" + + +``` + +The preceding example would result in output for a field with type `java.time.LocalDate` like this: + +```json +"fieldName": { + "type":"string", + "format":"date" +} +``` + +#### How to use a custom naming strategy? + +To use a custom naming strategies to override your case classes field names, you need to add this to your `build.sbt`: + +```scala +//default is 'none', which is your camelCased case class +swaggerNamingStrategy := "snake_case" //snake_case_skip_number, kebab-case, lowercase and UpperCamelCase also available +``` + +#### The spec is missing when built to a docker image using sbt-native-pakcager + +@mosche answered this one in #114 +> It's a bit unfortunate the way the docker plugin redefines stage. +However, the solution is pretty simple. Just add: +```Scala +(stage in Docker) <<= (stage in Docker).dependsOn(swagger) +``` + +#### How to hide an endpoint? +If you don't want an end point to be included, add `### NoDocs ###` in front of it +e.g. +``` +### NoDocs ### +GET /docs/swagger-ui/*file controllers.Assets.at(path:String="/public/lib/swagger-ui", file:String) +``` + +##### Skip entire file + +The entire file can be skipped by adding `### SkipFileForDocs ###` at the beginning of the routes file. + +Alternatively, the routes file can be split into multiple files, so that you can skip practically only a part of the file. + +https://www.playframework.com/documentation/ja/2.4.x/SBTSubProjects + +``` +### SkipFileForDocs ### + +GET /api/hidden/a controllers.hiddenEndPointA() +GET /api/hidden/b controllers.hiddenEndPointB() +GET /api/hidden/c controllers.hiddenEndPointC() +``` + -## About the Migration -This project has been migrated to a new repository in July 2023 due to a change in administration. +#### How to specify body content in a POST endpoint +Body content is specified as a special parameter in swagger. So you need to create a parameter in your swagger spec comment as "body", for example +``` +### +# parameters: +# - name: body +# schema: +# $ref: '#/definitions/com.iheart.api.Track' +### +POST /tracks controller.Api.createTrack() +``` +Again, play-swagger will generate the definition for com.iheart.api.Track case class -The development and maintenance of the project are being continued on the new repository. +#### How do I use a different "host" for different environment? +Use the [alternative setup](docs/AlternativeSetup.md). The library returns play JsObject, you can change however you want like +```scala +val spec: Try[JsObject] = ps.generate().map(_ + ("host" -> JsString(myHost))) +``` + + +#### How to use a route file different from the default "routes"? +In build.sbt, add +```Scala +swaggerRoutesFile := "my-routes" +``` + +or if you took the [alternative setup](docs/AlternativeSetup.md) +```scala +SwaggerSpecGenerator(domainPackage).generate("myRoutes.routes") +``` -While this repository is left for reference, please refer to the new repository for the most up-to-date code and information. +#### How do I change the location of the swagger documentation in the packaged app? +In build.sbt, add +```scala +swaggerTarget := new File("path/to/swagger/location") +``` + +#### How do I change the filename of the swagger documentation in the packaged app? +In build.sbt, add +```scala +swaggerFileName := "customSwagger.json" +``` + +#### How to output formatted json in swagger documentation file? +In build.sbt, add +```scala +swaggerPrettyJson := true +``` + +#### Support for generic types in schemas +Generic types in schema definitions for request/response body is supported. Example: +```scala +package models + +case class Foo[T](payload: T) +case class AnotherOne(someString: String) +``` +One can, then, reference the schema directly with `models.Foo[models.AnotherOne]` +and a correct OpenAPI 3 spec will be generated (not tested with Swagger 2.0): +```yaml +### +# summary: Get a message +# responses: +# 200: +# description: success +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.Foo[models.AnotherOne]' +### +GET /message controllers.AsyncController.parametric +``` +The generated schema name, however, cannot contain `[`, `]` or `,` which appear in type argument lists in Scala. +Therefore, there's a default `OutputTransformer` (`ParametricTypeNamesTransformerSpec`) which normalises the name into the URL-compliant form. +The definitions output would then look like: +```json5 +{ + "components": { + "schemas": { + "models.AnotherOne": { + "properties": { + "someString": { + "type": "string" + } + }, + "required": [ + "someString" + ] + }, + "models.Foo-models.AnotherOne": { + "properties": { + "payload": { + "$ref": "#/components/schemas/models.AnotherOne" + } + }, + "required": [ + "payload" + ] + } + } + }, + // ... + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.Foo-models.AnotherOne" + } + } + } + //... +} +``` + +#### Where to find more examples? +In the [tests](/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala)! + + +#### How to use markup in swagger specs +You can use markup on your swagger specs by providing `OutputTransformers` classes name to the setting `swaggerOutputTransformers` on your build file. + +For example you can use environment variables by adding the configuration: +``` + swaggerOutputTransformers := Seq(envOutputTransformer) +``` + +Then on your routes file or root swagger file you can use some markup like the one used below for the host field: +``` + swagger: "2.0" + info: + title: "API" + description: "REST API" + host: ${API_HOST} +``` + +This way when the swagger file is parsed the markup `${API_HOST}` is going to be substituted by the content of the environent variable `API_HOST`. + +#### How to support OpenAPI/Swagger v3 +You can produce swagger files for v3 by setting the flag: +``` + swaggerV3 := true +``` + +Make sure you also update your swagger markup to specify that you are using OpenAPI v3: +``` + openapi: 3.0.0 + info: + title: "API" + description: "REST API" + version: "1.0.0" +``` + +Also, for `$ref` fields you will want to prefix paths with `#/components/schemas/` instead of `#/definitions/`. For example: + +``` +### +# parameters: +# - name: body +# schema: +# $ref: '#/components/schemas/com.iheart.api.Track' +### +POST /tracks controller.Api.createTrack() +``` -https://github.com/play-swagger/play-swagger +#### No #definitions generated when referencing other Swagger files -# Get Started -For play 2.8, Scala 2.13.x and Scala 2.12.x please use +By placing a json or YAML file in `conf/${dir}/${file}` and referencing it with `$ref` in a comment, the file can be generated embedded in swagger.json. + +⚠️ **Warning**: If a file that does not exist in `/conf` is specified, or if a typo is used for the filename, `$ref:"${filename}"` will be output as is. + +example `conf/routes` file. + +``` +### +# summary: Top Page +# responses: +# 200: +# $ref: './swagger/home_200.yml' +### +GET / controllers.HomeController.index +``` + +example `conf/swagger/home_200.yml` file. + +```yaml +description: "success" +``` + +Of course, writing `schema` etc. will also be embedded. + +Generated `swagger.json`. + +```json5 +{ + "paths": { + "/": { + "get": { + "operationId": "index", + "tags": [ + "routes" + ], + "summary": "Top Page", + "responses": { + "200": { + "description": "success" + } + } + } + } + } + // ...... +} +``` + +See the following document for information on how to refer to other files by "$ref". + +https://swagger.io/docs/specification/using-ref/ + +##### You can also cut out the entire comment. + +This feature is very useful, but OpenAPI does not allow top-level `$ref`, so failing to embed it may result in an invalid `swagger.json`! + +``` +### +# $ref: './swagger/home.yml' +### +GET / controllers.HomeController.index +``` + +example `home.yml` file. + +```yaml +summary: Top Page +responses: + 200: + description: "success" +``` + +#### Duplicate operationId? + +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 -addSbtPlugin("io.github.play-swagger" % "sbt-play-swagger" % "1.2.3") +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: +``` + playJava := true +``` +The flag only suport PlayJava 2.7 and 2.8 diff --git a/build.sbt b/build.sbt index d68dbd2f..93a05f17 100644 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,12 @@ -organization in ThisBuild := "com.iheart" - +ThisBuild / sonatypeCredentialHost := "s01.oss.sonatype.org" +ThisBuild / sonatypeRepository := "https://s01.oss.sonatype.org/service/local" +ThisBuild / publish / skip := true ThisBuild / scalafixDependencies ++= Seq( "com.github.liancheng" %% "organize-imports" % "0.6.0", - "net.pixiv" %% "scalafix-pixiv-rule" % "4.5.3" + "com.sandinh" %% "scala-rewrites" % "1.1.0-M1", + "net.pixiv" %% "scalafix-pixiv-rule" % "4.5.3", + "com.github.xuwei-k" %% "scalafix-rules" % "0.3.1", + "com.github.jatcwang" %% "scalafix-named-params" % "0.2.3" ) addCommandAlias( @@ -10,28 +14,22 @@ addCommandAlias( ";set ThisBuild / version := \"0.0.1-EXAMPLE\"; +publishLocal" ) -lazy val noPublishSettings = Seq( - publish / skip := true, - publish := (), - publishLocal := (), - publishArtifact := false -) - -lazy val scalaV = "2.12.18" +lazy val scalaV = "2.12.19" lazy val root = project.in(file(".")) .aggregate(playSwagger, sbtPlaySwagger) .settings( - Publish.coreSettings, + sonatypeProfileName := "io.github.play-swagger", + publish / skip := true, sourcesInBase := false, - noPublishSettings, scalaVersion := scalaV ) lazy val playSwagger = project.in(file("core")) + .enablePlugins(GitBranchPrompt) .settings( + publish / skip := false, Publish.coreSettings, - Format.settings, Testing.settings, name := "play-swagger", libraryDependencies ++= Dependencies.playTest ++ @@ -42,27 +40,36 @@ lazy val playSwagger = project.in(file("core")) Dependencies.test ++ Dependencies.yaml ++ Seq( "com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3", - "org.scalameta" %% "scalameta" % "4.8.5", + "org.scalameta" %% "scalameta" % "4.8.15", "net.steppschuh.markdowngenerator" % "markdowngenerator" % "1.3.1.1", - "joda-time" % "joda-time" % "2.12.5" % Test, - "com.google.errorprone" % "error_prone_annotations" % "2.20.0" % Test + "joda-time" % "joda-time" % "2.12.6" % Test, + "com.google.errorprone" % "error_prone_annotations" % "2.25.0" % Test ), libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always, addCompilerPlugin("com.github.takezoe" %% "runtime-scaladoc-reader" % "1.0.3"), scalaVersion := scalaV, - crossScalaVersions := Seq(scalaVersion.value, "2.13.11"), + crossScalaVersions := Seq(scalaVersion.value, "2.13.13"), + semanticdbEnabled := true, + semanticdbVersion := scalafixSemanticdb.revision, scalacOptions ++= (CrossVersion.partialVersion(scalaVersion.value) match { case Some((2, 13)) => Seq("-Wunused") case _ => Seq("-Xlint:unused") - }) + }) ++ Seq( + "-deprecation", + "-feature", + "-Ypatmat-exhaust-depth", + "40", + "-P:semanticdb:synthetics:on" + ) ) lazy val sbtPlaySwagger = project.in(file("sbtPlugin")) + .enablePlugins(GitBranchPrompt) .settings( + publish / skip := false, Publish.coreSettings, - Format.settings, - addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.3.17" % Provided), - addSbtPlugin("com.typesafe.sbt" %% "sbt-web" % "1.4.4" % Provided) + addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.3.25" % Provided), + addSbtPlugin("com.github.sbt" %% "sbt-web" % "1.5.5" % Provided) ) .enablePlugins(BuildInfoPlugin, SbtPlugin) .settings( @@ -72,14 +79,22 @@ lazy val sbtPlaySwagger = project.in(file("sbtPlugin")) description := "sbt plugin for play swagger spec generation", sbtPlugin := true, scalaVersion := scalaV, - scripted := scripted.dependsOn(publishLocal in playSwagger).evaluated, + scripted := scripted.dependsOn(playSwagger / publishLocal).evaluated, scriptedLaunchOpts := { scriptedLaunchOpts.value ++ Seq("-Xmx1024M", "-Dplugin.version=" + version.value) }, scriptedBufferLog := false, + semanticdbEnabled := true, + semanticdbVersion := scalafixSemanticdb.revision, scalacOptions ++= (CrossVersion.partialVersion(scalaVersion.value) match { case Some((2, 13)) => Seq("-Wunused") case _ => Seq("-Xlint:unused") - }) + }) ++ Seq( + "-deprecation", + "-feature", + "-Ypatmat-exhaust-depth", + "40", + "-P:semanticdb:synthetics:on" + ) ) diff --git a/core/src/main/scala/com/iheart/playSwagger/Domain.scala b/core/src/main/scala/com/iheart/playSwagger/Domain.scala deleted file mode 100644 index 958a2f98..00000000 --- a/core/src/main/scala/com/iheart/playSwagger/Domain.scala +++ /dev/null @@ -1,73 +0,0 @@ -package com.iheart.playSwagger - -import play.api.libs.json.{JsObject, JsPath, JsValue, Reads} - -object Domain { - type Path = String - type Method = String - - final case class Definition( - name: String, - properties: Seq[SwaggerParameter], - description: Option[String] = None - ) - - sealed trait SwaggerParameter { - def name: String - 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 - } - - final case class GenSwaggerParameter( - name: String, - referenceType: Option[String] = None, - `type`: Option[String] = None, - format: Option[String] = None, - required: Boolean = true, - nullable: Option[Boolean] = None, - default: Option[JsValue] = None, - example: Option[JsValue] = None, - items: Option[SwaggerParameter] = 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) - } - - final case class CustomSwaggerParameter( - name: String, - specAsParameter: List[JsObject], - specAsProperty: Option[JsObject], - required: Boolean = true, - nullable: Option[Boolean] = 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) - } - - type CustomMappings = List[CustomTypeMapping] - - case class CustomTypeMapping( - `type`: String, - specAsParameter: List[JsObject] = Nil, - specAsProperty: Option[JsObject] = None, - required: Boolean = true - ) - - object CustomTypeMapping { - import play.api.libs.functional.syntax._ - implicit val csmFormat: Reads[CustomTypeMapping] = ( - (JsPath \ 'type).read[String] and - (JsPath \ 'specAsParameter).read[List[JsObject]] and - (JsPath \ 'specAsProperty).readNullable[JsObject] and - ((JsPath \ 'required).read[Boolean] orElse Reads.pure(true)) - )(CustomTypeMapping.apply _) - } -} diff --git a/core/src/main/scala/com/iheart/playSwagger/NamingStrategy.scala b/core/src/main/scala/com/iheart/playSwagger/NamingStrategy.scala deleted file mode 100644 index 367f5112..00000000 --- a/core/src/main/scala/com/iheart/playSwagger/NamingStrategy.scala +++ /dev/null @@ -1,33 +0,0 @@ -package com.iheart.playSwagger - -import scala.util.matching.Regex - -sealed abstract class NamingStrategy(f: String => String) extends (String => String) { - override def apply(keyName: String): String = f(keyName) -} - -object NamingStrategy { - val regex: Regex = "[A-Z\\d]".r - val skipNumberRegex: Regex = "[A-Z]".r - - object None extends NamingStrategy(identity) - object SnakeCase extends NamingStrategy(x => regex.replaceAllIn(x, { m => "_" + m.group(0).toLowerCase() })) - object KebabCase extends NamingStrategy(x => regex.replaceAllIn(x, { m => "-" + m.group(0).toLowerCase() })) - object LowerCase extends NamingStrategy(x => regex.replaceAllIn(x, { m => m.group(0).toLowerCase() })) - object UpperCamelCase extends NamingStrategy(x => { - val (head, tail) = x.splitAt(1) - head.toUpperCase() + tail - }) - object SnakeCaseSkipNumber extends NamingStrategy(x => - skipNumberRegex.replaceAllIn(x, { m => "_" + m.group(0).toLowerCase() }) - ) - - def from(naming: String): NamingStrategy = naming match { - case "snake_case" => SnakeCase - case "snake_case_skip_number" => SnakeCaseSkipNumber - case "kebab-case" => KebabCase - case "lowercase" => LowerCase - case "UpperCamelCase" => UpperCamelCase - case _ => None - } -} diff --git a/core/src/main/scala/com/iheart/playSwagger/OutputTransformer.scala b/core/src/main/scala/com/iheart/playSwagger/OutputTransformer.scala index 769c0a2e..960cd8cf 100644 --- a/core/src/main/scala/com/iheart/playSwagger/OutputTransformer.scala +++ b/core/src/main/scala/com/iheart/playSwagger/OutputTransformer.scala @@ -16,7 +16,7 @@ trait OutputTransformer extends (JsObject => Try[JsObject]) { } object OutputTransformer { - final case class SimpleOutputTransformer(run: (JsObject => Try[JsObject])) extends OutputTransformer { + final case class SimpleOutputTransformer(run: JsObject => Try[JsObject]) extends OutputTransformer { override def apply(value: JsObject): Try[JsObject] = run(value) } @@ -55,7 +55,7 @@ object OutputTransformer { } } -class PlaceholderVariablesTransformer(map: String => Option[String], pattern: Regex = "^\\$\\{(.*)\\}$".r) +class PlaceholderVariablesTransformer(map: String => Option[String], pattern: Regex = ("^\\$\\{(.*)\\}$").r) extends OutputTransformer { def apply(value: JsObject): Try[JsObject] = OutputTransformer.traverseTransformer(value) { case JsString(pattern(key)) => map(key) match { diff --git a/core/src/main/scala/com/iheart/playSwagger/SwaggerParameterMapper.scala b/core/src/main/scala/com/iheart/playSwagger/SwaggerParameterMapper.scala deleted file mode 100644 index 5a6b5ede..00000000 --- a/core/src/main/scala/com/iheart/playSwagger/SwaggerParameterMapper.scala +++ /dev/null @@ -1,229 +0,0 @@ -package com.iheart.playSwagger - -import scala.reflect.runtime.universe -import scala.util.Try -import scala.util.matching.Regex - -import com.iheart.playSwagger.Domain.{CustomMappings, CustomSwaggerParameter, GenSwaggerParameter, SwaggerParameter} -import play.api.libs.json._ -import play.routes.compiler.Parameter - -object SwaggerParameterMapper { - - type MappingFunction = PartialFunction[String, SwaggerParameter] - - def mapParam( - parameter: Parameter, - modelQualifier: DomainModelQualifier = PrefixDomainModelQualifier(), - customMappings: CustomMappings = Nil, - description: Option[String] = None - )(implicit cl: ClassLoader): SwaggerParameter = { - - def removeKnownPrefixes(name: String) = - name.replaceAll("^((scala\\.)|(java\\.lang\\.)|(java\\.util\\.)|(math\\.)|(org\\.joda\\.time\\.))", "") - - def higherOrderType(higherOrder: String, typeName: String, pkgPattern: Option[String]): Option[String] = { - s"^${pkgPattern.map(p => s"(?:$p\\.)?").getOrElse("")}$higherOrder\\[(\\S+)\\]".r - .findFirstMatchIn(typeName) - .map(_.group(1)) - } - - def collectionItemType(typeName: String): Option[String] = - List("Seq", "List", "Set", "Vector") - .map(higherOrderType(_, typeName, Some("collection(?:\\.(?:mutable|immutable))?"))) - .reduce(_ orElse _) - - val typeName = removeKnownPrefixes(parameter.typeName) - - val defaultValueO: Option[JsValue] = { - parameter.default.map { value => - if (value.equals("null")) { - JsNull - } else { - typeName match { - case ci"Int" | ci"Long" => JsNumber(value.toLong) - case ci"Double" | ci"Float" | ci"BigDecimal" => JsNumber(value.toDouble) - case ci"Boolean" => JsBoolean(value.toBoolean) - case ci"String" => - val unquotedString = value match { - case c if c.startsWith("\"\"\"") && c.endsWith("\"\"\"") => c.substring(3, c.length - 3) - case c if c.startsWith("\"") && c.endsWith("\"") => c.substring(1, c.length - 1) - case c => c - } - JsString(unquotedString) - case _ => JsString(value) - } - } - } - } - - def genSwaggerParameter( - 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, - description = description - ) - - val enumParamMF: MappingFunction = { - case JavaEnum(enumConstants) => genSwaggerParameter("string", enum = Option(enumConstants)) - case ScalaEnum(enumConstants) => genSwaggerParameter("string", enum = Option(enumConstants)) - case EnumeratumEnum(enumConstants) => genSwaggerParameter("string", enum = Option(enumConstants)) - } - - def isReference(tpeName: String = typeName): Boolean = modelQualifier.isModel(tpeName) - - def referenceParam(referenceType: String) = - GenSwaggerParameter(parameter.name, referenceType = Some(referenceType)) - - def optionalParam(optionalTpe: String) = { - val asRequired = mapParam( - parameter.copy( - typeName = optionalTpe, - default = parameter.default match { - // If `Some("None")`, then `variable: Option[T] ? = None` is specified. So `default` is treated as if it does not exist. - case Some("None") => None - // Maybe only `None`. - case default => default - } - ), - modelQualifier = modelQualifier, - customMappings = customMappings, - description = description - ) - asRequired.update(required = false, nullable = true, default = asRequired.default) - } - - def updateGenParam(param: SwaggerParameter)(update: GenSwaggerParameter => GenSwaggerParameter): SwaggerParameter = - param match { - case p: GenSwaggerParameter => update(p) - case _ => param - } - - val referenceParamMF: MappingFunction = { - case tpe if isReference(tpe) => referenceParam(tpe) - } - - val optionalParamMF: MappingFunction = { - case tpe if higherOrderType("Option", typeName, None).isDefined => - optionalParam(higherOrderType("Option", typeName, None).get) - } - - val generalParamMF: MappingFunction = { - case ci"Int" | ci"Integer" => genSwaggerParameter("integer", Some("int32")) - case ci"Long" => genSwaggerParameter("integer", Some("int64")) - case ci"Double" | ci"BigDecimal" => genSwaggerParameter("number", Some("double")) - case ci"Float" => genSwaggerParameter("number", Some("float")) - case ci"DateTime" => genSwaggerParameter("integer", Some("epoch")) - case ci"java.time.Instant" => genSwaggerParameter("string", Some("date-time")) - case ci"java.time.LocalDate" => genSwaggerParameter("string", Some("date")) - case ci"java.time.LocalDateTime" => genSwaggerParameter("string", Some("date-time")) - case ci"java.time.Duration" => genSwaggerParameter("string") - case ci"Any" => genSwaggerParameter("any").copy(example = Some(JsString("any JSON value"))) - case unknown => genSwaggerParameter(unknown.toLowerCase()) - } - - val itemsParamMF: MappingFunction = { - case tpe if collectionItemType(tpe).isDefined => - // TODO: This could use a different type to represent ItemsObject(http://swagger.io/specification/#itemsObject), - // since the structure is not quite the same, and still has to be handled specially in a json transform (see propWrites in SwaggerSpecGenerator) - // However, that spec conflicts with example code elsewhere that shows other fields in the object, such as properties: - // http://stackoverflow.com/questions/26206685/how-can-i-describe-complex-json-model-in-swagger - updateGenParam(generalParamMF("array"))(_.copy( - items = Some( - mapParam(parameter.copy(typeName = collectionItemType(tpe).get), modelQualifier, customMappings) - ) - )) - } - - val customMappingMF: MappingFunction = customMappings.map { mapping => - val re = StringContext(removeKnownPrefixes(mapping.`type`)).ci - val mf: MappingFunction = { - case re() => - CustomSwaggerParameter( - parameter.name, - mapping.specAsParameter, - mapping.specAsProperty, - default = defaultValueO, - required = defaultValueO.isEmpty && mapping.required - ) - } - mf - }.foldLeft[MappingFunction](PartialFunction.empty)(_ orElse _) - - // sequence of this list is the sequence of matching, that is, of importance - List( - optionalParamMF, - itemsParamMF, - customMappingMF, - enumParamMF, - referenceParamMF, - generalParamMF - ).reduce(_ orElse _)(typeName) - - } - - implicit class CaseInsensitiveRegex(sc: StringContext) { - def ci: Regex = ("(?i)" + sc.parts.mkString).r - } - - /** - * Unapply the type by name and return the Java enum constants if those exist. - */ - private object JavaEnum { - def unapply(tpeName: String)(implicit cl: ClassLoader): Option[Seq[String]] = { - Try(cl.loadClass(tpeName)).toOption.filter(_.isEnum).map(_.getEnumConstants.map(_.toString)) - } - } - - /** - * Unapply the type by name and return the Scala enum constants if those exist. - */ - private object ScalaEnum { - def unapply(tpeName: String)(implicit cl: ClassLoader): Option[Seq[String]] = { - if (tpeName.endsWith(".Value")) { - Try { - val mirror = universe.runtimeMirror(cl) - val module = mirror.reflectModule(mirror.staticModule(tpeName.stripSuffix(".Value"))) - for { - enum ← Option(module.instance).toSeq if enum.isInstanceOf[Enumeration] - value ← enum.asInstanceOf[Enumeration].values.asInstanceOf[Iterable[Enumeration#Value]] - } yield value.toString - }.toOption.filterNot(_.isEmpty) - } else - None - } - } - - /** - * Unapply the type by name and return the Enumeratum enum constants if those exist. - */ - private object EnumeratumEnum { - def unapply(className: String): Option[Seq[String]] = { - (for { - clazz <- Try(Class.forName(className + "$")) - singleton <- Try(clazz.getField("MODULE$").get(clazz)) - values <- Try(singleton.getClass.getDeclaredField("values")) - _ = values.setAccessible(true) - entries <- Try(values - .get(singleton) - .asInstanceOf[Vector[_]] - .map { item => - val entryName = Try( - item.getClass.getMethod("entryName") - ).getOrElse(item.getClass.getMethod("value")) - entryName.setAccessible(true) - entryName.invoke(item).asInstanceOf[String] - } - .toList) - } yield entries).toOption - } - } -} diff --git a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala deleted file mode 100644 index c9707a29..00000000 --- a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecGenerator.scala +++ /dev/null @@ -1,555 +0,0 @@ -package com.iheart.playSwagger - -import java.io.File - -import scala.collection.immutable.ListMap -import scala.collection.mutable -import scala.util.{Failure, Success, Try} - -import com.fasterxml.jackson.databind.ObjectMapper -import com.iheart.playSwagger.Domain._ -import com.iheart.playSwagger.OutputTransformer.SimpleOutputTransformer -import com.iheart.playSwagger.ResourceReader.read -import com.iheart.playSwagger.SwaggerParameterMapper.mapParam -import org.yaml.snakeyaml.Yaml -import play.api.libs.json.JsValue.jsValueToJsLookup -import play.api.libs.json._ -import play.routes.compiler._ - -object SwaggerSpecGenerator { - private val marker = "##" - val customMappingsFileName = "swagger-custom-mappings" - val baseSpecFileName = "swagger" - - def apply(namingStrategy: NamingStrategy, swaggerV3: Boolean, domainNameSpaces: String*)(implicit - cl: ClassLoader): SwaggerSpecGenerator = { - SwaggerSpecGenerator(namingStrategy, PrefixDomainModelQualifier(domainNameSpaces: _*), swaggerV3 = swaggerV3) - } - - def apply( - namingStrategy: NamingStrategy, - outputTransformers: Seq[OutputTransformer], - domainNameSpaces: String* - )(implicit cl: ClassLoader): SwaggerSpecGenerator = { - SwaggerSpecGenerator( - namingStrategy, - PrefixDomainModelQualifier(domainNameSpaces: _*), - outputTransformers = outputTransformers - ) - } - - def apply(swaggerV3: Boolean, operationIdFully: Boolean, embedScaladoc: Boolean, domainNameSpaces: String*)(implicit - cl: ClassLoader): SwaggerSpecGenerator = { - SwaggerSpecGenerator( - NamingStrategy.None, - PrefixDomainModelQualifier(domainNameSpaces: _*), - swaggerV3 = swaggerV3, - operationIdFully = operationIdFully, - embedScaladoc = embedScaladoc - ) - } - def apply(outputTransformers: Seq[OutputTransformer], domainNameSpaces: String*)(implicit - cl: ClassLoader): SwaggerSpecGenerator = { - SwaggerSpecGenerator( - NamingStrategy.None, - PrefixDomainModelQualifier(domainNameSpaces: _*), - outputTransformers = outputTransformers - ) - } - - case object MissingBaseSpecException - extends Exception(s"Missing a $baseSpecFileName.yml or $baseSpecFileName.json to provide base swagger spec") - -} - -final case class SwaggerSpecGenerator( - namingStrategy: NamingStrategy = NamingStrategy.None, - modelQualifier: DomainModelQualifier = PrefixDomainModelQualifier(), - defaultPostBodyFormat: String = "application/json", - outputTransformers: Seq[OutputTransformer] = Nil, - swaggerV3: Boolean = false, - swaggerPlayJava: Boolean = false, - apiVersion: Option[String] = None, - operationIdFully: Boolean = false, - embedScaladoc: Boolean = false -)(implicit cl: ClassLoader) { - - import SwaggerSpecGenerator.{MissingBaseSpecException, baseSpecFileName, customMappingsFileName} - - // routes with their prefix - type Routes = (String, Seq[Route]) - - // Mapping of the tag, which is the file the routes were read from, and the optional prefix if it was - // included from another router. ListMap is used to maintain the original definition order - type RoutesData = Try[ListMap[Tag, Routes]] - - val defaultRoutesFile = "routes" - - def generate(routesFile: String = defaultRoutesFile): Try[JsObject] = { - - val base = apiVersion.fold(defaultBase)(v => Json.obj("info" -> Json.obj("version" -> v)) deepMerge defaultBase) - generateFromRoutesFile(routesFile = routesFile, base = base) - } - - val routesExt = ".routes" - - private[playSwagger] def generateFromRoutesFile( - routesFile: String = defaultRoutesFile, - base: JsObject = Json.obj() - ): Try[JsObject] = { - - def tagFromFile(file: String) = file.replace(routesExt, "") - - def loop(path: String, routesFile: String): RoutesData = { - - // TODO: better error handling - ResourceReader.read(routesFile).flatMap { lines => - lines.headOption match { - case Some("### SkipFileForDocs ###") => Success { ListMap.empty } - case _ => - val content = lines.mkString("\n") - - // artificial file to conform to api, used by play for error reporting - val file = new File(routesFile) - - def errorMessage(error: RoutesCompilationError) = { - val lineNumber = error.line.fold("")(":" + _ + error.column.fold("")(":" + _)) - val errorLine = error.line.flatMap { line => - val caret = error.column.map(c => (" " * (c - 1)) + "^").getOrElse("") - lines.lift(line - 1).map(_ + "\n" + caret) - }.getOrElse("") - s"""|Error parsing routes file: ${error.source.getName}$lineNumber ${error.message} - |$errorLine - |""".stripMargin - } - - RoutesFileParser.parseContent(content, file).fold( - { errors => - val message = errors.map(errorMessage).mkString("\n") - Failure(new Exception(message)) - }, - { rules => - val routerName = tagFromFile(routesFile) - val init: RoutesData = Success(ListMap(routerName -> (path, Seq.empty))) - rules.foldLeft(init) { - case (Success(acc), route: Route) => - val (prefix, routes) = acc(routerName) - Success(acc + (routerName -> (prefix, routes :+ route))) - case (Success(acc), Include(prefix, router)) => - val reference = router.replace(".Routes", ".routes") - val isIncludedRoutesFile = cl.getResource(reference) != null - if (isIncludedRoutesFile) { - val updated = if (path.nonEmpty) path + "/" + prefix else prefix - loop(updated, reference).map(acc ++ _) - } else Success(acc) - case (l @ Failure(_), _) => l - } - } - ) - } - } - } - - // starts with empty prefix, assuming that the routesFile is the outermost (usually 'routes') - loop("", routesFile).flatMap { data => - val result: JsObject = generateFromRoutes(data, base) - val initial = SimpleOutputTransformer(Success[JsObject]) - val mapper = outputTransformers.foldLeft[OutputTransformer](initial)(_ >=> _) - mapper(result) - } - } - - /** - * Generate directly from routes - * - * @param routes [[Route]]s compiled by Play routes compiler - * @param base - * @return - */ - def generateFromRoutes(routes: ListMap[Tag, (String, Seq[Route])], base: JsObject = defaultBase): JsObject = { - val docs = routes.map { - case (tag, (prefix, routes)) => - // val subTag = if (tag == tagFromFile(routesFile)) None else Some(tag) - tag -> paths(routes, prefix, Some(tag)) - }.filter(_._2.keys.nonEmpty) - generateWithBase(docs, base) - } - - private[playSwagger] def generateWithBase( - paths: ListMap[String, JsObject], - baseJson: JsObject = Json.obj() - ): JsObject = { - - val refKey = "$ref" - - val pathsJson = paths.values.reduce((acc, p) => JsObject(acc.fields ++ p.fields)) - - val mainRefs = (pathsJson ++ baseJson) \\ refKey - val customMappingRefs = for { - customMapping ← customMappings - mappingsJson = customMapping.specAsProperty.toSeq ++ customMapping.specAsParameter - ref ← mappingsJson.flatMap(_ \\ refKey) - } yield ref - val allRefs = mainRefs ++ customMappingRefs - - val definitions: List[Definition] = { - val referredClasses: Seq[String] = for { - refJson ← allRefs.toList - ref ← refJson.asOpt[String].toList - className = ref.stripPrefix(referencePrefix) - if modelQualifier.isModel(className) - } yield className - - DefinitionGenerator( - modelQualifier = modelQualifier, - mappings = customMappings, - swaggerPlayJava = swaggerPlayJava, - namingStrategy = namingStrategy, - embedScaladoc = embedScaladoc - ).allDefinitions(referredClasses) - } - - val definitionsJson = JsObject(definitions.map(d => d.name -> Json.toJson(d))) - - val tagsJson = (baseJson \ "tags").asOpt[JsArray].getOrElse(JsArray()) - val pathsAndDefinitionsJson = Json.obj( - "paths" -> pathsJson, - if (swaggerV3) { - "components" -> Json.obj( - "schemas" -> definitionsJson - ) - } else { - "definitions" -> definitionsJson - } - ) - - pathsAndDefinitionsJson.deepMerge(baseJson) + ("tags" -> tagsJson) - } - - private def referencePrefix = if (swaggerV3) "#/components/schemas/" else "#/definitions/" - - private val refWrite = OWrites((refType: String) => Json.obj("$ref" -> JsString(referencePrefix + refType))) - - import play.api.libs.functional.syntax._ - - private lazy val genParamWrites: OWrites[GenSwaggerParameter] = { - val under = if (swaggerV3) __ \ "schema" else __ - val nullableName = if (swaggerV3) "nullable" else "x-nullable" - - ( - (__ \ 'name).write[String] ~ - (__ \ "schema").writeNullable[String](refWrite) ~ - (under \ 'type).writeNullable[String] ~ - (under \ 'format).writeNullable[String] ~ - (__ \ 'required).write[Boolean] ~ - (under \ nullableName).writeNullable[Boolean] ~ - (under \ 'default).writeNullable[JsValue] ~ - (under \ 'example).writeNullable[JsValue] ~ - (under \ "items").writeNullable[SwaggerParameter](propWrites) ~ - (under \ "enum").writeNullable[Seq[String]] ~ - (__ \ "description").writeNullable[String] - )(unlift(GenSwaggerParameter.unapply)) - } - - private def customParamWrites(csp: CustomSwaggerParameter): List[JsObject] = { - csp.specAsParameter match { - case head :: tail => - def withPrefix(input: JsObject): JsObject = { - if (swaggerV3) Json.obj("schema" -> input) else input - } - - val nullableName = if (swaggerV3) "nullable" else "x-nullable" - - val under = if (swaggerV3) __ \ "schema" else __ - val w = ( - (__ \ 'name).write[String] ~ - (__ \ 'required).write[Boolean] ~ - (under \ nullableName).writeNullable[Boolean] ~ - (under \ 'default).writeNullable[JsValue] - )((c: CustomSwaggerParameter) => (c.name, c.required, c.nullable, c.default)) - - (w.writes(csp) ++ withPrefix(head)) :: tail - case Nil => Nil - } - } - - private lazy val customPropWrites: Writes[CustomSwaggerParameter] = Writes { cwp => - val nullableName = if (swaggerV3) "nullable" else "x-nullable" - - (__ \ 'default).writeNullable[JsValue].writes(cwp.default) ++ - (__ \ nullableName).writeNullable[Boolean].writes(cwp.nullable) ++ - (cwp.specAsProperty orElse cwp.specAsParameter.headOption).getOrElse(Json.obj()) - } - - private lazy val propWrites: Writes[SwaggerParameter] = Writes { - case g: GenSwaggerParameter => genPropWrites.writes(g) - case c: CustomSwaggerParameter => customPropWrites.writes(c) - } - - private lazy val genPropWrites: Writes[GenSwaggerParameter] = { - val nullableName = if (swaggerV3) "nullable" else "x-nullable" - - ( - (__ \ 'type).writeNullable[String] ~ - (__ \ 'format).writeNullable[String] ~ - (__ \ nullableName).writeNullable[Boolean] ~ - (__ \ 'default).writeNullable[JsValue] ~ - (__ \ 'example).writeNullable[JsValue] ~ - (__ \ "$ref").writeNullable[String] ~ - (__ \ "items").lazyWriteNullable[SwaggerParameter](propWrites) ~ - (__ \ "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.description - ) - ) - } - - implicit class PathAdditions(path: JsPath) { - def writeNullableIterable[A <: Iterable[_]](implicit writes: Writes[A]): OWrites[A] = - OWrites[A] { (a: A) => - if (a.isEmpty) Json.obj() - else JsPath.createObj(path -> writes.writes(a)) - } - } - - private implicit val propertiesWriter: Writes[Seq[SwaggerParameter]] = Writes[Seq[SwaggerParameter]] { ps => - JsObject(ps.map(p => p.name -> Json.toJson(p)(propWrites))) - } - - private implicit val defFormat: Writes[Definition] = ( - (__ \ 'description).writeNullable[String] ~ - (__ \ 'properties).write[Seq[SwaggerParameter]] ~ - (__ \ 'required).writeNullable[Seq[String]] - )((d: Definition) => (d.description, d.properties, requiredProperties(d.properties))) - - private def requiredProperties(properties: Seq[SwaggerParameter]): Option[Seq[String]] = { - val required = properties.filter(_.required).map(_.name) - if (required.isEmpty) None else Some(required) - } - - private lazy val defaultBase: JsObject = - readYmlOrJson[JsObject](baseSpecFileName).getOrElse(throw MissingBaseSpecException) - - private lazy val customMappings: CustomMappings = { - readYmlOrJson[CustomMappings](customMappingsFileName).getOrElse(Nil) - } - - private def readYmlOrJson[T: Reads](fileName: String): Option[T] = { - readCfgFile[T](s"$fileName.json") orElse readCfgFile[T](s"$fileName.yml") - } - - private def mergeByName(base: JsArray, toMerge: JsArray): JsArray = { - JsArray(base.value.map { bs => - val name = (bs \ "name").as[String] - findByName(toMerge, name).fold(bs) { f => bs.as[JsObject] deepMerge f } - } ++ toMerge.value.filter { tm => - (tm \ "name").validate[String].fold( - { errors => true }, - { name => - findByName(base, name).isEmpty - } - ) - }) - } - - private def findByName(array: JsArray, name: String): Option[JsObject] = - array.value.find(param => (param \ "name").asOpt[String].contains(name)) - .map(_.as[JsObject]) - - private[playSwagger] def readCfgFile[T](name: String)(implicit fjs: Reads[T]): Option[T] = { - Option(cl.getResource(name)).map { url => - val st = url.openStream() - try { - val ext = url.getFile.split("\\.").last - ext match { - case "json" => Json.parse(st).as[T] - // TODO: improve error handling - case "yml" => parseYaml(read(st).get.mkString("\n")) - case unknown => - throw new IllegalArgumentException(s"$name has an unsupported extension. Use either json or yml. ") - } - } finally { - st.close() - } - } - } - - private def parseYaml[T](yamlStr: String)(implicit fjs: Reads[T]): T = { - val yaml = new Yaml() - val map = yaml.load[T](yamlStr) - val mapper = new ObjectMapper() - val jsonString = mapper.writeValueAsString(map) - Json.parse(jsonString).as[T] - } - - private def paths(routes: Seq[Route], prefix: String, tag: Option[Tag]): JsObject = { - JsObject { - val endPointEntries = routes.flatMap(route => endPointEntry(route, prefix, tag)) - - // maintain the routes order as per the original routing file - val zgbp = endPointEntries.zipWithIndex.groupBy(_._1._1) - val lhm = mutable.LinkedHashMap(zgbp.toSeq.sortBy(_._2.head._2): _*) - val gbp2 = lhm.mapValues(_.map(_._1)).toSeq - - gbp2.map(x => (x._1, x._2.map(_._2).reduce(_ deepMerge _))) - } - } - - private def endPointEntry(route: Route, prefix: String, tag: Option[String]): Option[(String, JsObject)] = { - import SwaggerSpecGenerator.marker - - val comments = route.comments.map(_.comment).mkString("\n") - if (s"$marker\\s*NoDocs\\s*$marker".r.findFirstIn(comments).isDefined) { - None - } else { - val inRoutePath = route.path.parts.map { - case DynamicPart(name, _, _) => s"{$name}" - case StaticPart(value) => value - }.mkString - val method = route.verb.value.toLowerCase - Some(fullPath(prefix, inRoutePath) -> Json.obj(method -> endPointSpec(route, tag))) - } - } - - private[playSwagger] def fullPath(prefix: String, inRoutePath: String): String = { - if (prefix.endsWith("/") && (inRoutePath == "/" || inRoutePath.isEmpty)) // special case for ("/p/" , "/") or ("/p/" , "") - "/" + prefix.stripPrefix("/") - else - "/" + List( - prefix.stripPrefix("/").stripSuffix("/"), - inRoutePath.stripPrefix("/") - ).filterNot(_.isEmpty).mkString("/") - } - - // Multiple routes may have the same path, merge the objects instead of overwriting - - private def endPointSpec(route: Route, tag: Option[String]) = { - - def tryParseYaml(comment: String): Option[JsObject] = { - // The purpose here is more to ensure that it is not in other formats such as JSON - // If invalid YAML is passed, org.yaml.snakeyaml.parser.ParserException - val pattern = "^\\w+|\\$ref:".r - pattern.findFirstIn(comment).map(_ => parseYaml[JsObject](comment)) - } - - def tryParseJson(comment: String): Option[JsObject] = { - if (comment.startsWith("{")) - Some(Json.parse(comment).as[JsObject]) - else None - } - - def amendBodyParam(params: JsArray): JsArray = { - val bodyParam = findByName(params, "body") - bodyParam.fold(params) { param => - val enhancedBodyParam = Json.obj("in" -> JsString("body")) ++ param - JsArray(enhancedBodyParam +: params.value.filterNot(_ == bodyParam.get)) - } - } - - val paramsFromController = { - val pathParams = route.path.parts.collect { - case d: DynamicPart => d.name - }.toSet - - val params = for { - paramList ← route.call.parameters.toSeq - param ← paramList - if param.fixed.isEmpty && !param.isJavaRequest // Removes parameters the client cannot set - } yield mapParam(param, modelQualifier, customMappings) - - JsArray(params.flatMap { p => - val jos: List[JsObject] = p match { - case gsp: GenSwaggerParameter => List(genParamWrites.writes(gsp)) - case csp: CustomSwaggerParameter => customParamWrites(csp) - } - - val in = if (pathParams.contains(p.name)) "path" else "query" - val enhance = Json.obj("in" -> in) - jos.map(enhance ++ _) - }) - } - - val jsonFromComment = { - import SwaggerSpecGenerator.marker - - val comments = route.comments.map(_.comment) - val commentDocLines = comments match { - case `marker` +: docs :+ `marker` => docs - case _ => Nil - } - - 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) - - val mergedParams = mergeByName(paramsFromController, paramsFromComment.getOrElse(JsArray())) - - val parameterJson = if (mergedParams.value.nonEmpty) Json.obj("parameters" -> mergedParams) else Json.obj() - - val operationId = Json.obj( - "operationId" -> (if (operationIdFully) s"${route.call.controller}.${route.call.method}" else route.call.method) - ) - - val rawPathJson = operationId ++ tag.fold(Json.obj()) { t => - Json.obj("tags" -> List(t)) - } ++ jsonFromComment.getOrElse(Json.obj()) ++ parameterJson - - val hasConsumes = (rawPathJson \ "consumes").toOption.isDefined - - if (findByName(mergedParams, "body").isDefined && !hasConsumes) - rawPathJson + ("consumes" -> Json.arr(defaultPostBodyFormat)) - else rawPathJson - } -} diff --git a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala index 8a956f90..517aafb9 100644 --- a/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala +++ b/core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala @@ -4,6 +4,7 @@ import java.nio.file.{Files, Paths, StandardOpenOption} import scala.util.{Failure, Success, Try} +import com.iheart.playSwagger.generator.{NamingConvention, PrefixDomainModelQualifier, SwaggerSpecGenerator} import play.api.libs.json.{JsValue, Json} object SwaggerSpecRunner extends App { @@ -20,7 +21,7 @@ object SwaggerSpecRunner extends App { val domainModelQualifier = PrefixDomainModelQualifier(domainNameSpaceArgs.split(","): _*) val transformersStrs: Seq[String] = if (outputTransformersArgs.isEmpty) Seq() else outputTransformersArgs.split(",") val transformers = transformersStrs.map { clazz => - Try(cl.loadClass(clazz).asSubclass(classOf[OutputTransformer]).newInstance()) match { + Try(cl.loadClass(clazz).asSubclass(classOf[OutputTransformer]).getDeclaredConstructor().newInstance()) match { case Failure(ex: ClassCastException) => throw new IllegalArgumentException( "Transformer should be a subclass of com.iheart.playSwagger.OutputTransformer:" + clazz, @@ -31,8 +32,8 @@ object SwaggerSpecRunner extends App { } } val swaggerSpec: JsValue = SwaggerSpecGenerator( - NamingStrategy.from(namingStrategy), - domainModelQualifier, + namingConvention = NamingConvention.fromString(namingStrategy), + modelQualifier = domainModelQualifier, outputTransformers = transformers, swaggerV3 = swaggerV3, swaggerPlayJava = swaggerPlayJava, diff --git a/core/src/main/scala/com/iheart/playSwagger/domain/CustomTypeMapping.scala b/core/src/main/scala/com/iheart/playSwagger/domain/CustomTypeMapping.scala new file mode 100644 index 00000000..16503758 --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/domain/CustomTypeMapping.scala @@ -0,0 +1,20 @@ +package com.iheart.playSwagger.domain + +import play.api.libs.functional.syntax.toFunctionalBuilderOps +import play.api.libs.json.{JsObject, JsPath, Reads} + +case class CustomTypeMapping( + `type`: String, + specAsParameter: List[JsObject] = Nil, + specAsProperty: Option[JsObject] = None, + required: Boolean = true +) + +object CustomTypeMapping { + implicit val reads: Reads[CustomTypeMapping] = ( + (JsPath \ "type").read[String] and + (JsPath \ "specAsParameter").read[List[JsObject]] and + (JsPath \ "specAsProperty").readNullable[JsObject] and + (JsPath \ "required").read[Boolean].orElse(Reads.pure(true)) + )(CustomTypeMapping.apply _) +} diff --git a/core/src/main/scala/com/iheart/playSwagger/domain/Definition.scala b/core/src/main/scala/com/iheart/playSwagger/domain/Definition.scala new file mode 100644 index 00000000..3ec5396f --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/domain/Definition.scala @@ -0,0 +1,25 @@ +package com.iheart.playSwagger.domain + +import com.iheart.playSwagger.domain.parameter.SwaggerParameter +import play.api.libs.functional.syntax.toFunctionalBuilderOps +import play.api.libs.json.{Writes, __} + +final case class Definition( + name: String, + properties: Seq[SwaggerParameter], + description: Option[String] = None +) + +object Definition { + implicit def writer(implicit paramWriter: Writes[Seq[SwaggerParameter]]): Writes[Definition] = ( + (__ \ 'description).writeNullable[String] ~ + (__ \ 'properties).write[Seq[SwaggerParameter]] ~ + (__ \ 'required).writeNullable[Seq[String]] + )((d: Definition) => (d.description, d.properties, requiredProperties(d.properties))) + + private def requiredProperties(properties: Seq[SwaggerParameter]): Option[Seq[String]] = { + val required = properties.filter(_.required).map(_.name) + if (required.isEmpty) None else Some(required) + } + +} diff --git a/core/src/main/scala/com/iheart/playSwagger/domain/parameter/CustomSwaggerParameter.scala b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/CustomSwaggerParameter.scala new file mode 100644 index 00000000..30d6a535 --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/CustomSwaggerParameter.scala @@ -0,0 +1,16 @@ +package com.iheart.playSwagger.domain.parameter + +import play.api.libs.json.{JsObject, JsValue} + +final case class CustomSwaggerParameter( + override val name: String, + specAsParameter: List[JsObject], + specAsProperty: Option[JsObject], + override val required: Boolean = true, + override val nullable: Option[Boolean] = None, + override val default: Option[JsValue] = None, + override val 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/domain/parameter/GenSwaggerParameter.scala b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/GenSwaggerParameter.scala new file mode 100644 index 00000000..e41b8931 --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/GenSwaggerParameter.scala @@ -0,0 +1,40 @@ +package com.iheart.playSwagger.domain.parameter + +import play.api.libs.json.JsValue + +final case class GenSwaggerParameter private ( + override val name: String, + override val required: Boolean, + override val description: Option[String] = None, + referenceType: Option[String] = None, + `type`: Option[String] = None, + format: Option[String] = None, + override val nullable: Option[Boolean] = None, + override val default: Option[JsValue] = None, + example: Option[JsValue] = None, + items: Option[SwaggerParameter] = None, + enum: Option[Seq[String]] = None +) extends SwaggerParameter { + override def update(_required: Boolean, _nullable: Boolean, _default: Option[JsValue]): GenSwaggerParameter = + copy(required = _required, nullable = Some(_nullable), default = _default) +} + +object GenSwaggerParameter { + def apply( + `type`: String, + format: Option[String], + enum: Option[Seq[String]] + )(implicit name: String, default: Option[JsValue], description: Option[String]): GenSwaggerParameter = + new GenSwaggerParameter( + name = name, + `type` = Some(`type`), + format = format, + required = default.isEmpty, + default = default, + enum = enum, + description = description + ) + + def apply(name: String, `type`: String): GenSwaggerParameter = + new GenSwaggerParameter(name = name, required = true, `type` = Some(`type`)) +} diff --git a/core/src/main/scala/com/iheart/playSwagger/domain/parameter/SwaggerParameter.scala b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/SwaggerParameter.scala new file mode 100644 index 00000000..4a8800bd --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/SwaggerParameter.scala @@ -0,0 +1,18 @@ +package com.iheart.playSwagger.domain.parameter + +import play.api.libs.json.JsValue + +/** [[https://swagger.io/specification/?sbsearch=-schema%20-object#parameter-object Parameter Object]] */ +trait SwaggerParameter { + def name: String + + 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 +} diff --git a/core/src/main/scala/com/iheart/playSwagger/domain/parameter/SwaggerParameterWriter.scala b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/SwaggerParameterWriter.scala new file mode 100644 index 00000000..ceaba0e2 --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/domain/parameter/SwaggerParameterWriter.scala @@ -0,0 +1,95 @@ +package com.iheart.playSwagger.domain.parameter + +import play.api.libs.functional.syntax.{toFunctionalBuilderOps, unlift} +import play.api.libs.json._ +class SwaggerParameterWriter(swaggerV3: Boolean) { + + private val nullableName: String = if (swaggerV3) "nullable" else "x-nullable" + + private val under: JsPath = if (swaggerV3) __ \ "schema" else __ + + val referencePrefix: String = if (swaggerV3) "#/components/schemas/" else "#/definitions/" + + private lazy val propWrites: Writes[SwaggerParameter] = Writes { + case g: GenSwaggerParameter => genPropWrites.writes(g) + case c: CustomSwaggerParameter => customPropWrites.writes(c) + } + + private val customPropWrites: Writes[CustomSwaggerParameter] = Writes { cwp => + (__ \ "default").writeNullable[JsValue].writes(cwp.default) ++ + (__ \ nullableName).writeNullable[Boolean].writes(cwp.nullable) ++ + (cwp.specAsProperty orElse cwp.specAsParameter.headOption).getOrElse(Json.obj()) + } + + def customParamWrites(csp: CustomSwaggerParameter): List[JsObject] = { + csp.specAsParameter match { + case head :: tail => + val w = ( + (__ \ 'name).write[String] ~ + (__ \ 'required).write[Boolean] ~ + (under \ nullableName).writeNullable[Boolean] ~ + (under \ 'default).writeNullable[JsValue] + )((c: CustomSwaggerParameter) => (c.name, c.required, c.nullable, c.default)) + (w.writes(csp) ++ withPrefix(head)) :: tail + // 要素が1つの場合は `elem :: Nil` になるので残りは `Nil` のみ + case Nil => Nil + } + } + + private def withPrefix(input: JsObject): JsObject = { + if (swaggerV3) Json.obj("schema" -> input) else input + } + + private val refWrite: Writes[String] = Writes { (refType: String) => + Json.obj("$ref" -> JsString(referencePrefix + refType)) + } + + val genParamWrites: OWrites[GenSwaggerParameter] = { + ( + (__ \ "name").write[String] ~ + (__ \ "required").write[Boolean] ~ + (__ \ "description").writeNullable[String] ~ + // referenceType は `schema: $ref: ` という表記になる + (__ \ "schema").writeNullable[String](refWrite) ~ + (under \ "type").writeNullable[String] ~ + (under \ "format").writeNullable[String] ~ + (under \ nullableName).writeNullable[Boolean] ~ + (under \ "default").writeNullable[JsValue] ~ + (under \ "example").writeNullable[JsValue] ~ + (under \ "items").writeNullable[SwaggerParameter](propWrites) ~ + (under \ "enum").writeNullable[Seq[String]] + )(unlift(GenSwaggerParameter.unapply)) + } + + private val genPropWrites: Writes[GenSwaggerParameter] = { + + val writesBuilder = (__ \ "type").writeNullable[String] ~ + (__ \ "format").writeNullable[String] ~ + (__ \ nullableName).writeNullable[Boolean] ~ + (__ \ "default").writeNullable[JsValue] ~ + (__ \ "example").writeNullable[JsValue] ~ + (__ \ "$ref").writeNullable[String] ~ + (__ \ "items").lazyWriteNullable[SwaggerParameter](propWrites) ~ + (__ \ "enum").writeNullable[Seq[String]] ~ + (__ \ "description").writeNullable[String] + + writesBuilder { p => + Tuple9( + _1 = p.`type`, + _2 = p.format, + _3 = p.nullable, + _4 = p.default, + _5 = p.example, + _6 = p.referenceType.map(referencePrefix + _), + _7 = p.items, + _8 = p.enum, + _9 = p.description + ) + } + } + + implicit val propertiesWriter: Writes[Seq[SwaggerParameter]] = Writes[Seq[SwaggerParameter]] { ps => + JsObject(ps.map(p => p.name -> Json.toJson(p)(propWrites))) + } + +} diff --git a/core/src/main/scala/com/iheart/playSwagger/exception/MissingBaseSpecException.scala b/core/src/main/scala/com/iheart/playSwagger/exception/MissingBaseSpecException.scala new file mode 100644 index 00000000..cd9a1e4e --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/exception/MissingBaseSpecException.scala @@ -0,0 +1,4 @@ +package com.iheart.playSwagger.exception + +class MissingBaseSpecException(baseSpecFileName: String) + extends Exception(s"Missing a $baseSpecFileName.yml or $baseSpecFileName.json to provide base swagger spec") diff --git a/core/src/main/scala/com/iheart/playSwagger/exception/RoutesParseException.scala b/core/src/main/scala/com/iheart/playSwagger/exception/RoutesParseException.scala new file mode 100644 index 00000000..da69b703 --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/exception/RoutesParseException.scala @@ -0,0 +1,25 @@ +package com.iheart.playSwagger.exception + +import com.iheart.playSwagger.exception.RoutesParseException.RoutesParseErrorDetail + +class RoutesParseException(errors: Seq[RoutesParseErrorDetail]) extends RuntimeException( + errors.map { error => + val caret = error.column.map(c => (" " * (c - 1)) + "^").getOrElse("") + // line Number がある場合は ":" と共に表記する + val lineNumberText = error.lineNumber.fold("")(n => f":$n") + s"""|Error parsing routes file: ${error.sourceFileName}$lineNumberText ${error.message} + |${error.content.fold("")(_)} + |$caret + |""".stripMargin + }.mkString("\n") + ) + +object RoutesParseException { + case class RoutesParseErrorDetail( + sourceFileName: String, + message: String, + content: Option[String] = None, + lineNumber: Option[Int] = None, + column: Option[Int] = None + ) +} diff --git a/core/src/main/scala/com/iheart/playSwagger/DefinitionGenerator.scala b/core/src/main/scala/com/iheart/playSwagger/generator/DefinitionGenerator.scala similarity index 82% rename from core/src/main/scala/com/iheart/playSwagger/DefinitionGenerator.scala rename to core/src/main/scala/com/iheart/playSwagger/generator/DefinitionGenerator.scala index 407ee6a6..7db81d9c 100644 --- a/core/src/main/scala/com/iheart/playSwagger/DefinitionGenerator.scala +++ b/core/src/main/scala/com/iheart/playSwagger/generator/DefinitionGenerator.scala @@ -1,14 +1,15 @@ -package com.iheart.playSwagger +package com.iheart.playSwagger.generator import scala.collection.JavaConverters import scala.meta.internal.parsers.ScaladocParser -import scala.meta.internal.{Scaladoc => iScaladoc} +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 com.iheart.playSwagger.ParametricType +import com.iheart.playSwagger.domain.Definition +import com.iheart.playSwagger.domain.parameter.{GenSwaggerParameter, SwaggerParameter} import net.steppschuh.markdowngenerator.MarkdownElement import net.steppschuh.markdowngenerator.link.Link import net.steppschuh.markdowngenerator.table.Table @@ -18,17 +19,16 @@ import net.steppschuh.markdowngenerator.text.heading.Heading import play.routes.compiler.Parameter final case class DefinitionGenerator( - modelQualifier: DomainModelQualifier = PrefixDomainModelQualifier(), - mappings: CustomMappings = Nil, + mapper: SwaggerParameterMapper, swaggerPlayJava: Boolean = false, _mapper: ObjectMapper = new ObjectMapper(), - namingStrategy: NamingStrategy = NamingStrategy.None, + namingConvention: NamingConvention = NamingConvention.None, embedScaladoc: Boolean = false )(implicit cl: ClassLoader) { private val refinedTypePattern = raw"(eu\.timepit\.refined\.api\.Refined(?:\[.+])?)".r - def dealiasParams(t: Type): Type = { + private def dealiasParams(t: Type): Type = { t.toString match { case refinedTypePattern(_) => t.typeArgs.headOption.getOrElse(t) case _ => @@ -100,7 +100,7 @@ final case class DefinitionGenerator( fields.map { field: Symbol => // TODO: find a better way to get the string representation of typeSignature - val name = namingStrategy(field.name.decodedName.toString) + val name = namingConvention(field.name.decodedName.toString) val rawTypeName = dealiasParams(field.typeSignature).toString match { case refinedTypePattern(_) => field.info.dealias.typeArgs.head.toString @@ -109,7 +109,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, paramDescriptions.get(field.name.decodedName.toString)) + mapper.mapParam(param, paramDescriptions.get(field.name.decodedName.toString)) } } @@ -119,7 +119,7 @@ final case class DefinitionGenerator( ) } - private def definitionForPOJO(tpe: Type): Seq[Domain.SwaggerParameter] = { + private def definitionForPOJO(tpe: Type): Seq[SwaggerParameter] = { val m = runtimeMirror(cl) val clazz = m.runtimeClass(tpe.typeSymbol.asClass) val `type` = _mapper.constructType(clazz) @@ -142,7 +142,7 @@ final case class DefinitionGenerator( generalTypeName } val param = Parameter(name, typeName, None, None) - mapParam(param, modelQualifier, mappings) + mapper.mapParam(param, None) } } @@ -166,9 +166,9 @@ final case class DefinitionGenerator( case None => val thisDef = definition(defName) val refNames: Seq[String] = for { - p ← thisDef.properties.collect(genSwaggerParameter) - className ← findRefTypes(p) - if modelQualifier.isModel(className) + p <- thisDef.properties.collect(genSwaggerParameter) + className <- findRefTypes(p) + if mapper.isReference(className) } yield className refNames.foldLeft(thisDef :: memo) { (foundDefs, refName) => @@ -185,28 +185,24 @@ final case class DefinitionGenerator( object DefinitionGenerator { def apply( - domainNameSpace: String, - customParameterTypeMappings: CustomMappings, + mapper: SwaggerParameterMapper, swaggerPlayJava: Boolean, - namingStrategy: NamingStrategy + namingConvention: NamingConvention )(implicit cl: ClassLoader): DefinitionGenerator = - DefinitionGenerator( - PrefixDomainModelQualifier(domainNameSpace), - customParameterTypeMappings, + new DefinitionGenerator( + mapper, swaggerPlayJava, - namingStrategy = namingStrategy + namingConvention = namingConvention ) def apply( - domainNameSpace: String, - customParameterTypeMappings: CustomMappings, - namingStrategy: NamingStrategy, + mapper: SwaggerParameterMapper, + namingConvention: NamingConvention, embedScaladoc: Boolean )(implicit cl: ClassLoader): DefinitionGenerator = - DefinitionGenerator( - PrefixDomainModelQualifier(domainNameSpace), - customParameterTypeMappings, - namingStrategy = namingStrategy, + new DefinitionGenerator( + mapper = mapper, + namingConvention = namingConvention, embedScaladoc = embedScaladoc ) } diff --git a/core/src/main/scala/com/iheart/playSwagger/DomainModelQualifier.scala b/core/src/main/scala/com/iheart/playSwagger/generator/DomainModelQualifier.scala similarity index 51% rename from core/src/main/scala/com/iheart/playSwagger/DomainModelQualifier.scala rename to core/src/main/scala/com/iheart/playSwagger/generator/DomainModelQualifier.scala index 65fb6e17..a44629fc 100644 --- a/core/src/main/scala/com/iheart/playSwagger/DomainModelQualifier.scala +++ b/core/src/main/scala/com/iheart/playSwagger/generator/DomainModelQualifier.scala @@ -1,9 +1,12 @@ -package com.iheart.playSwagger +package com.iheart.playSwagger.generator trait DomainModelQualifier { + + /** あるクラスがドメインモデルとして定義されているかを確認する */ def isModel(className: String): Boolean } +/** パッケージ名のリストを用いてドメインモデルかどうかを判別する */ final case class PrefixDomainModelQualifier(namespaces: String*) extends DomainModelQualifier { def isModel(className: String): Boolean = namespaces exists className.startsWith } diff --git a/core/src/main/scala/com/iheart/playSwagger/generator/NamingConvention.scala b/core/src/main/scala/com/iheart/playSwagger/generator/NamingConvention.scala new file mode 100644 index 00000000..b6fd4b68 --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/generator/NamingConvention.scala @@ -0,0 +1,33 @@ +package com.iheart.playSwagger.generator + +import scala.util.matching.Regex + +sealed abstract class NamingConvention(f: String => String) extends (String => String) { + override def apply(keyName: String): String = f(keyName) +} + +object NamingConvention { + private val regex: Regex = "[A-Z\\d]".r + private val skipNumberRegex: Regex = "[A-Z]".r + + object None extends NamingConvention(identity) + object SnakeCase extends NamingConvention(x => regex.replaceAllIn(x, { m => "_" + m.group(0).toLowerCase() })) + object KebabCase extends NamingConvention(x => regex.replaceAllIn(x, { m => "-" + m.group(0).toLowerCase() })) + object LowerCase extends NamingConvention(x => regex.replaceAllIn(x, { m => m.group(0).toLowerCase() })) + object UpperCamelCase extends NamingConvention(x => { + val (head, tail) = x.splitAt(1) + head.toUpperCase() + tail + }) + object SnakeCaseSkipNumber extends NamingConvention(x => + skipNumberRegex.replaceAllIn(x, { m => "_" + m.group(0).toLowerCase() }) + ) + + def fromString(naming: String): NamingConvention = naming match { + case "snake_case" => SnakeCase + case "snake_case_skip_number" => SnakeCaseSkipNumber + case "kebab-case" => KebabCase + case "lowercase" => LowerCase + case "UpperCamelCase" => UpperCamelCase + case _ => None + } +} diff --git a/core/src/main/scala/com/iheart/playSwagger/ResourceReader.scala b/core/src/main/scala/com/iheart/playSwagger/generator/ResourceReader.scala similarity index 92% rename from core/src/main/scala/com/iheart/playSwagger/ResourceReader.scala rename to core/src/main/scala/com/iheart/playSwagger/generator/ResourceReader.scala index 3d36e703..39aab986 100644 --- a/core/src/main/scala/com/iheart/playSwagger/ResourceReader.scala +++ b/core/src/main/scala/com/iheart/playSwagger/generator/ResourceReader.scala @@ -1,4 +1,4 @@ -package com.iheart.playSwagger +package com.iheart.playSwagger.generator import java.io.{IOException, InputStream} diff --git a/core/src/main/scala/com/iheart/playSwagger/generator/SwaggerParameterMapper.scala b/core/src/main/scala/com/iheart/playSwagger/generator/SwaggerParameterMapper.scala new file mode 100644 index 00000000..b989fa3b --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/generator/SwaggerParameterMapper.scala @@ -0,0 +1,272 @@ +package com.iheart.playSwagger.generator + +import scala.reflect.runtime.universe +import scala.util.Try +import scala.util.matching.Regex + +import com.iheart.playSwagger.domain.CustomTypeMapping +import com.iheart.playSwagger.domain.parameter.{CustomSwaggerParameter, GenSwaggerParameter, SwaggerParameter} +import play.api.libs.json._ +import play.routes.compiler.Parameter + +class SwaggerParameterMapper( + customMappings: Seq[CustomTypeMapping] = Nil, + val modelQualifier: DomainModelQualifier +) { + + type MappingFunction = PartialFunction[String, SwaggerParameter] + + def mapParam( + parameter: Parameter, + description: Option[String] + )(implicit cl: ClassLoader): SwaggerParameter = { + val typeName = removeKnownPrefixes(parameter.typeName) + mapParam( + typeName, + parameter.name, + parameter.default.map(defaultValueO(_, typeName)), + description + ) + } + + private def mapParam( + typeName: String, + name: String, + default: Option[JsValue], + description: Option[String] = None + )(implicit cl: ClassLoader): SwaggerParameter = { + val tpe = removeKnownPrefixes(typeName) + implicit val implicitName: String = name + implicit val implicitDefault: Option[JsValue] = default + implicit val implicitDescription: Option[String] = description + // sequence of this list is the sequence of matching, that is, of importance + List( + optionalParamMF, + itemsParamMF, + customMappingMF, + enumParamMF, + referenceParamMF, + generalParamMF + ).reduce(_ orElse _)(tpe) + } + + /* Mapper 内で直接参照されるパッケージのうち、標準で定義されているクラスのパッケージ名を削除 */ + private def removeKnownPrefixes(name: String): String = + name.replaceAll("^((scala\\.)|(java\\.lang\\.)|(java\\.util\\.)|(math\\.)|(org\\.joda\\.time\\.))", "") + + /** + * 単一型パラメータのジェネリクスが指定された場合に、型パラメータを取り出す + * + * @param higherOrder ジェネリッククラス + * @param typeName ジェネリクスの型情報 + * @param pkgPattern ジェネリッククラスのパッケージのパターン + * @return 型パラメータの名前 + */ + private def higherOrderType(higherOrder: String, typeName: String, pkgPattern: Option[String]): Option[String] = { + (s"^${pkgPattern.map(p => s"(?:$p\\.)?").getOrElse("")}$higherOrder\\[(\\S+)\\]").r + .findFirstMatchIn(typeName) + .map(_.group(1)) + } + + /** typeName にコレクションが渡された際、要素の型を返却する */ + private def collectionItemType(typeName: String): Option[String] = + List("Seq", "List", "Set", "Vector") + .map(higherOrderType(_, typeName, Some("collection(?:\\.(?:mutable|immutable))?"))) + .reduce(_ orElse _) + + private def defaultValueO(default: String, typeName: String): JsValue = { + if (default.equals("null")) { + JsNull + } else { + typeName match { + // Java の場合は int, Scala の場合は Int という命名になっているため、区別しない + case ci"Int" | ci"Long" => JsNumber(default.toLong) + case ci"Double" | ci"Float" | ci"BigDecimal" => JsNumber(default.toDouble) + case ci"Boolean" => JsBoolean(default.toBoolean) + case ci"String" => + // router では `func(value ?= "default value")` 形式で定義されるため、 `"` を削除する + val unquotedString = default match { + case c if c.startsWith("\"\"\"") && c.endsWith("\"\"\"") => c.substring(3, c.length - 3) + case c if c.startsWith("\"") && c.endsWith("\"") => c.substring(1, c.length - 1) + case c => c + } + JsString(unquotedString) + case _ => JsString(default) + } + } + } + + private def generalParamMF( + implicit name: String, + default: Option[JsValue], + description: Option[String] + ): MappingFunction = { + case ci"Int" | ci"Integer" => GenSwaggerParameter("integer", Some("int32"), None) + case ci"Long" => GenSwaggerParameter("integer", Some("int64"), None) + case ci"Double" | ci"BigDecimal" => GenSwaggerParameter("number", Some("double"), None) + case ci"Float" => GenSwaggerParameter("number", Some("float"), None) + case ci"DateTime" => GenSwaggerParameter("integer", Some("epoch"), None) + case ci"java.time.Instant" => GenSwaggerParameter("string", Some("date-time"), None) + case ci"java.time.LocalDate" => GenSwaggerParameter("string", Some("date"), None) + case ci"java.time.LocalDateTime" => GenSwaggerParameter("string", Some("date-time"), None) + case ci"java.time.Duration" => GenSwaggerParameter(`type` = "string", None, None) + case ci"Any" => GenSwaggerParameter(`type` = "any", None, None).copy(example = Some(JsString("any JSON value"))) + case unknown => GenSwaggerParameter(`type` = unknown.toLowerCase(), None, None) + } + + private def enumParamMF( + implicit name: String, + default: Option[JsValue], + description: Option[String], + cl: ClassLoader + ): MappingFunction = { + case JavaEnum(enumConstants) => GenSwaggerParameter(`type` = "string", format = None, enum = Option(enumConstants)) + case ScalaEnum(enumConstants) => GenSwaggerParameter(`type` = "string", format = None, enum = Option(enumConstants)) + case EnumeratumEnum(enumConstants) => + GenSwaggerParameter(`type` = "string", format = None, enum = Option(enumConstants)) + } + + /** + * Unapply the type by name and return the Java enum constants if those exist. + */ + private object JavaEnum { + def unapply(tpeName: String)(implicit cl: ClassLoader): Option[Seq[String]] = { + Try(cl.loadClass(tpeName)).toOption.filter(_.isEnum).map(_.getEnumConstants.map(_.toString)) + } + } + + /** + * Unapply the type by name and return the Scala enum constants if those exist. + * see: [[https://github.com/iheartradio/play-swagger/pull/125]] + */ + private object ScalaEnum { + def unapply(tpeName: String)(implicit cl: ClassLoader): Option[Seq[String]] = { + if (tpeName.endsWith(".Value")) { + Try { + val mirror = universe.runtimeMirror(cl) + val module = mirror.reflectModule(mirror.staticModule(tpeName.stripSuffix(".Value"))) + for { + enum <- Option(module.instance).toSeq if enum.isInstanceOf[Enumeration] + value <- enum.asInstanceOf[Enumeration].values.asInstanceOf[Iterable[Enumeration#Value]] + } yield value.toString + }.toOption.filterNot(_.isEmpty) + } else None + } + } + + /** + * Unapply the type by name and return the Enumeratum enum constants if those exist. + */ + private object EnumeratumEnum { + def unapply(className: String): Option[Seq[String]] = { + (for { + clazz <- Try(Class.forName(className + "$")) + singleton <- Try(clazz.getField("MODULE$").get(clazz)) + values <- Try(singleton.getClass.getDeclaredField("values")) + _ = values.setAccessible(true) + entries <- Try(values + .get(singleton) + .asInstanceOf[Vector[_]] + .map { item => + val entryName = Try( + item.getClass.getMethod("entryName") + ).getOrElse(item.getClass.getMethod("value")) + entryName.setAccessible(true) + entryName.invoke(item).asInstanceOf[String] + } + .toList) + } yield entries).toOption + } + } + + private def referenceParamMF(implicit name: String): MappingFunction = { + case tpe if isReference(tpe) => referenceParam(tpe) + } + + def isReference(tpeName: String): Boolean = modelQualifier.isModel(tpeName) + + private def referenceParam(referenceType: String)(implicit name: String): GenSwaggerParameter = + GenSwaggerParameter(name = name, required = true, referenceType = Some(referenceType)) + + private def optionalParamMF( + implicit name: String, + default: Option[JsValue], + description: Option[String], + cl: ClassLoader + ): MappingFunction = { + case tpe if higherOrderType("Option", tpe, None).isDefined => + optionalParam(higherOrderType("Option", tpe, None).get) + } + + private def optionalParam(optionalTpe: String)( + implicit name: String, + default: Option[JsValue], + description: Option[String], + cl: ClassLoader + ): SwaggerParameter = { + val asRequired = mapParam( + typeName = optionalTpe, + name = name, + default = default.flatMap { + // If `Some("None")`, then `variable: Option[T] ? = None` is specified. So `default` is treated as if it does not exist. + case JsString("None") => None + case json => Some(json) + }, + description = description + ) + asRequired.update(required = false, nullable = true, default = asRequired.default) + } + + private def itemsParamMF( + implicit name: String, + default: Option[JsValue], + description: Option[String], + cl: ClassLoader + ): MappingFunction = { + case tpe if collectionItemType(tpe).isDefined => + // TODO: This could use a different type to represent ItemsObject(http://swagger.io/specification/#itemsObject), + // since the structure is not quite the same, and still has to be handled specially in a json transform (see propWrites in SwaggerSpecGenerator) + // However, that spec conflicts with example code elsewhere that shows other fields in the object, such as properties: + // http://stackoverflow.com/questions/26206685/how-can-i-describe-complex-json-model-in-swagger + updateOnlyGenParam(generalParamMF.apply("array"))(_.copy( + items = Some( + mapParam( + typeName = collectionItemType(tpe).get, + name = name, + default = default, + description = description + ) + ) + )) + } + + private def updateOnlyGenParam(param: SwaggerParameter)(update: GenSwaggerParameter => GenSwaggerParameter) + : SwaggerParameter = + param match { + case p: GenSwaggerParameter => update(p) + case _ => param + } + + private def customMappingMF(implicit name: String, default: Option[JsValue]): MappingFunction = + customMappings.map { mapping => + val re = StringContext(removeKnownPrefixes(mapping.`type`)).ci + val mf: MappingFunction = { + case re() => + CustomSwaggerParameter( + name, + mapping.specAsParameter, + mapping.specAsProperty, + default = default, + required = default.isEmpty && mapping.required + ) + } + mf + } + // mapping を全てチェックする + .foldLeft[MappingFunction](PartialFunction.empty)(_ orElse _) + + implicit class CaseInsensitiveRegex(sc: StringContext) { + def ci: Regex = ("(?i)" + sc.parts.mkString).r + } + +} diff --git a/core/src/main/scala/com/iheart/playSwagger/generator/SwaggerSpecGenerator.scala b/core/src/main/scala/com/iheart/playSwagger/generator/SwaggerSpecGenerator.scala new file mode 100644 index 00000000..8f38361d --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/generator/SwaggerSpecGenerator.scala @@ -0,0 +1,448 @@ +package com.iheart.playSwagger.generator + +import java.io.File + +import scala.collection.immutable.ListMap +import scala.collection.mutable +import scala.util.{Failure, Success, Try} + +import com.iheart.playSwagger.OutputTransformer.SimpleOutputTransformer +import com.iheart.playSwagger._ +import com.iheart.playSwagger.domain.parameter.{CustomSwaggerParameter, GenSwaggerParameter, SwaggerParameterWriter} +import com.iheart.playSwagger.domain.{CustomTypeMapping, Definition} +import com.iheart.playSwagger.exception.RoutesParseException.RoutesParseErrorDetail +import com.iheart.playSwagger.exception.{MissingBaseSpecException, RoutesParseException} +import com.iheart.playSwagger.generator.ResourceReader.read +import com.iheart.playSwagger.generator.SwaggerSpecGenerator._ +import com.iheart.playSwagger.generator.YAMLParser.parseYaml +import com.iheart.playSwagger.util.ExtendJsValue.JsObjectUpdate +import play.api.libs.json.JsValue.jsValueToJsLookup +import play.api.libs.json._ +import play.routes.compiler._ + +object SwaggerSpecGenerator { + private val defaultRoutesFile = "routes" + private val routesExt = ".routes" + private val skipFileHeader = "### SkipFileForDocs ###" + private val swaggerCommentMarker = "##" + private val skipPathCommentRegex = ("##\\s*NoDocs\\s*##").r + private val customMappingsFileName = "swagger-custom-mappings" + + /** $ref */ + private val refKey = "$ref" + private val baseSpecFileName = "swagger" + + def apply(namingConvention: NamingConvention, swaggerV3: Boolean, domainNameSpaces: String*)(implicit + cl: ClassLoader): SwaggerSpecGenerator = { + SwaggerSpecGenerator( + namingConvention = namingConvention, + modelQualifier = PrefixDomainModelQualifier(domainNameSpaces: _*), + swaggerV3 = swaggerV3 + ) + } + + def apply( + namingConvention: NamingConvention, + outputTransformers: Seq[OutputTransformer], + domainNameSpaces: String* + )(implicit cl: ClassLoader): SwaggerSpecGenerator = { + SwaggerSpecGenerator( + namingConvention = namingConvention, + modelQualifier = PrefixDomainModelQualifier(domainNameSpaces: _*), + outputTransformers = outputTransformers + ) + } + + def apply(swaggerV3: Boolean, operationIdFully: Boolean, embedScaladoc: Boolean, domainNameSpaces: String*)(implicit + cl: ClassLoader): SwaggerSpecGenerator = { + SwaggerSpecGenerator( + namingConvention = NamingConvention.None, + modelQualifier = PrefixDomainModelQualifier(domainNameSpaces: _*), + swaggerV3 = swaggerV3, + operationIdFully = operationIdFully, + embedScaladoc = embedScaladoc + ) + } + + def apply(outputTransformers: Seq[OutputTransformer], domainNameSpaces: String*)(implicit + cl: ClassLoader): SwaggerSpecGenerator = { + SwaggerSpecGenerator( + namingConvention = NamingConvention.None, + modelQualifier = PrefixDomainModelQualifier(domainNameSpaces: _*), + outputTransformers = outputTransformers + ) + } + +} + +/** + * @param namingConvention 命名規則 (snake_case, camelCase など) + * @param modelQualifier ドメインモデル判定器 + * @param defaultPostBodyFormat 未記載の場合の Post レスポンスボディの MIME TYPE + * @param apiVersion 対象 API のバージョン + * @param operationIdFully API の名前にパッケージ名を利用するか + */ +final case class SwaggerSpecGenerator( + namingConvention: NamingConvention = NamingConvention.None, + modelQualifier: DomainModelQualifier = PrefixDomainModelQualifier(), + defaultPostBodyFormat: String = "application/json", + outputTransformers: Seq[OutputTransformer] = Nil, + swaggerV3: Boolean = false, + swaggerPlayJava: Boolean = false, + apiVersion: Option[String] = None, + operationIdFully: Boolean = false, + embedScaladoc: Boolean = false +)(implicit cl: ClassLoader) { + + private val parameterWriter = new SwaggerParameterWriter(swaggerV3) + + private def readYmlOrJson[T: Reads](fileName: String): Option[T] = { + readCfgFile[T](s"$fileName.json") orElse readCfgFile[T](s"$fileName.yml") + } + + private lazy val customMappings: Seq[CustomTypeMapping] = { + readYmlOrJson[Seq[CustomTypeMapping]](customMappingsFileName).getOrElse(Nil) + } + + private lazy val swaggerParameterMapper = new SwaggerParameterMapper(customMappings, modelQualifier) + private lazy val definitionGenerator = DefinitionGenerator( + mapper = swaggerParameterMapper, + swaggerPlayJava = swaggerPlayJava, + namingConvention = namingConvention, + embedScaladoc = embedScaladoc + ) + + // routes with their prefix + type Routes = (Path, Seq[Route]) + type Tag = String + type Path = String + + // Mapping of the tag, which is the file the routes were read from, and the optional prefix if it was + // included from another router. ListMap is used to maintain the original definition order + type RoutesData = Try[ListMap[Tag, Routes]] + + def generate(routesFile: String = defaultRoutesFile): Try[JsObject] = { + val base = apiVersion.fold(defaultBase) { v => + // version を base json にマージする + Json.obj("info" -> Json.obj("version" -> v)) deepMerge defaultBase + } + generateFromRoutesFile(routesFile = routesFile, base = base) + } + + /** .routes を除いたファイル名がタグ名となる */ + private def tagFromFile(fileName: String): Tag = fileName.replace(routesExt, "") + + private def loop(path: Path, routesFile: String): RoutesData = { + // TODO: better error handling + ResourceReader.read(routesFile).flatMap { lines => + lines.headOption match { + // ドキュメントの第一行に設定が記載されている場合はスキップする + case Some(SwaggerSpecGenerator.skipFileHeader) => Success(ListMap.empty) + case _ => + val content = lines.mkString("\n") + + // artificial file to conform to api, used by play for error reporting + val file = new File(routesFile) + + RoutesFileParser.parseContent(content, file).fold( + // パースに失敗した場合 + { errors => + val detail = errors.map { error => + val lineNumber = error.line + val column = error.column + val errorLine = lineNumber.flatMap(line => lines.lift(line - 1)) + RoutesParseErrorDetail(error.source.getName, error.message, errorLine, lineNumber, column) + } + Failure(new RoutesParseException(detail)) + }, + { rules: Seq[Rule] => + val tag = tagFromFile(routesFile) + val init: RoutesData = Success(ListMap(tag -> (path, Seq.empty))) + rules.foldLeft(init) { + // Route 内に直接 API 定義がある場合 + case (Success(routesData), route: Route) => + // 定義済みの routes 情報とマージする + // 例えば、1回目の実行では `routes` ファイルの内容が展開されるため、 prefix には " " が代入される + val (prefix, routes) = routesData(tag) + Success(routesData + (tag -> (prefix, routes :+ route))) + // 他の Routes ファイルへの参照がある場合 + case (Success(routesData), Include(prefix, router)) => + val referenceFile = router.replace(".Routes", ".routes") + val isIncludedRoutesFile = cl.getResource(referenceFile) != null + if (!isIncludedRoutesFile) { + Success(routesData) + } else { + // routes ファイルが入れ子になった場合、親の path とそのファイルの path をマージする + val updatedPath = if (path.nonEmpty) path + "/" + prefix else prefix + loop(updatedPath, referenceFile).map(routesData ++ _) + } + // 失敗した場合はそこで中断 + case (l: Failure[_], _) => l + } + } + ) + } + } + } + + private[generator] def generateFromRoutesFile( + routesFile: String, + base: JsObject + ): Try[JsObject] = { + + // starts with empty prefix, assuming that the routesFile is the outermost (usually 'routes') + loop("", routesFile).flatMap { data => + val result: JsObject = generateFromRoutes(data, base) + val initial = SimpleOutputTransformer(Success[JsObject]) + val mapper = outputTransformers.foldLeft[OutputTransformer](initial)(_ >=> _) + mapper(result) + } + } + + /** + * Generate directly from routes + * + * @param routes [[Route]]s compiled by Play routes compiler + * @param base swagger.yaml に記載された基本設定 + */ + private def generateFromRoutes(routes: ListMap[Tag, (String, Seq[Route])], base: JsObject): JsObject = { + val docs = routes.map { + case (tag, (path, routes)) => + tag -> paths(routes, path, Some(tag)) + }.filter(_._2.keys.nonEmpty) + generateWithBase(docs, base) + } + + /** 基本の swagger.yaml とマージして、 #/definitions で参照される case class から定義を生成する */ + private[playSwagger] def generateWithBase( + paths: ListMap[Tag, JsObject], + baseJson: JsObject = Json.obj() + ): JsObject = { + + // 1つの Json にまとめる + val pathsJson = paths.values.reduce((acc, p) => JsObject(acc.fields ++ p.fields)) + + // $ref: として定義されている名前を一覧で取得する + val mainRefs = (pathsJson ++ baseJson) \\ refKey + + // swagger-custom-mappings.yaml で指定される $ref: の一覧を取得する + val customMappingRefs = for { + customMapping <- customMappings + mappingsJson = customMapping.specAsProperty.toSeq ++ customMapping.specAsParameter + ref <- mappingsJson.flatMap(_ \\ refKey) + } yield ref + val allRefs = mainRefs ++ customMappingRefs + + // $ref: で参照される名前から定義リストを作成する。 + val definitions: List[Definition] = { + val referredClasses: Seq[String] = for { + refJson <- allRefs.toList + ref <- refJson.asOpt[String].toList + // #/definitions を省いたものがクラス名 + className = ref.stripPrefix(parameterWriter.referencePrefix) + if modelQualifier.isModel(className) + } yield className + + definitionGenerator.allDefinitions(referredClasses) + } + + val definitionsJson = + JsObject(definitions.map(d => d.name -> Json.toJson(d)(Definition.writer(parameterWriter.propertiesWriter)))) + + val pathsAndDefinitionsJson = Json.obj( + "paths" -> pathsJson, + if (swaggerV3) { + "components" -> Json.obj( + "schemas" -> definitionsJson + ) + } else { + "definitions" -> definitionsJson + }, + // base json に tags が存在しない場合 + "tags" -> JsArray() + ) + + pathsAndDefinitionsJson.deepMerge(baseJson) + } + + private lazy val defaultBase: JsObject = + readYmlOrJson[JsObject](baseSpecFileName).getOrElse(throw new MissingBaseSpecException(baseSpecFileName)) + + private def mergeByName(base: JsArray, toMerge: JsArray): JsArray = { + JsArray(base.value.map { bs => + val name = (bs \ "name").as[String] + findByName(toMerge, name).fold(bs) { f => bs.as[JsObject] deepMerge f } + } ++ toMerge.value.filter { tm => + (tm \ "name").validate[String].fold( + { _ => true }, + { name => + findByName(base, name).isEmpty + } + ) + }) + } + + private def findByName(array: JsArray, name: String): Option[JsObject] = + array.value.find(param => (param \ "name").asOpt[String].contains(name)) + .map(_.as[JsObject]) + + private[playSwagger] def readCfgFile[T](name: String)(implicit fjs: Reads[T]): Option[T] = { + Option(cl.getResource(name)).map { url => + val st = url.openStream() + try { + val ext = url.getFile.split("\\.").last + ext match { + case "json" => Json.parse(st).as[T] + // TODO: improve error handling + case "yml" => YAMLParser.parseYaml(read(st).get.mkString("\n")) + case _ => + throw new IllegalArgumentException(s"$name has an unsupported extension. Use either json or yml. ") + } + } finally { + st.close() + } + } + } + + private def paths(routes: Seq[Route], path: String, tag: Option[Tag]): JsObject = { + JsObject { + val endPointEntries = routes.flatMap(route => endPointEntry(route, path, tag)) + + // maintain the routes order as per the original routing file + val zgbp = endPointEntries.zipWithIndex.groupBy(_._1._1) + val lhm = mutable.LinkedHashMap(zgbp.toSeq.sortBy(_._2.head._2): _*) + val gbp2 = lhm.mapValues(_.map(_._1)).toSeq + + gbp2.map(x => (x._1, x._2.map(_._2).reduce(_ deepMerge _))) + } + } + + private def endPointEntry(route: Route, path: String, tag: Option[String]): Option[(String, JsObject)] = { + + val comments = route.comments.map(_.comment).mkString("\n") + // NoDocs がついている path は無視する + if (skipPathCommentRegex.findFirstIn(comments).isDefined) { + None + } else { + val inRoutePath = route.path.parts.map { + // パスパラメータの場合は {} で囲む + case DynamicPart(name, _, _) => s"{$name}" + // StaticPart には前後の "/" が含まれる + case StaticPart(value) => value + }.mkString + val method = route.verb.value.toLowerCase + Some(fullPath(path, inRoutePath) -> Json.obj(method -> endPointSpec(route, tag))) + } + } + + /** routes ファイルとコントローラーの path をマージする */ + private[playSwagger] def fullPath(path: String, inRoutePath: String): String = { + // special case for ("/p/" , "/") or ("/p/" , "") + if (path.endsWith("/") && (inRoutePath == "/" || inRoutePath.isEmpty)) { // special case for ("/p/" , "/") or ("/p/" , "") + "/" + path.stripPrefix("/") + } else { + "/" + List( + path.stripPrefix("/").stripSuffix("/"), + inRoutePath.stripPrefix("/") + ).filterNot(_.isEmpty).mkString("/") + } + } + + // Multiple routes may have the same path, merge the objects instead of overwriting + private def endPointSpec(route: Route, tag: Option[String]) = { + // controller から parameter object の作成 + val paramsFromController = { + val pathParams = route.path.parts.collect { + case d: DynamicPart => d.name + }.toSet + + val params = for { + paramList <- route.call.parameters.toSeq + param <- paramList + if param.fixed.isEmpty && !param.isJavaRequest // Removes parameters the client cannot set + } yield swaggerParameterMapper.mapParam(param, None) + + JsArray(params.flatMap { p => + val jos: List[JsObject] = p match { + case gsp: GenSwaggerParameter => List(parameterWriter.genParamWrites.writes(gsp)) + case csp: CustomSwaggerParameter => parameterWriter.customParamWrites(csp) + } + + val in = if (pathParams.contains(p.name)) "path" else "query" + val enhance = Json.obj("in" -> in) + jos.map(enhance ++ _) + }) + } + + // コメントから parameter object の作成 + val jsonFromComment = { + val comments = route.comments.map(_.comment) + val commentDocLines = comments match { + case SwaggerSpecGenerator.swaggerCommentMarker +: docs :+ SwaggerSpecGenerator.swaggerCommentMarker => docs + case _ => Nil + } + + 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 => + JsObject(commentsJson.update(refKey) { + case JsString(v) => + val pattern = "^([^#]+)(?:#(?:/[a-zA-Z])+)?$".r + v match { + // #/definitions/ のようなものが指定されて**いない**場合はファイルへのリンクとして取得を試みる + 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 { params => + // play-swagger 仕様としてボディパラメータでの ref の使用は `name: body` が利用される + val bodyParam = findByName(params, "body") + bodyParam.fold(params) { param => + // 本来は `in: body` の後に型の定義が続く形式 + val enhancedBodyParam = Json.obj("in" -> JsString("body")) ++ param + JsArray(enhancedBodyParam +: params.value.filterNot(_ == bodyParam.get)) + } + } + + val mergedParams = mergeByName(paramsFromController, paramsFromComment.getOrElse(JsArray())) + + val parameterJson = if (mergedParams.value.nonEmpty) Json.obj("parameters" -> mergedParams) else Json.obj() + + // コントローラー名とメソッド名、もしくはメソッド名のみから operationId を取得する + val operationId = Json.obj( + "operationId" -> (if (operationIdFully) s"${route.call.controller}.${route.call.method}" else route.call.method) + ) + + // operationId, tag, parameter object, コメントから生成されたその他の情報をマージする + val rawPathJson = operationId ++ tag.fold(Json.obj()) { t => + Json.obj("tags" -> List(t)) + } ++ jsonFromComment.getOrElse(Json.obj()) ++ parameterJson + + val hasConsumes = (rawPathJson \ "consumes").toOption.isDefined + + // MIME Type の指定がない場合はデフォルトを設定する + if (findByName(mergedParams, "body").isDefined && !hasConsumes) + rawPathJson + ("consumes" -> Json.arr(defaultPostBodyFormat)) + else rawPathJson + } + + private def tryParseYaml(comment: String): Option[JsObject] = { + // The purpose here is more to ensure that it is not in other formats such as JSON + // If invalid YAML is passed, org.yaml.snakeyaml.parser.ParserException + val pattern = "^\\w+|\\$ref:".r + pattern.findFirstIn(comment).map(_ => parseYaml[JsObject](comment)) + } + + private def tryParseJson(comment: String): Option[JsObject] = + if (comment.startsWith("{")) Some(Json.parse(comment).as[JsObject]) else None + +} diff --git a/core/src/main/scala/com/iheart/playSwagger/generator/YAMLParser.scala b/core/src/main/scala/com/iheart/playSwagger/generator/YAMLParser.scala new file mode 100644 index 00000000..ba75f90c --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/generator/YAMLParser.scala @@ -0,0 +1,17 @@ +package com.iheart.playSwagger.generator + +import com.fasterxml.jackson.databind.ObjectMapper +import org.yaml.snakeyaml.Yaml +import play.api.libs.json.{Json, Reads} + +object YAMLParser { + + def parseYaml[T](document: String)(implicit fjs: Reads[T]): T = { + val yaml = new Yaml() + val map = yaml.load[T](document) + val mapper = new ObjectMapper() + val jsonString = mapper.writeValueAsString(map) + Json.parse(jsonString).as[T] + } + +} diff --git a/core/src/main/scala/com/iheart/playSwagger/package.scala b/core/src/main/scala/com/iheart/playSwagger/package.scala index 40036119..89aaf771 100644 --- a/core/src/main/scala/com/iheart/playSwagger/package.scala +++ b/core/src/main/scala/com/iheart/playSwagger/package.scala @@ -2,8 +2,6 @@ package com.iheart package object playSwagger { type Line = String - type APIName = String type Tag = String - type RoutesDocumentation = Seq[(String, String, String)] } diff --git a/core/src/main/scala/com/iheart/playSwagger/util/ExtendJsValue.scala b/core/src/main/scala/com/iheart/playSwagger/util/ExtendJsValue.scala new file mode 100644 index 00000000..7a87f370 --- /dev/null +++ b/core/src/main/scala/com/iheart/playSwagger/util/ExtendJsValue.scala @@ -0,0 +1,27 @@ +package com.iheart.playSwagger.util + +import play.api.libs.json._ + +object ExtendJsValue { + + 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)) + } + } + + 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 + } + } +} diff --git a/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala b/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala index 728ba6b2..ccf8833f 100644 --- a/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/DefinitionGeneratorSpec.scala @@ -1,6 +1,7 @@ package com.iheart.playSwagger - -import com.iheart.playSwagger.Domain._ +import com.iheart.playSwagger.domain.parameter.{CustomSwaggerParameter, GenSwaggerParameter} +import com.iheart.playSwagger.domain.{CustomTypeMapping, Definition} +import com.iheart.playSwagger.generator.{DefinitionGenerator, DomainModelQualifier, NamingConvention, PrefixDomainModelQualifier, SwaggerParameterMapper} import org.specs2.mutable.Specification import play.api.libs.json.Json @@ -56,34 +57,39 @@ object ExcludingDomainQualifier extends DomainModelQualifier { class DefinitionGeneratorSpec extends Specification { implicit val cl: ClassLoader = getClass.getClassLoader + val generalMapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier()) "definition" >> { "generate name correctly" >> { - DefinitionGenerator().definition[Foo].name === "com.iheart.playSwagger.Foo" + DefinitionGenerator(generalMapper).definition[Foo].name === "com.iheart.playSwagger.Foo" } "generate from string classname " >> { - DefinitionGenerator().definition("com.iheart.playSwagger.Foo").name === "com.iheart.playSwagger.Foo" + DefinitionGenerator(generalMapper).definition("com.iheart.playSwagger.Foo").name === "com.iheart.playSwagger.Foo" } "generate properties" >> { - + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart.playSwagger")) val result = DefinitionGenerator( - "com.iheart.playSwagger", - Nil, - NamingStrategy.None, + mapper, + NamingConvention.None, embedScaladoc = false ).definition[Foo].properties result.length === 7 "with correct string property" >> { - result.head === GenSwaggerParameter(name = "barStr", `type` = Some("string")) + result.head === GenSwaggerParameter(name = "barStr", required = true, `type` = Some("string")) } "with correct int32 property" >> { - result(1) === GenSwaggerParameter(name = "barInt", `type` = Some("integer"), format = Some("int32")) + result(1) === GenSwaggerParameter( + name = "barInt", + required = true, + `type` = Some("integer"), + format = Some("int32") + ) } "with correct optional long property" >> { @@ -97,18 +103,35 @@ class DefinitionGeneratorSpec extends Specification { } "with reference type" >> { - result(3) === GenSwaggerParameter(name = "reffedFoo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) + result(3) === GenSwaggerParameter( + name = "reffedFoo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) } "with sequence of reference type" >> { val itemsParam = - GenSwaggerParameter(name = "seqReffedFoo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) - result(4) === GenSwaggerParameter(name = "seqReffedFoo", `type` = Some("array"), items = Some(itemsParam)) + GenSwaggerParameter( + name = "seqReffedFoo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) + result(4) === GenSwaggerParameter( + name = "seqReffedFoo", + required = true, + `type` = Some("array"), + items = Some(itemsParam) + ) } "with optional sequence of reference type" >> { val itemsParam = - GenSwaggerParameter(name = "optionSeqReffedFoo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) + GenSwaggerParameter( + name = "optionSeqReffedFoo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) result(5) === GenSwaggerParameter( name = "optionSeqReffedFoo", `type` = Some("array"), @@ -122,19 +145,23 @@ class DefinitionGeneratorSpec extends Specification { "generate properties using snake case naming strategy" >> { + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart.playSwagger")) val result = - DefinitionGenerator("com.iheart.playSwagger", Nil, NamingStrategy.SnakeCase, embedScaladoc = false).definition[ - Foo - ].properties + DefinitionGenerator(mapper, NamingConvention.SnakeCase, embedScaladoc = false).definition[Foo].properties result.length === 7 "with correct string property" >> { - result.head === GenSwaggerParameter(name = "bar_str", `type` = Some("string")) + result.head === GenSwaggerParameter(name = "bar_str", required = true, `type` = Some("string")) } "with correct int32 property" >> { - result(1) === GenSwaggerParameter(name = "bar_int", `type` = Some("integer"), format = Some("int32")) + result(1) === GenSwaggerParameter( + name = "bar_int", + required = true, + `type` = Some("integer"), + format = Some("int32") + ) } "with correct optional long property" >> { @@ -148,18 +175,35 @@ class DefinitionGeneratorSpec extends Specification { } "with reference type" >> { - result(3) === GenSwaggerParameter(name = "reffed_foo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) + result(3) === GenSwaggerParameter( + name = "reffed_foo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) } "with sequence of reference type" >> { val itemsParam = - GenSwaggerParameter(name = "seq_reffed_foo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) - result(4) === GenSwaggerParameter(name = "seq_reffed_foo", `type` = Some("array"), items = Some(itemsParam)) + GenSwaggerParameter( + name = "seq_reffed_foo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) + result(4) === GenSwaggerParameter( + name = "seq_reffed_foo", + required = true, + `type` = Some("array"), + items = Some(itemsParam) + ) } "with optional sequence of reference type" >> { val itemsParam = - GenSwaggerParameter(name = "option_seq_reffed_foo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) + GenSwaggerParameter( + name = "option_seq_reffed_foo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) result(5) === GenSwaggerParameter( name = "option_seq_reffed_foo", `type` = Some("array"), @@ -173,19 +217,23 @@ class DefinitionGeneratorSpec extends Specification { "generate properties using kebab case naming strategy" >> { + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart.playSwagger")) val result = - DefinitionGenerator("com.iheart.playSwagger", Nil, NamingStrategy.KebabCase, embedScaladoc = false).definition[ - Foo - ].properties + DefinitionGenerator(mapper, NamingConvention.KebabCase, embedScaladoc = false).definition[Foo].properties result.length === 7 "with correct string property" >> { - result.head === GenSwaggerParameter(name = "bar-str", `type` = Some("string")) + result.head === GenSwaggerParameter(name = "bar-str", required = true, `type` = Some("string")) } "with correct int32 property" >> { - result(1) === GenSwaggerParameter(name = "bar-int", `type` = Some("integer"), format = Some("int32")) + result(1) === GenSwaggerParameter( + name = "bar-int", + required = true, + `type` = Some("integer"), + format = Some("int32") + ) } "with correct optional long property" >> { @@ -199,18 +247,35 @@ class DefinitionGeneratorSpec extends Specification { } "with reference type" >> { - result(3) === GenSwaggerParameter(name = "reffed-foo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) + result(3) === GenSwaggerParameter( + name = "reffed-foo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) } "with sequence of reference type" >> { val itemsParam = - GenSwaggerParameter(name = "seq-reffed-foo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) - result(4) === GenSwaggerParameter(name = "seq-reffed-foo", `type` = Some("array"), items = Some(itemsParam)) + GenSwaggerParameter( + name = "seq-reffed-foo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) + result(4) === GenSwaggerParameter( + name = "seq-reffed-foo", + required = true, + `type` = Some("array"), + items = Some(itemsParam) + ) } "with optional sequence of reference type" >> { val itemsParam = - GenSwaggerParameter(name = "option-seq-reffed-foo", referenceType = Some("com.iheart.playSwagger.ReffedFoo")) + GenSwaggerParameter( + name = "option-seq-reffed-foo", + required = true, + referenceType = Some("com.iheart.playSwagger.ReffedFoo") + ) result(5) === GenSwaggerParameter( name = "option-seq-reffed-foo", `type` = Some("array"), @@ -223,14 +288,16 @@ class DefinitionGeneratorSpec extends Specification { } "read class in Object" >> { - val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart")) + val result = DefinitionGenerator(mapper, NamingConvention.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, embedScaladoc = false).definition( + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart")) + val result = DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false).definition( "com.iheart.playSwagger.MyObject.MyInnerClass" ) @@ -241,8 +308,9 @@ class DefinitionGeneratorSpec extends Specification { } "read sequence items" >> { + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart")) val result = - DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false).definition( "com.iheart.playSwagger.FooWithSeq" ) result.properties.head.asInstanceOf[GenSwaggerParameter].items.get.asInstanceOf[ @@ -251,7 +319,8 @@ class DefinitionGeneratorSpec extends Specification { } "read primitive sequence items" >> { - val result = DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart")) + val result = DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false).definition( "com.iheart.playSwagger.WithListOfPrimitive" ) result.properties.head.asInstanceOf[GenSwaggerParameter].items.get.asInstanceOf[ @@ -261,8 +330,9 @@ class DefinitionGeneratorSpec extends Specification { } "read Optional items " >> { + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart")) val result = - DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false).definition( "com.iheart.playSwagger.FooWithOption" ) result.properties.head.asInstanceOf[GenSwaggerParameter].referenceType must beSome( @@ -272,8 +342,9 @@ class DefinitionGeneratorSpec extends Specification { "with dates" >> { "no override" >> { + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart")) val result = - DefinitionGenerator("com.iheart", Nil, NamingStrategy.None, embedScaladoc = false).definition( + DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false).definition( "com.iheart.playSwagger.WithDate" ) val prop = result.properties.head.asInstanceOf[GenSwaggerParameter] @@ -289,8 +360,9 @@ class DefinitionGeneratorSpec extends Specification { specAsParameter = customJson ) ) + val mapper = new SwaggerParameterMapper(mappings, PrefixDomainModelQualifier("com.iheart")) val result = - DefinitionGenerator("com.iheart", mappings, NamingStrategy.None, embedScaladoc = false).definition( + DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false).definition( "com.iheart.playSwagger.WithDate" ) val prop = result.properties.head.asInstanceOf[CustomSwaggerParameter] @@ -306,7 +378,8 @@ class DefinitionGeneratorSpec extends Specification { specAsParameter = customJson ) ) - val result = DefinitionGenerator("com.iheart", mappings, NamingStrategy.None, embedScaladoc = false).definition( + val mapper = new SwaggerParameterMapper(mappings, PrefixDomainModelQualifier("com.iheart")) + val result = DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false).definition( "com.iheart.playSwagger.WithOptionalDate" ) val prop = result.properties.head.asInstanceOf[CustomSwaggerParameter] @@ -321,7 +394,8 @@ class DefinitionGeneratorSpec extends Specification { `type` = "com.iheart.playSwagger.WrappedString", specAsParameter = customJson ) - val generator = DefinitionGenerator("com.iheart", List(customMapping), NamingStrategy.None, embedScaladoc = false) + val mapper = new SwaggerParameterMapper(List(customMapping), PrefixDomainModelQualifier("com.iheart")) + val generator = DefinitionGenerator(mapper, NamingConvention.None, embedScaladoc = false) val definition = generator.definition[FooWithWrappedStringProperties] "support simple property types" >> { @@ -351,7 +425,8 @@ class DefinitionGeneratorSpec extends Specification { } "allDefinitions" >> { - val allDefs = DefinitionGenerator(ExcludingDomainQualifier).allDefinitions(List("com.iheart.playSwagger.Foo")) + val mapper = new SwaggerParameterMapper(Nil, ExcludingDomainQualifier) + val allDefs = DefinitionGenerator(mapper).allDefinitions(List("com.iheart.playSwagger.Foo")) allDefs.length === 3 allDefs.find(_.name == "com.iheart.playSwagger.ReffedFoo") must beSome[Definition] allDefs.find(_.name == "com.iheart.playSwagger.RefReffedFoo") must beSome[Definition] @@ -360,19 +435,21 @@ class DefinitionGeneratorSpec extends Specification { "java class definition" >> { "generate name correctly" >> { - DefinitionGenerator().definition[Person].name === "com.iheart.playSwagger.Person" + DefinitionGenerator(generalMapper).definition[Person].name === "com.iheart.playSwagger.Person" } "generate from string classname " >> { - DefinitionGenerator().definition("com.iheart.playSwagger.Person").name === "com.iheart.playSwagger.Person" + DefinitionGenerator(generalMapper).definition( + "com.iheart.playSwagger.Person" + ).name === "com.iheart.playSwagger.Person" } "generate properties" >> { + val mapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier("com.iheart.playSwagger")) val result = DefinitionGenerator( - "com.iheart.playSwagger", - Nil, + mapper, swaggerPlayJava = true, - NamingStrategy.None + NamingConvention.None ).definition[Person].properties result.length === 16 @@ -468,9 +545,10 @@ class DefinitionGeneratorSpec extends Specification { } "with correct array property" >> { - val itemsParam = GenSwaggerParameter(name = "customKey", `type` = Some("string")) + val itemsParam = GenSwaggerParameter(name = "customKey", required = true, `type` = Some("string")) result.filter(r => r.name == "customKey").seq.head === GenSwaggerParameter( name = "customKey", + required = true, `type` = Some("array"), items = Some(itemsParam) ) @@ -496,7 +574,11 @@ class DefinitionGeneratorSpec extends Specification { "with java collection reference type" >> { val itemsParamList = - GenSwaggerParameter(name = "attributeList", referenceType = Some("com.iheart.playSwagger.Attribute")) + GenSwaggerParameter( + name = "attributeList", + required = true, + referenceType = Some("com.iheart.playSwagger.Attribute") + ) result.filter(r => r.name == "attributeList").seq.head === GenSwaggerParameter( name = "attributeList", `type` = Some("array"), @@ -506,7 +588,11 @@ class DefinitionGeneratorSpec extends Specification { ) val itemsParamSet = - GenSwaggerParameter(name = "attributeSet", referenceType = Some("com.iheart.playSwagger.Attribute")) + GenSwaggerParameter( + name = "attributeSet", + required = true, + referenceType = Some("com.iheart.playSwagger.Attribute") + ) result.filter(r => r.name == "attributeSet").seq.head === GenSwaggerParameter( name = "attributeSet", `type` = Some("array"), diff --git a/core/src/test/scala/com/iheart/playSwagger/NamingStrategySpec.scala b/core/src/test/scala/com/iheart/playSwagger/NamingStrategySpec.scala index c9bdfe1a..51ffaa19 100644 --- a/core/src/test/scala/com/iheart/playSwagger/NamingStrategySpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/NamingStrategySpec.scala @@ -1,31 +1,32 @@ package com.iheart.playSwagger +import com.iheart.playSwagger.generator.NamingConvention import org.specs2.mutable.Specification class NamingStrategySpec extends Specification { "naming strategy" >> { "none" >> { - NamingStrategy.from("none")("attributeName") must be("attributeName") + NamingConvention.fromString("none")("attributeName") must be("attributeName") } "snake_case" >> { - NamingStrategy.from("snake_case")("attributeName") must equalTo("attribute_name") + NamingConvention.fromString("snake_case")("attributeName") must equalTo("attribute_name") } "snake_case_skip_number" >> { - NamingStrategy.from("snake_case_skip_number")("attributeName1") must equalTo("attribute_name1") + NamingConvention.fromString("snake_case_skip_number")("attributeName1") must equalTo("attribute_name1") } "kebab-case" >> { - NamingStrategy.from("kebab-case")("attributeName") must equalTo("attribute-name") + NamingConvention.fromString("kebab-case")("attributeName") must equalTo("attribute-name") } "lowercase" >> { - NamingStrategy.from("lowercase")("attributeName") must equalTo("attributename") + NamingConvention.fromString("lowercase")("attributeName") must equalTo("attributename") } "UpperCamelCase" >> { - NamingStrategy.from("UpperCamelCase")("attributeName") must equalTo("AttributeName") + NamingConvention.fromString("UpperCamelCase")("attributeName") must equalTo("AttributeName") } } } diff --git a/core/src/test/scala/com/iheart/playSwagger/OutputTransformerSpec.scala b/core/src/test/scala/com/iheart/playSwagger/OutputTransformerSpec.scala index 953d14cc..bb747800 100644 --- a/core/src/test/scala/com/iheart/playSwagger/OutputTransformerSpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/OutputTransformerSpec.scala @@ -3,6 +3,7 @@ package com.iheart.playSwagger import scala.util.{Failure, Success} import com.iheart.playSwagger.OutputTransformer.SimpleOutputTransformer +import com.iheart.playSwagger.generator.{NamingConvention, PrefixDomainModelQualifier, SwaggerSpecGenerator} import org.specs2.mutable.Specification import play.api.libs.json._ @@ -84,7 +85,7 @@ class OutputTransformerSpec extends Specification { case _ => Failure(new IllegalStateException()) }) val b = SimpleOutputTransformer(OutputTransformer.traverseTransformer(_) { - case JsString(content) => Failure(new IllegalStateException("not strings")) + case JsString(_) => Failure(new IllegalStateException("not strings")) case _ => Failure(new IllegalStateException()) }) @@ -129,8 +130,8 @@ class EnvironmentVariablesIntegrationSpec extends Specification { "integration" >> { "generate api with placeholders in place" >> { val envs = Map("LAST_TRACK_DESCRIPTION" -> "Last track", "PLAYED_TRACKS_DESCRIPTION" -> "Add tracks") - val json = SwaggerSpecGenerator( - NamingStrategy.None, + val json = generator.SwaggerSpecGenerator( + NamingConvention.None, PrefixDomainModelQualifier("com.iheart"), outputTransformers = MapVariablesTransformer(envs) :: Nil ).generate("env.routes").get @@ -146,8 +147,8 @@ class EnvironmentVariablesIntegrationSpec extends Specification { "fail to generate API if environment variable is not found" >> { val envs = Map("LAST_TRACK_DESCRIPTION" -> "Last track") val json = SwaggerSpecGenerator( - NamingStrategy.None, - PrefixDomainModelQualifier("com.iheart"), + namingConvention = NamingConvention.None, + modelQualifier = PrefixDomainModelQualifier("com.iheart"), outputTransformers = MapVariablesTransformer(envs) :: Nil ).generate("env.routes") json must beFailedTry[JsObject].withThrowable[IllegalStateException]( diff --git a/core/src/test/scala/com/iheart/playSwagger/PrefixDomainModelQualifierSpec.scala b/core/src/test/scala/com/iheart/playSwagger/PrefixDomainModelQualifierSpec.scala index 3d439148..a0b37824 100644 --- a/core/src/test/scala/com/iheart/playSwagger/PrefixDomainModelQualifierSpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/PrefixDomainModelQualifierSpec.scala @@ -1,5 +1,6 @@ package com.iheart.playSwagger +import com.iheart.playSwagger.generator.PrefixDomainModelQualifier import org.specs2.mutable.Specification class PrefixDomainModelQualifierSpec extends Specification { diff --git a/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumEnum.scala b/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumEnum.scala index 90058b97..db4d9ee0 100644 --- a/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumEnum.scala +++ b/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumEnum.scala @@ -1,12 +1,14 @@ package com.iheart.playSwagger +import scala.collection.immutable + import enumeratum.EnumEntry.Snakecase import enumeratum._ sealed trait SampleEnumeratumEnum extends EnumEntry with Snakecase object SampleEnumeratumEnum extends Enum[SampleEnumeratumEnum] { - val values = findValues + val values: immutable.IndexedSeq[SampleEnumeratumEnum] = findValues case object InfoOne extends SampleEnumeratumEnum case object InfoTwo extends SampleEnumeratumEnum diff --git a/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumValueEnum.scala b/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumValueEnum.scala index 5abc6c70..0ccc3bce 100644 --- a/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumValueEnum.scala +++ b/core/src/test/scala/com/iheart/playSwagger/SampleEnumeratumValueEnum.scala @@ -1,11 +1,13 @@ package com.iheart.playSwagger +import scala.collection.immutable + import enumeratum.values.{StringEnum, StringEnumEntry} sealed abstract class SampleEnumeratumValueEnum(val value: String) extends StringEnumEntry object SampleEnumeratumValueEnum extends StringEnum[SampleEnumeratumValueEnum] { - val values = findValues + val values: immutable.IndexedSeq[SampleEnumeratumValueEnum] = findValues case object ValueOne extends SampleEnumeratumValueEnum("valueOne") case object ValueTwo extends SampleEnumeratumValueEnum("valueTwo") diff --git a/core/src/test/scala/com/iheart/playSwagger/SampleScalaEnum.scala b/core/src/test/scala/com/iheart/playSwagger/SampleScalaEnum.scala index f659a9e7..46de2291 100644 --- a/core/src/test/scala/com/iheart/playSwagger/SampleScalaEnum.scala +++ b/core/src/test/scala/com/iheart/playSwagger/SampleScalaEnum.scala @@ -1,8 +1,10 @@ package com.iheart.playSwagger +import com.iheart.playSwagger + object SampleScalaEnum extends Enumeration { type SampleScalaEnum = Value - val One = Value - val Two = Value + val One: playSwagger.SampleScalaEnum.Value = Value + val Two: playSwagger.SampleScalaEnum.Value = Value } diff --git a/core/src/test/scala/com/iheart/playSwagger/SwaggerParameterMapperSpec.scala b/core/src/test/scala/com/iheart/playSwagger/SwaggerParameterMapperSpec.scala index 8a4c7a74..d3eb2a2c 100644 --- a/core/src/test/scala/com/iheart/playSwagger/SwaggerParameterMapperSpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/SwaggerParameterMapperSpec.scala @@ -1,18 +1,23 @@ package com.iheart.playSwagger - -import com.iheart.playSwagger.Domain._ +import com.iheart.playSwagger.domain.CustomTypeMapping +import com.iheart.playSwagger.domain.parameter.{CustomSwaggerParameter, GenSwaggerParameter} +import com.iheart.playSwagger.generator.{PrefixDomainModelQualifier, SwaggerParameterMapper} import org.specs2.mutable.Specification import play.api.libs.json.{JsString, Json} import play.routes.compiler.Parameter class SwaggerParameterMapperSpec extends Specification { "mapParam" >> { - import SwaggerParameterMapper.mapParam - implicit val cl = this.getClass.getClassLoader + val generalMapper = new SwaggerParameterMapper(Nil, PrefixDomainModelQualifier()) + implicit val cl: ClassLoader = this.getClass.getClassLoader "map org.joda.time.DateTime to integer with format epoch" >> { - mapParam(Parameter("fieldWithDateTime", "org.joda.time.DateTime", None, None)) === GenSwaggerParameter( + generalMapper.mapParam( + Parameter("fieldWithDateTime", "org.joda.time.DateTime", None, None), + None + ) === GenSwaggerParameter( name = "fieldWithDateTime", + required = true, `type` = Option("integer"), format = Option("epoch") ) @@ -21,14 +26,16 @@ class SwaggerParameterMapperSpec extends Specification { "override mapping to map DateTime to string with format date-time" >> { "single DateTime" >> { val specAsParameter = List(Json.obj("type" -> "string", "format" -> "date-time")) - val mappings: CustomMappings = List(CustomTypeMapping( + val mappings: Seq[CustomTypeMapping] = List(CustomTypeMapping( "org.joda.time.DateTime", specAsParameter = specAsParameter )) - val parameter = mapParam( + val mapper = new SwaggerParameterMapper(mappings, PrefixDomainModelQualifier()) + + val parameter = mapper.mapParam( Parameter("fieldWithDateTimeOverRide", "org.joda.time.DateTime", None, None), - customMappings = mappings + None ) parameter.name mustEqual "fieldWithDateTimeOverRide" parameter must beAnInstanceOf[CustomSwaggerParameter] @@ -37,13 +44,16 @@ class SwaggerParameterMapperSpec extends Specification { "sequence of DateTimes" >> { val specAsProperty = Json.obj("type" -> "string", "format" -> "date-time") - val mappings: CustomMappings = List(CustomTypeMapping( + val mappings: Seq[CustomTypeMapping] = List(CustomTypeMapping( "org.joda.time.DateTime", specAsProperty = Some(specAsProperty) )) - val parameter = mapParam( + + val mapper = new SwaggerParameterMapper(mappings, PrefixDomainModelQualifier()) + + val parameter = mapper.mapParam( Parameter("seqWithDateTimeOverRide", "Option[Seq[org.joda.time.DateTime]]", None, None), - customMappings = mappings + None ).asInstanceOf[GenSwaggerParameter] parameter.name mustEqual "seqWithDateTimeOverRide" @@ -59,37 +69,47 @@ class SwaggerParameterMapperSpec extends Specification { "add new custom type mapping" >> { val specAsParameter = List(Json.obj("type" -> "string", "format" -> "date-time")) - val mappings: CustomMappings = List(CustomTypeMapping( + val mappings: Seq[CustomTypeMapping] = List(CustomTypeMapping( "java.util.Date", specAsParameter = specAsParameter )) - val parameter = mapParam(Parameter("fieldWithDate", "java.util.Date", None, None), customMappings = mappings) + + val mapper = new SwaggerParameterMapper(mappings, PrefixDomainModelQualifier()) + + val parameter = mapper.mapParam(Parameter("fieldWithDate", "java.util.Date", None, None), None) parameter.name mustEqual "fieldWithDate" parameter must beAnInstanceOf[CustomSwaggerParameter] parameter.asInstanceOf[CustomSwaggerParameter].specAsParameter === specAsParameter } "map Any to any with example value" >> { - mapParam(Parameter("fieldWithAny", "Any", None, None)) === GenSwaggerParameter( + generalMapper.mapParam(Parameter("fieldWithAny", "Any", None, None), None) === GenSwaggerParameter( name = "fieldWithAny", + required = true, `type` = Option("any"), example = Option(JsString("any JSON value")) ) } "map java enum to enum constants" >> { - mapParam(Parameter("javaEnum", "com.iheart.playSwagger.SampleJavaEnum", None, None)) === GenSwaggerParameter( + generalMapper.mapParam( + Parameter("javaEnum", "com.iheart.playSwagger.SampleJavaEnum", None, None), + None + ) === GenSwaggerParameter( name = "javaEnum", + required = true, `type` = Option("string"), enum = Option(Seq("DISABLED", "ACTIVE")) ) } "map scala enum to enum constants" >> { - mapParam( - Parameter("scalaEnum", "com.iheart.playSwagger.SampleScalaEnum.Value", None, None) + generalMapper.mapParam( + Parameter("scalaEnum", "com.iheart.playSwagger.SampleScalaEnum.Value", None, None), + None ) === GenSwaggerParameter( name = "scalaEnum", + required = true, `type` = Option("string"), enum = Option(Seq("One", "Two")) ) @@ -97,7 +117,7 @@ class SwaggerParameterMapperSpec extends Specification { // TODO: for sequences, should the nested required be ignored? "map Option[Seq[T]] to item type" >> { - mapParam(Parameter("aField", "Option[Seq[String]]", None, None)) === GenSwaggerParameter( + generalMapper.mapParam(Parameter("aField", "Option[Seq[String]]", None, None), None) === GenSwaggerParameter( name = "aField", required = false, nullable = Some(true), @@ -112,12 +132,17 @@ class SwaggerParameterMapperSpec extends Specification { } "map scala.collection.immutable.Seq[T] to item type" >> { - mapParam(Parameter("aField", "scala.collection.immutable.Seq[String]", None, None)) === GenSwaggerParameter( + generalMapper.mapParam( + Parameter("aField", "scala.collection.immutable.Seq[String]", None, None), + None + ) === GenSwaggerParameter( name = "aField", + required = true, nullable = None, `type` = Some("array"), items = Some(GenSwaggerParameter( name = "aField", + required = true, nullable = None, `type` = Some("string") )) @@ -127,7 +152,7 @@ class SwaggerParameterMapperSpec extends Specification { "map String to string without override interference" >> { val specAsParameter = List(Json.obj("type" -> "string", "format" -> "date-time")) - val mappings: CustomMappings = List( + val mappings: Seq[CustomTypeMapping] = List( CustomTypeMapping( "java.time.LocalDate", specAsParameter = specAsParameter @@ -137,7 +162,10 @@ class SwaggerParameterMapperSpec extends Specification { specAsParameter = specAsParameter ) ) - val parameter = mapParam(Parameter("strField", "String", None, None), customMappings = mappings) + + val mapper = new SwaggerParameterMapper(mappings, PrefixDomainModelQualifier()) + + val parameter = mapper.mapParam(Parameter("strField", "String", None, None), None) parameter.name mustEqual "strField" parameter must beAnInstanceOf[GenSwaggerParameter] parameter.asInstanceOf[GenSwaggerParameter].`type` must beSome("string") @@ -145,7 +173,7 @@ class SwaggerParameterMapperSpec extends Specification { } "map default value to content without quotes when provided with string without quotes" >> { - mapParam(Parameter("strField", "String", None, Some("defaultValue"))) === GenSwaggerParameter( + generalMapper.mapParam(Parameter("strField", "String", None, Some("defaultValue")), None) === GenSwaggerParameter( name = "strField", `type` = Option("string"), required = false, @@ -153,7 +181,10 @@ class SwaggerParameterMapperSpec extends Specification { ) } "map default value to content without quotes when provided with string with simple quotes" >> { - mapParam(Parameter("strField", "String", None, Some("\"defaultValue\""))) === GenSwaggerParameter( + generalMapper.mapParam( + Parameter("strField", "String", None, Some("\"defaultValue\"")), + None + ) === GenSwaggerParameter( name = "strField", `type` = Option("string"), required = false, @@ -161,7 +192,10 @@ class SwaggerParameterMapperSpec extends Specification { ) } "map default value to content without quotes when provided with string with triple quotes" >> { - mapParam(Parameter("strField", "String", None, Some("\"\"\"defaultValue\"\"\""))) === GenSwaggerParameter( + generalMapper.mapParam( + Parameter("strField", "String", None, Some("\"\"\"defaultValue\"\"\"")), + None + ) === GenSwaggerParameter( name = "strField", `type` = Option("string"), required = false, diff --git a/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala b/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala index 6ddc2424..fb006089 100644 --- a/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala +++ b/core/src/test/scala/com/iheart/playSwagger/SwaggerSpecGeneratorSpec.scala @@ -2,8 +2,9 @@ package com.iheart.playSwagger import java.time.LocalDate -import com.iheart.playSwagger.Domain.CustomMappings import com.iheart.playSwagger.RefinedTypes.{Age, Albums, SpotifyAccount} +import com.iheart.playSwagger.domain.CustomTypeMapping +import com.iheart.playSwagger.generator.SwaggerSpecGenerator import org.specs2.mutable.Specification import play.api.libs.json._ @@ -87,8 +88,8 @@ class SwaggerSpecGeneratorSpec extends Specification { "getCfgFile" >> { "valid swagger-custom-mappings yml" >> { - val result = gen.readCfgFile[CustomMappings]("swagger-custom-mappings.yml") - result must beSome[CustomMappings] + val result = gen.readCfgFile[Seq[CustomTypeMapping]]("swagger-custom-mappings.yml") + result must beSome[Seq[CustomTypeMapping]] val mappings = result.get mappings.size must be_>(2) mappings.head.`type` mustEqual "java\\.time\\.LocalDate" @@ -98,7 +99,7 @@ class SwaggerSpecGeneratorSpec extends Specification { } "invalid swagger-settings yml" >> { - gen.readCfgFile[CustomMappings]("swagger-custom-mappings_invalid.yml") must throwA[JsResultException] + gen.readCfgFile[Seq[CustomTypeMapping]]("swagger-custom-mappings_invalid.yml") must throwA[JsResultException] } } @@ -201,13 +202,13 @@ class SwaggerSpecGeneratorIntegrationSpec extends Specification { } "read seq of referenced type" >> { - val relatedProp = (trackJson \ "properties" \ "related") + val relatedProp = trackJson \ "properties" \ "related" (relatedProp \ "type").asOpt[String] === Some("array") (relatedProp \ "items" \ "$ref").asOpt[String] === Some("#/definitions/com.iheart.playSwagger.Artist") } "read seq of primitive type" >> { - val numberProps = (trackJson \ "properties" \ "numbers") + val numberProps = trackJson \ "properties" \ "numbers" (numberProps \ "type").asOpt[String] === Some("array") (numberProps \ "items" \ "type").asOpt[String] === Some("integer") } @@ -615,7 +616,7 @@ class SwaggerSpecGeneratorIntegrationSpec extends Specification { "definitions exposes 'required' array if there are required properties" >> { val requiredFields = Seq("name", "artist", "related", "numbers") - (trackJson \ "required").as[Seq[String]] must contain(allOf(requiredFields: _*).exactly) + (trackJson \ "required").as[Seq[String]] must contain(allOf(requiredFields.toSeq: _*).exactly) } "definitions does not expose 'required' array if there are no required properties" >> { diff --git a/docs/AlternativeSetup.md b/docs/AlternativeSetup.md index 46c365b7..c892d7c4 100644 --- a/docs/AlternativeSetup.md +++ b/docs/AlternativeSetup.md @@ -6,7 +6,10 @@ Follow the [Step 1](../README.md#step-1) from the main README. Note: It is sufficient to only add Play swagger as a library dependency in your `build.sbt` rather than a plugin in this setup. - You'll need to add `Resolver.jcenterRepo` to your `resolvers`. + You'll need to add following dependency: +```scala + "io.github.play-swagger" %% "play-swagger" % "1.4.4" +``` #### Step 2 Add a controller that uses Play swagger as a library to generates a swagger spec json and serves it as an endpoint. @@ -16,7 +19,7 @@ Example (compile time DI): package controllers.swagger import play.api.Configuration -import com.iheart.playSwagger.SwaggerSpecGenerator +import com.iheart.playSwagger.generator.SwaggerSpecGenerator import play.api.libs.json.JsString import play.api.mvc._ diff --git a/example/build.sbt b/example/build.sbt index d93dcbaf..8ce6e018 100644 --- a/example/build.sbt +++ b/example/build.sbt @@ -2,22 +2,25 @@ name := """example""" version := "1.0-SNAPSHOT" -scalafixDependencies in ThisBuild ++= Seq( +ThisBuild / scalafixDependencies ++= Seq( "com.github.liancheng" %% "organize-imports" % "0.6.0", - "net.pixiv" %% "scalafix-pixiv-rule" % "4.5.3" + "com.sandinh" %% "scala-rewrites" % "1.1.0-M1", + "net.pixiv" %% "scalafix-pixiv-rule" % "4.5.3", + "com.github.xuwei-k" %% "scalafix-rules" % "0.3.1", + "com.github.jatcwang" %% "scalafix-named-params" % "0.2.3" ) lazy val root = (project in file(".")).enablePlugins(PlayScala, SwaggerPlugin) //enable plugin -scalaVersion := "2.12.18" +scalaVersion := "2.12.19" libraryDependencies ++= Seq( jdbc, cacheApi, ws, guice, - "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0" % Test, - "org.webjars" % "swagger-ui" % "2.2.0" // play-swagger ui integration + "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test, + "org.webjars" % "swagger-ui" % "4.18.1" // play-swagger ui integration ) scalacOptions ++= Seq("-Xlint:unused") diff --git a/example/project/plugins.sbt b/example/project/plugins.sbt index 03c6f13d..9c84ee4e 100644 --- a/example/project/plugins.sbt +++ b/example/project/plugins.sbt @@ -1,9 +1,9 @@ // The Play plugin addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.8.0") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.1") // play swagger plugin -addSbtPlugin("com.iheart" % "sbt-play-swagger" % "0.0.1-EXAMPLE") +addSbtPlugin("io.github.play-swagger" % "sbt-play-swagger" % "0.0.1-EXAMPLE") diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 5417bd28..91ba587e 100755 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -3,37 +3,37 @@ import sbt._ object Dependencies { object Versions { val play = "2.8.20" - val playJson = "2.9.4" + val playJson = "2.10.4" val specs2 = "4.20.6" val enumeratum = "1.7.3" - val refined = "0.11.0" + val refined = "0.11.1" } - val playTest = Seq( + val playTest: Seq[ModuleID] = Seq( "com.typesafe.play" %% "play-test" % Versions.play % Test ) - val playRoutesCompiler = Seq( + val playRoutesCompiler: Seq[ModuleID] = Seq( "com.typesafe.play" %% "routes-compiler" % Versions.play ) - val playJson = Seq( + val playJson: Seq[ModuleID] = Seq( "com.typesafe.play" %% "play-json" % Versions.playJson % "provided" ) - val yaml = Seq( - "org.yaml" % "snakeyaml" % "2.0" + val yaml: Seq[ModuleID] = Seq( + "org.yaml" % "snakeyaml" % "2.2" ) - val enumeratum = Seq( + val enumeratum: Seq[ModuleID] = Seq( "com.beachape" %% "enumeratum" % Versions.enumeratum % Test ) - val refined = Seq( + val refined: Seq[ModuleID] = Seq( "eu.timepit" %% "refined" % Versions.refined % Test ) - val test = Seq( + val test: Seq[ModuleID] = Seq( "org.specs2" %% "specs2-core" % Versions.specs2 % "test", "org.specs2" %% "specs2-mock" % Versions.specs2 % "test" ) diff --git a/project/Format.scala b/project/Format.scala deleted file mode 100644 index abb0f4a3..00000000 --- a/project/Format.scala +++ /dev/null @@ -1,19 +0,0 @@ -import sbt._ -import com.typesafe.sbt.SbtScalariform -import com.typesafe.sbt.SbtScalariform.ScalariformKeys - -object Format { - lazy val settings = SbtScalariform.autoImport.scalariformSettings(autoformat = false) ++ Seq( - ScalariformKeys.preferences in Compile := formattingPreferences, - ScalariformKeys.preferences in Test := formattingPreferences - ) - - def formattingPreferences = { - import scalariform.formatter.preferences._ - FormattingPreferences() - .setPreference(RewriteArrowSymbols, true) - .setPreference(AlignParameters, true) - .setPreference(AlignSingleLineCaseStatements, true) - } - -} diff --git a/project/Publish.scala b/project/Publish.scala index d14f8644..82a258fb 100644 --- a/project/Publish.scala +++ b/project/Publish.scala @@ -1,29 +1,24 @@ -import com.jsuereth.sbtpgp.PgpKeys -import xerial.sbt.Sonatype.autoImport._ -import sbt._, Keys._ +import sbt.Keys._ +import sbt.{Def, _} object Publish { - val coreSettings = Seq( - ThisBuild / organization := "com.iheart", - publishMavenStyle := true, + val coreSettings: Seq[Def.Setting[_]] = Seq( + organization := "io.github.play-swagger", licenses := Seq("Apache-2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0.html")), - homepage := Some(url("http://iheartradio.github.io/play-swagger")), + homepage := Some(url("https://github.com/play-swagger/play-swagger")), scmInfo := Some(ScmInfo( - url("https://github.com/iheartradio/play-swagger"), - "git@github.com:iheartradio/play-swagger.git" + url("https://github.com/play-swagger/play-swagger"), + "git@github.com:play-swagger/play-swagger.git" )), developers := List( Developer( - "kailuowang", - "Kailuo Wang", - "kailuo.wang@gmail.com", - url("https://kailuowang.com") + "javakky", + "Javakky", + "javakky@pixiv.co.jp", + url("https://twitter.com/javakky_P/") ) - ), - pomIncludeRepository := { _ ⇒ false }, - Test / publishArtifact := false, - publishTo := sonatypePublishToBundle.value + ) ) } diff --git a/project/Testing.scala b/project/Testing.scala index 8b83e25d..c999ae11 100644 --- a/project/Testing.scala +++ b/project/Testing.scala @@ -1,11 +1,11 @@ import org.scoverage.coveralls.Imports.CoverallsKeys._ -import sbt._ +import sbt.{Def, _} import sbt.Keys._ object Testing { - lazy val settings = Seq( - scalacOptions in Test ++= Seq("-Yrangepos") + lazy val settings: Seq[Def.Setting[Task[Seq[String]]]] = Seq( + Test / scalacOptions ++= Seq("-Yrangepos") ) } diff --git a/project/Versioning.scala b/project/Versioning.scala index 97fc5ad6..3d764f56 100644 --- a/project/Versioning.scala +++ b/project/Versioning.scala @@ -1,10 +1,10 @@ import sbt.Keys._ -import sbt._ +import sbt.{Def, _} object Versioning { - def writeVersionFile(path: String) = Def.task { - val file = (resourceManaged in Compile).value / path + def writeVersionFile(path: String): Def.Initialize[Task[Seq[File]]] = Def.task { + val file = (Compile / resourceManaged).value / path IO.write(file, version.value.getBytes) Seq(file) } diff --git a/project/build.properties b/project/build.properties index 52413ab7..04267b14 100755 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.9.3 +sbt.version=1.9.9 diff --git a/project/plugins.sbt b/project/plugins.sbt index 94f4d13f..8741bf56 100755 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,15 +1,14 @@ -addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.0") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8") +addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.3.11") -addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.3.9") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") -addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") - -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.1") ThisBuild / libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12") +addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.21") diff --git a/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala b/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala index 39972a68..baeee4d8 100755 --- a/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala +++ b/sbtPlugin/src/main/scala/com/iheart/sbtPlaySwagger/SwaggerPlugin.scala @@ -1,15 +1,15 @@ package com.iheart.sbtPlaySwagger import com.typesafe.sbt.packager.archetypes.JavaAppPackaging -import com.typesafe.sbt.packager.universal.UniversalPlugin.autoImport._ -import com.typesafe.sbt.web.Import._ -import sbt.Attributed._ -import sbt.Keys._ -import sbt.{AutoPlugin, _} +import com.typesafe.sbt.packager.universal.UniversalPlugin.autoImport.* +import com.typesafe.sbt.web.Import.* +import sbt.Attributed.* +import sbt.Keys.* +import sbt.{AutoPlugin, *} object SwaggerPlugin extends AutoPlugin { - lazy val SwaggerConfig: Configuration = config("play-swagger").hide - lazy val playSwaggerVersion: String = com.iheart.playSwagger.BuildInfo.version + private lazy val SwaggerConfig: Configuration = config("play-swagger").hide + private lazy val playSwaggerVersion: String = com.iheart.playSwagger.BuildInfo.version object autoImport extends SwaggerKeys @@ -17,15 +17,15 @@ object SwaggerPlugin extends AutoPlugin { override def trigger = noTrigger - import autoImport._ + import autoImport.* override def projectConfigurations: Seq[Configuration] = Seq(SwaggerConfig) - override def projectSettings: Seq[Setting[_]] = Seq( + override def projectSettings: Seq[Setting[?]] = Seq( ivyConfigurations += SwaggerConfig, resolvers += Resolver.jcenterRepo, // todo: remove hardcoded org name using BuildInfo - libraryDependencies += "com.iheart" %% "play-swagger" % playSwaggerVersion % SwaggerConfig, + libraryDependencies += "io.github.play-swagger" %% "play-swagger" % playSwaggerVersion % SwaggerConfig, swaggerDomainNameSpaces := Seq(), swaggerV3 := false, swaggerTarget := target.value / "swagger", @@ -39,7 +39,7 @@ object SwaggerPlugin extends AutoPlugin { swaggerOperationIdNamingFully := false, embedScaladoc := false, swagger := Def.task[File] { - (swaggerTarget.value).mkdirs() + swaggerTarget.value.mkdirs() val file = swaggerTarget.value / swaggerFileName.value IO.delete(file) val args: Seq[String] = file.absolutePath :: swaggerRoutesFile.value :: @@ -54,7 +54,7 @@ object SwaggerPlugin extends AutoPlugin { embedScaladoc.value.toString :: Nil val swaggerClasspath = - data((fullClasspath in Runtime).value) ++ update.value.select(configurationFilter(SwaggerConfig.name)) + data((Runtime / fullClasspath).value) ++ update.value.select(configurationFilter(SwaggerConfig.name)) runner.value.run( "com.iheart.playSwagger.SwaggerSpecRunner", swaggerClasspath, @@ -63,10 +63,10 @@ object SwaggerPlugin extends AutoPlugin { ).failed foreach (sys error _.getMessage) file }.value, - unmanagedResourceDirectories in Assets += swaggerTarget.value, - mappings in (Compile, packageBin) += (swagger.value) -> s"public/${swaggerFileName.value}", // include it in the unmanagedResourceDirectories in Assets doesn't automatically include it package - packageBin in Universal := (packageBin in Universal).dependsOn(swagger).value, - run := (run in Compile).dependsOn(swagger).evaluated, + Assets / unmanagedResourceDirectories += swaggerTarget.value, + Compile / packageBin / mappings += swagger.value -> s"public/${swaggerFileName.value}", // include it in the unmanagedResourceDirectories in Assets doesn't automatically include it package + Universal / packageBin := (Universal / packageBin).dependsOn(swagger).value, + run := (Compile / run).dependsOn(swagger).evaluated, stage := stage.dependsOn(swagger).value ) } 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 index 2b531d15..873e9e66 100755 --- 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 @@ -1,7 +1,7 @@ import spray.json._ import DefaultJsonProtocol._ -logLevel in update := sbt.Level.Warn +update / logLevel := sbt.Level.Warn enablePlugins(PlayScala, SwaggerPlugin) @@ -9,7 +9,7 @@ name := "app" version := "1.0.1-BETA1" -scalaVersion := "2.12.18" +scalaVersion := "2.12.19" swaggerDomainNameSpaces := Seq("namespace1", "namespace2") @@ -148,7 +148,7 @@ TaskKey[Unit]("check") := { s"Result > $resultLine" }.mkString("\n") - val left = ep.takeRight(ep.size - rs.size).mkString("\n") + val left = ep.takeRight(ep.length - rs.length).mkString("\n") sys.error( s"""Swagger.json is off. 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 index 945bc394..cb423444 100644 --- 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 @@ -1,4 +1,4 @@ -logLevel in update := sbt.Level.Warn +update / logLevel := sbt.Level.Warn addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.3.2") addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.8.16") @@ -8,7 +8,7 @@ addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.8.16") 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) + else addSbtPlugin("io.github.play-swagger" %% "sbt-play-swagger" % pluginVersion) } -libraryDependencies += "io.spray" %% "spray-json" % "1.3.3" +libraryDependencies += "io.spray" %% "spray-json" % "1.3.6" diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/build.sbt b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/build.sbt index 09d539f0..94005d0e 100755 --- a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/build.sbt +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/build.sbt @@ -1,7 +1,7 @@ import spray.json._ import DefaultJsonProtocol._ -logLevel in update := sbt.Level.Warn +update / logLevel := sbt.Level.Warn enablePlugins(PlayScala, SwaggerPlugin) @@ -9,7 +9,7 @@ name := "app" version := "1.0.1-BETA1" -scalaVersion := "2.12.18" +scalaVersion := "2.12.19" swaggerDomainNameSpaces := Seq("namespace1", "namespace2") @@ -140,7 +140,7 @@ TaskKey[Unit]("check") := { s"Result > $resultLine" }.mkString("\n") - val left = ep.takeRight(ep.size - rs.size).mkString("\n") + val left = ep.takeRight(ep.length - rs.length).mkString("\n") sys.error( s"""Swagger.json is off. diff --git a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/project/plugins.sbt b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/project/plugins.sbt index 945bc394..cb423444 100644 --- a/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/project/plugins.sbt +++ b/sbtPlugin/src/sbt-test/sbt-play-swagger/generate-docs/project/plugins.sbt @@ -1,4 +1,4 @@ -logLevel in update := sbt.Level.Warn +update / logLevel := sbt.Level.Warn addSbtPlugin("com.typesafe.sbt" %% "sbt-native-packager" % "1.3.2") addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.8.16") @@ -8,7 +8,7 @@ addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.8.16") 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) + else addSbtPlugin("io.github.play-swagger" %% "sbt-play-swagger" % pluginVersion) } -libraryDependencies += "io.spray" %% "spray-json" % "1.3.3" +libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"