All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.iheart.playSwagger.generator.SwaggerSpecGenerator.scala Maven / Gradle / Ivy

The newest version!
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

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy