├── project ├── build.properties ├── plugins.sbt └── Dependencies.scala ├── .gitignore ├── plugin └── src │ ├── sbt-test │ └── openapi-test │ │ ├── json-scala │ │ ├── project │ │ │ ├── build.properties │ │ │ └── plugins.sbt │ │ ├── test │ │ ├── src │ │ │ └── main │ │ │ │ ├── scala │ │ │ │ ├── Codecs.scala │ │ │ │ └── Main.scala │ │ │ │ └── resources │ │ │ │ └── test.yml │ │ └── build.sbt │ │ ├── simple-scala │ │ ├── project │ │ │ ├── build.properties │ │ │ └── plugins.sbt │ │ ├── test │ │ ├── src │ │ │ └── main │ │ │ │ ├── scala │ │ │ │ └── Main.scala │ │ │ │ └── resources │ │ │ │ └── test.yml │ │ └── build.sbt │ │ ├── trait-scala │ │ ├── project │ │ │ ├── build.properties │ │ │ └── plugins.sbt │ │ ├── test │ │ ├── build.sbt │ │ └── src │ │ │ └── main │ │ │ ├── scala │ │ │ └── Main.scala │ │ │ └── resources │ │ │ └── test.yml │ │ └── json-extra-scala │ │ ├── project │ │ ├── build.properties │ │ └── plugins.sbt │ │ ├── test │ │ ├── src │ │ └── main │ │ │ ├── scala │ │ │ ├── Codecs.scala │ │ │ └── Main.scala │ │ │ └── resources │ │ │ └── test.yml │ │ └── build.sbt │ ├── main │ └── scala │ │ └── com │ │ └── github │ │ └── eikek │ │ └── sbt │ │ └── openapi │ │ ├── Pkg.scala │ │ ├── TypeDef.scala │ │ ├── Superclass.scala │ │ ├── Doc.scala │ │ ├── Field.scala │ │ ├── ScalaModelType.scala │ │ ├── impl │ │ ├── StringUtil.scala │ │ ├── TypeMapping.scala │ │ ├── Sys.scala │ │ ├── SchemaClass.scala │ │ ├── ElmCode.scala │ │ ├── Parser.scala │ │ └── ScalaCode.scala │ │ ├── Imports.scala │ │ ├── Annotation.scala │ │ ├── Property.scala │ │ ├── ElmConfig.scala │ │ ├── ScalaConfig.scala │ │ ├── SourceFile.scala │ │ ├── Type.scala │ │ ├── Part.scala │ │ ├── CustomMapping.scala │ │ ├── PartConv.scala │ │ ├── ScalaJson.scala │ │ ├── ElmJson.scala │ │ └── OpenApiSchema.scala │ └── test │ ├── scala │ └── com │ │ └── github │ │ └── eikek │ │ └── sbt │ │ └── openapi │ │ ├── impl │ │ └── ScalaCodeSpec.scala │ │ ├── CodegenSpec.scala │ │ ├── OpenApiSchemaSpec.scala │ │ └── ParserSpec.scala │ └── resources │ └── test1.yml ├── .scala-steward.conf ├── .github ├── renovate.json ├── workflows │ ├── release-drafter.yml │ ├── auto-approve.yml │ ├── release.yml │ └── ci.yml └── release-drafter.yml ├── .git-blame-ignore-revs ├── .scalafix.conf ├── .scalafmt.conf ├── LICENSE.txt ├── .mergify.yml └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .bsp 3 | 4 | target 5 | logs 6 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-scala/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0 2 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/simple-scala/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0 2 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/trait-scala/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0 2 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-extra-scala/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0 2 | -------------------------------------------------------------------------------- /.scala-steward.conf: -------------------------------------------------------------------------------- 1 | updates.pin = [ { groupId = "org.scala-lang", artifactId="scala-library", version = "2.12." } ] -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Pkg.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class Pkg(name: String) 4 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/TypeDef.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class TypeDef(name: String, imports: Imports) 4 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Superclass.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class Superclass(name: String, imports: Imports, interface: Boolean) 4 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-scala/test: -------------------------------------------------------------------------------- 1 | # run the task 2 | > openapiCodegen 3 | $ exists target/scala-2.12/src_managed/main/org/myapi/RoomDto.scala 4 | # the code should compile 5 | > compile 6 | > run -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-extra-scala/test: -------------------------------------------------------------------------------- 1 | # run the task 2 | > openapiCodegen 3 | $ exists target/scala-2.12/src_managed/main/org/myapi/RoomDto.scala 4 | # the code should compile 5 | > compile 6 | > run -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Doc.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class Doc(text: String) { 4 | def isEmpty: Boolean = text.trim.isEmpty 5 | } 6 | object Doc { 7 | val empty = Doc("") 8 | } 9 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "automerge": true, 3 | "labels": ["type: dependencies"], 4 | "packageRules": [ 5 | { 6 | "matchManagers": [ 7 | "sbt" 8 | ], 9 | "enabled": false 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Field.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class Field(prop: Property, annot: List[Annotation], typeDef: TypeDef) { 4 | 5 | val nullablePrimitive = prop.nullable && !prop.`type`.isCollection 6 | 7 | } 8 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/ScalaModelType.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | sealed trait ScalaModelType 4 | 5 | object ScalaModelType { 6 | case object CaseClass extends ScalaModelType 7 | case object Trait extends ScalaModelType 8 | } 9 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.7.5 2 | 386ce4bd4cd33d69d0ce7207361bef0ab6a422eb 3 | 4 | # Scala Steward: Reformat with scalafmt 3.7.17 5 | df4933f509193b30fb5d0654e2d16776efb9140b 6 | 7 | # Scala Steward: Reformat with scalafmt 3.8.4 8 | 0d74d14dcb684879af3cc1738418a05d2d9893b9 9 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/simple-scala/test: -------------------------------------------------------------------------------- 1 | # run the task 2 | > openapiCodegen 3 | $ exists target/scala-2.12/src_managed/main/org/myapi/Room.scala 4 | # the code should compile 5 | > compile 6 | > run 7 | # run static doc generation 8 | > openapiStaticDoc 9 | $ exists target/scala-2.12/resource_managed/main/openapiDoc/index.html -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-24.04 11 | steps: 12 | - uses: release-drafter/release-drafter@v6 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | ProcedureSyntax 3 | OrganizeImports 4 | ] 5 | OrganizeImports { 6 | coalesceToWildcardImportThreshold = 3 7 | expandRelative = true 8 | groupedImports = Keep 9 | importsOrder = Ascii 10 | groups = ["re:javax?\\.", "scala.", "*"] 11 | importSelectorsOrder = SymbolsFirst 12 | removeUnused = true 13 | } -------------------------------------------------------------------------------- /.github/workflows/auto-approve.yml: -------------------------------------------------------------------------------- 1 | name: Auto approve 2 | 3 | on: 4 | pull_request_target 5 | 6 | jobs: 7 | auto-approve: 8 | runs-on: ubuntu-24.04 9 | steps: 10 | - uses: hmarr/auto-approve-action@v4.0.0 11 | if: github.actor == 'eikek-scala-steward' 12 | with: 13 | github-token: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.3") 2 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") 3 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.0") 4 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1") 5 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.4.0") 6 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 7 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-scala/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | sys.props.get("plugin.version") match { 2 | case Some(x) => addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % x) 3 | case _ => 4 | sys.error( 5 | """|The system property 'plugin.version' is not defined. 6 | |Specify this property using the scriptedLaunchOpts -D.""".stripMargin 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/simple-scala/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | sys.props.get("plugin.version") match { 2 | case Some(x) => addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % x) 3 | case _ => 4 | sys.error( 5 | """|The system property 'plugin.version' is not defined. 6 | |Specify this property using the scriptedLaunchOpts -D.""".stripMargin 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/trait-scala/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | sys.props.get("plugin.version") match { 2 | case Some(x) => addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % x) 3 | case _ => 4 | sys.error( 5 | """|The system property 'plugin.version' is not defined. 6 | |Specify this property using the scriptedLaunchOpts -D.""".stripMargin 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/impl/StringUtil.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | object StringUtil { 4 | 5 | implicit class StringHelper(s: String) { 6 | def nullToEmpty: String = 7 | if (s == null) "" else s 8 | 9 | def asNonEmpty: Option[String] = 10 | Option(s).map(_.trim).filter(_.nonEmpty) 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-extra-scala/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | sys.props.get("plugin.version") match { 2 | case Some(x) => addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % x) 3 | case _ => 4 | sys.error( 5 | """|The system property 'plugin.version' is not defined. 6 | |Specify this property using the scriptedLaunchOpts -D.""".stripMargin 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-scala/src/main/scala/Codecs.scala: -------------------------------------------------------------------------------- 1 | package org.app 2 | 3 | import java.time.LocalDate 4 | import io.circe._ 5 | 6 | trait Codecs { 7 | 8 | implicit val dateDecoder: Decoder[LocalDate] = 9 | Decoder.decodeString.map(s => LocalDate.parse(s)) 10 | 11 | implicit val dateEncoder: Encoder[LocalDate] = 12 | Encoder.encodeString.contramap(_.toString) 13 | 14 | } 15 | 16 | object Codecs extends Codecs 17 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Imports.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class Imports(lines: List[String]) { 4 | def ++(is: Imports): Imports = 5 | Imports((lines ++ is.lines).distinct) 6 | } 7 | object Imports { 8 | val empty = Imports(Nil) 9 | 10 | def apply(i: String, is: String*): Imports = 11 | Imports(i :: is.toList) 12 | 13 | def flatten(l: Seq[Imports]): Imports = 14 | l.foldLeft(empty)(_ ++ _) 15 | } 16 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/trait-scala/test: -------------------------------------------------------------------------------- 1 | # run the task 2 | > openapiCodegen 3 | $ exec cat target/scala-2.12/src_managed/main/org/myapi/Room.scala 4 | $ exec cat target/scala-2.12/src_managed/main/org/myapi/Pet.scala 5 | $ exists target/scala-2.12/src_managed/main/org/myapi/Room.scala 6 | # the code should compile 7 | > compile 8 | > run 9 | # run static doc generation 10 | > openapiStaticDoc 11 | $ exists target/scala-2.12/resource_managed/main/openapiDoc/index.html -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.10.2" 2 | 3 | preset = default 4 | align.preset = some 5 | runner.dialect = scala213 6 | 7 | maxColumn = 90 8 | 9 | rewrite.rules = [ 10 | AvoidInfix 11 | RedundantBraces 12 | RedundantParens 13 | AsciiSortImports 14 | PreferCurlyFors 15 | SortModifiers 16 | ] 17 | 18 | assumeStandardLibraryStripMargin = true 19 | align.stripMargin = true 20 | 21 | docstrings.style = SpaceAsterisk 22 | docstrings.oneline = fold 23 | docstrings.wrap = "yes" -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-extra-scala/src/main/scala/Codecs.scala: -------------------------------------------------------------------------------- 1 | package org.app 2 | 3 | import java.time.LocalDate 4 | import io.circe._ 5 | import io.circe.generic.extras.semiauto._ 6 | 7 | trait Codecs { 8 | 9 | implicit val dateDecoder: Decoder[LocalDate] = 10 | Decoder.decodeString.map(s => LocalDate.parse(s)) 11 | 12 | implicit val dateEncoder: Encoder[LocalDate] = 13 | Encoder.encodeString.contramap(_.toString) 14 | 15 | } 16 | 17 | object Codecs extends Codecs 18 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Annotation.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | trait Annotation { 4 | def render: String 5 | } 6 | 7 | object Annotation { 8 | def apply(code: String): Annotation = 9 | new Annotation { val render = fixAnnotationString(code) } 10 | 11 | private def fixAnnotationString(str: String): String = 12 | if (!str.startsWith("@")) fixAnnotationString("@" + str) 13 | else if (!str.endsWith(")")) str + "()" 14 | else str 15 | } 16 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Property.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class Property( 4 | name: String, 5 | `type`: Type, 6 | nullable: Boolean = false, 7 | format: Option[String] = None, 8 | paramFormat: Option[String] = None, 9 | pattern: Option[String] = None, 10 | doc: Doc = Doc.empty, 11 | discriminator: Boolean = false 12 | ) { 13 | 14 | def optional: Property = 15 | if (nullable) this else copy(nullable = true) 16 | } 17 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/impl/TypeMapping.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | import com.github.eikek.sbt.openapi._ 4 | 5 | trait TypeMapping { self => 6 | def apply(t: Type): Option[TypeDef] 7 | 8 | def ++(next: TypeMapping): TypeMapping = 9 | (t: Type) => self(t).orElse(next(t)) 10 | } 11 | 12 | object TypeMapping { 13 | def apply(t: (Type, TypeDef), ts: (Type, TypeDef)*): TypeMapping = { 14 | val m = (t :: ts.toList).toMap 15 | t: Type => m.get(t) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/simple-scala/src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | package org.myapp 2 | 3 | import org.myapi._ 4 | 5 | import java.time._ 6 | 7 | case class Ident(id: String) 8 | 9 | object Main { 10 | 11 | def main(args: Array[String]): Unit = { 12 | val room = Room("main room", Some(68)) 13 | val person = Person(Some("Hans"), Some("Hanslein"), Some(Instant.now)) 14 | println(s"room = $room, person = $person") 15 | 16 | val mapper = Mapper(Ident("id1"), List(Ident("id2")), Option(Ident("id3"))) 17 | println(s"mapper=$mapper") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/ElmConfig.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class ElmConfig( 4 | mapping: CustomMapping = CustomMapping.none, 5 | json: ElmJson = ElmJson.none 6 | ) { 7 | 8 | def withJson(json: ElmJson): ElmConfig = 9 | copy(json = json) 10 | 11 | def addMapping(cm: CustomMapping): ElmConfig = 12 | copy(mapping = mapping.andThen(cm)) 13 | 14 | def setMapping(cm: CustomMapping): ElmConfig = 15 | copy(mapping = cm) 16 | } 17 | 18 | object ElmConfig { 19 | 20 | val default = ElmConfig() 21 | 22 | } 23 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | object V { 5 | val munitVersion = "1.1.0" 6 | val munitCatsEffectVersion = "2.1.0" 7 | val swaggerParser = "2.1.29" 8 | val swaggerCodegen = "3.0.68" 9 | } 10 | 11 | val munit = Seq( 12 | "org.scalameta" %% "munit" % V.munitVersion, 13 | "org.scalameta" %% "munit-scalacheck" % V.munitVersion, 14 | "org.typelevel" %% "munit-cats-effect" % V.munitCatsEffectVersion 15 | ) 16 | 17 | val `swagger-parser` = "io.swagger.parser.v3" % "swagger-parser" % V.swaggerParser 18 | 19 | val swaggerCodegen = "io.swagger.codegen.v3" % "swagger-codegen-cli" % V.swaggerCodegen 20 | 21 | } 22 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/simple-scala/build.sbt: -------------------------------------------------------------------------------- 1 | import com.github.eikek.sbt.openapi._ 2 | 3 | name := "sbt-openapi-simple-scala-test" 4 | version := "0.0.1" 5 | scalaVersion := "2.12.20" 6 | 7 | enablePlugins(OpenApiSchema) 8 | openapiTargetLanguage := Language.Scala 9 | openapiSpec := (Compile / resourceDirectory).value / "test.yml" 10 | openapiScalaConfig := 11 | ScalaConfig.default 12 | .addMapping(CustomMapping.forFormatType { case "ident" => 13 | field => field.copy(typeDef = TypeDef("Ident", Imports("org.myapp.Ident"))) 14 | }) 15 | .addMapping(CustomMapping.forType { case TypeDef("LocalDate", _) => 16 | TypeDef("Instant", Imports("java.time.Instant")) 17 | }) 18 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/trait-scala/build.sbt: -------------------------------------------------------------------------------- 1 | import com.github.eikek.sbt.openapi._ 2 | 3 | name := "sbt-openapi-simple-scala-test" 4 | version := "0.0.1" 5 | scalaVersion := "2.12.17" 6 | 7 | enablePlugins(OpenApiSchema) 8 | openapiTargetLanguage := Language.Scala 9 | openapiSpec := (Compile / resourceDirectory).value / "test.yml" 10 | openapiScalaConfig := 11 | ScalaConfig.default 12 | .addMapping(CustomMapping.forFormatType { case "ident" => 13 | field => field.copy(typeDef = TypeDef("Ident", Imports("org.myapp.Ident"))) 14 | }) 15 | .addMapping(CustomMapping.forType { case TypeDef("LocalDate", _) => 16 | TypeDef("Instant", Imports("java.time.Instant")) 17 | }) 18 | .setModelType(ScalaModelType.Trait) 19 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-scala/build.sbt: -------------------------------------------------------------------------------- 1 | import com.github.eikek.sbt.openapi._ 2 | 3 | name := "sbt-openapi-simple-test" 4 | version := "0.0.1" 5 | scalaVersion := "2.12.20" 6 | 7 | libraryDependencies ++= Seq( 8 | "io.circe" %% "circe-core" % "0.11.1", 9 | "io.circe" %% "circe-generic" % "0.11.1", 10 | "io.circe" %% "circe-parser" % "0.11.1" 11 | ) 12 | 13 | openapiSpec := (Compile / resourceDirectory).value / "test.yml" 14 | openapiTargetLanguage := Language.Scala 15 | openapiScalaConfig := ScalaConfig() 16 | .withJson(ScalaJson.circeSemiauto) 17 | .addMapping(CustomMapping.forSource { case src => 18 | src.addImports(Imports("org.app.Codecs._")) 19 | }) 20 | .addMapping(CustomMapping.forName { case s => s + "Dto" }) 21 | 22 | enablePlugins(OpenApiSchema) 23 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-extra-scala/build.sbt: -------------------------------------------------------------------------------- 1 | import com.github.eikek.sbt.openapi._ 2 | 3 | name := "sbt-openapi-simple-test" 4 | version := "0.0.1" 5 | scalaVersion := "2.12.20" 6 | 7 | libraryDependencies ++= Seq( 8 | "io.circe" %% "circe-generic-extras" % "0.11.1", 9 | "io.circe" %% "circe-core" % "0.11.1", 10 | "io.circe" %% "circe-generic" % "0.11.1", 11 | "io.circe" %% "circe-parser" % "0.11.1" 12 | ) 13 | 14 | openapiSpec := (Compile / resourceDirectory).value / "test.yml" 15 | openapiTargetLanguage := Language.Scala 16 | openapiScalaConfig := ScalaConfig() 17 | .withJson(ScalaJson.circeSemiautoExtra) 18 | .addMapping(CustomMapping.forSource { case src => 19 | src.addImports(Imports("org.app.Codecs._")) 20 | }) 21 | .addMapping(CustomMapping.forName { case s => s + "Dto" }) 22 | 23 | enablePlugins(OpenApiSchema) 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: [ master ] 5 | release: 6 | types: [ published ] 7 | jobs: 8 | release: 9 | runs-on: ubuntu-24.04 10 | steps: 11 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 12 | with: 13 | fetch-depth: 0 14 | - uses: olafurpg/setup-scala@v14 15 | with: 16 | java-version: openjdk@1.11 17 | - name: Coursier cache 18 | uses: coursier/cache-action@v7 19 | - name: sbt ci-release ${{ github.ref }} 20 | run: sbt ci-release 21 | env: 22 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 23 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 24 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 25 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | jobs: 5 | ci-matrix: 6 | runs-on: ubuntu-24.04 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | java: [ 'openjdk@1.11' ] 11 | steps: 12 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 13 | with: 14 | fetch-depth: 100 15 | - name: Fetch tags 16 | run: git fetch --depth=100 origin +refs/tags/*:refs/tags/* 17 | - uses: olafurpg/setup-scala@v14 18 | with: 19 | java-version: ${{ matrix.java }} 20 | - name: Coursier cache 21 | uses: coursier/cache-action@v7 22 | - name: sbt ci ${{ github.ref }} 23 | run: sbt ci 24 | ci: 25 | runs-on: ubuntu-24.04 26 | needs: [ci-matrix] 27 | steps: 28 | - name: Aggregate of lint, and all tests 29 | run: echo "ci passed" 30 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | template: | 4 | # What's Changed 5 | $CHANGES 6 | categories: 7 | - title: 'Breaking' 8 | label: 'type: breaking' 9 | - title: 'New' 10 | label: 'type: feature' 11 | - title: 'Bug Fixes' 12 | label: 'type: bug' 13 | - title: 'Maintenance' 14 | label: 'type: maintenance' 15 | - title: 'Documentation' 16 | label: 'type: docs' 17 | - title: 'Dependency Updates' 18 | label: 'type: dependencies' 19 | 20 | version-resolver: 21 | major: 22 | labels: 23 | - 'type: breaking' 24 | minor: 25 | labels: 26 | - 'type: feature' 27 | patch: 28 | labels: 29 | - 'type: bug' 30 | - 'type: maintenance' 31 | - 'type: docs' 32 | - 'type: dependencies' 33 | - 'type: security' 34 | 35 | exclude-labels: 36 | - 'skip-changelog' 37 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/ScalaConfig.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class ScalaConfig( 4 | mapping: CustomMapping = CustomMapping.none, 5 | json: ScalaJson = ScalaJson.none, 6 | modelType: ScalaModelType = ScalaModelType.CaseClass 7 | ) { 8 | require( 9 | modelType == ScalaModelType.CaseClass || json == ScalaJson.none, 10 | "Generating traits and ScalaJson is not supported." 11 | ) 12 | 13 | def withJson(json: ScalaJson): ScalaConfig = 14 | copy(json = json) 15 | 16 | def addMapping(cm: CustomMapping): ScalaConfig = 17 | copy(mapping = mapping.andThen(cm)) 18 | 19 | def setMapping(cm: CustomMapping): ScalaConfig = 20 | copy(mapping = cm) 21 | 22 | def setModelType(mt: ScalaModelType): ScalaConfig = 23 | copy(modelType = mt) 24 | } 25 | 26 | object ScalaConfig { 27 | 28 | val default = ScalaConfig() 29 | 30 | } 31 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/SourceFile.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class SourceFile( 4 | name: String, 5 | pkg: Pkg, 6 | imports: Imports, 7 | annot: List[Annotation], 8 | ctorAnnot: List[Annotation], 9 | doc: Doc, 10 | fields: List[Field], 11 | parents: List[Superclass] = Nil, 12 | wrapper: Boolean, 13 | internalSchemas: List[SourceFile], 14 | isInternal: Boolean = false 15 | ) { 16 | 17 | def addFields(fs: List[Field]): SourceFile = 18 | copy(fields = fields ++ fs) 19 | 20 | def addImports(is: Imports): SourceFile = 21 | copy(imports = imports ++ is) 22 | 23 | def addAnnotation(a: Annotation): SourceFile = 24 | copy(annot = a :: annot) 25 | 26 | def addCtorAnnotation(a: Annotation): SourceFile = 27 | copy(ctorAnnot = a :: ctorAnnot) 28 | 29 | def addParents(s0: Superclass, sn: Superclass*): SourceFile = 30 | copy(parents = (s0 :: sn.toList ::: parents).distinct) 31 | 32 | def modify(f: SourceFile => SourceFile): SourceFile = 33 | f(this) 34 | } 35 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/impl/Sys.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | import scala.sys.process._ 4 | 5 | import sbt.util.Logger 6 | 7 | trait Sys { 8 | def exec(cmd: Seq[String]): Int 9 | def execSuccess(cmd: Seq[String]): Unit 10 | } 11 | 12 | object Sys { 13 | 14 | def apply(logger: Logger): Sys = 15 | new Impl(new Output(logger)) 16 | 17 | final private class Impl(pl: ProcessLogger) extends Sys { 18 | 19 | def exec(cmd: Seq[String]): Int = 20 | Process(cmd).!(pl) 21 | 22 | def execSuccess(cmd: Seq[String]): Unit = 23 | exec(cmd) match { 24 | case 0 => () 25 | case n => 26 | val cmdStr = cmd.mkString(" ") 27 | sys.error(s"Command '$cmdStr' finished with error: $n") 28 | } 29 | } 30 | 31 | final private class Output(logger: Logger) extends ProcessLogger { 32 | def buffer[T](f: => T): T = f 33 | def err(s: => String): Unit = 34 | logger.info(s"[stderr] $s") 35 | 36 | def out(s: => String): Unit = 37 | logger.info(s"[stdout] $s") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Eike Kettner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/trait-scala/src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | package org.myapp 2 | 3 | import org.myapi._ 4 | 5 | import java.time._ 6 | 7 | case class Ident(id: String) 8 | case class RoomImpl(name: String, seats: Option[Int]) extends Room 9 | case class PersonImpl( 10 | firstname: Option[String], 11 | lastname: Option[String], 12 | dob: Option[Instant] 13 | ) extends Person 14 | case class MapperImpl(id: Ident, secondary: List[Ident], fallback: Option[Ident]) 15 | extends Mapper 16 | object PetImpl { 17 | case class Cat(huntingSkill: String, name: String) extends Pet.Cat 18 | case class Dog(packSize: Int, name: String) extends Pet.Dog 19 | } 20 | 21 | object Main { 22 | 23 | def main(args: Array[String]): Unit = { 24 | val room = RoomImpl("main room", Some(68)) 25 | val person = PersonImpl(Some("Hans"), Some("Hanslein"), Some(Instant.now)) 26 | println(s"room = $room, person = $person") 27 | 28 | val cat = PetImpl.Cat("rabbits", "Tom") 29 | val dog = PetImpl.Dog(1, "Lassie") 30 | println(s"cat = $cat, dog = $dog") 31 | 32 | val mapper = MapperImpl(Ident("id1"), List(Ident("id2")), Option(Ident("id3"))) 33 | println(s"mapper=$mapper") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /plugin/src/test/scala/com/github/eikek/sbt/openapi/impl/ScalaCodeSpec.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | import com.github.eikek.sbt.openapi._ 4 | import munit.FunSuite 5 | 6 | class ScalaCodeSpec extends FunSuite { 7 | 8 | test("Discriminant Schema") { 9 | val schemaClass = DiscriminantSchemaClass( 10 | "Stuff", 11 | properties = List( 12 | Property("shared1", Type.String, nullable = true), 13 | Property("type", Type.String, discriminator = true) 14 | ), 15 | subSchemas = List( 16 | SingularSchemaClass( 17 | "Foo", 18 | List( 19 | Property("thisIsAString", Type.String), 20 | Property("anotherField", Type.Int32) 21 | ), 22 | allOfRef = Some("Stuff") 23 | ), 24 | SingularSchemaClass( 25 | "Bar", 26 | List(Property("barField", Type.String), Property("newBool", Type.Bool)), 27 | allOfRef = Some("Stuff") 28 | ) 29 | ) 30 | ) 31 | 32 | val result = ScalaCode.generate( 33 | schemaClass, 34 | Pkg("com.test"), 35 | ScalaConfig().withJson(ScalaJson.circeSemiautoExtra) 36 | ) 37 | 38 | println(result._2) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Type.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | sealed trait Type { 4 | def isCollection: Boolean 5 | } 6 | object Type { 7 | 8 | trait PrimitiveType extends Type { 9 | val isCollection = false 10 | } 11 | trait CollectionType extends Type { 12 | val isCollection = true 13 | } 14 | 15 | case object Bool extends PrimitiveType 16 | case object String extends PrimitiveType 17 | case object Int32 extends PrimitiveType 18 | case object Int64 extends PrimitiveType 19 | case object Float32 extends PrimitiveType 20 | case object Float64 extends PrimitiveType 21 | case object BigDecimal extends PrimitiveType 22 | case object Uuid extends PrimitiveType 23 | case object Url extends PrimitiveType 24 | case object Uri extends PrimitiveType 25 | case class Date(repr: TimeRepr) extends PrimitiveType 26 | case class DateTime(repr: TimeRepr) extends PrimitiveType 27 | case class Sequence(param: Type) extends CollectionType 28 | case class Map(key: Type, value: Type) extends CollectionType 29 | case class Ref(name: String) extends PrimitiveType 30 | 31 | case object Json extends PrimitiveType 32 | 33 | sealed trait TimeRepr 34 | object TimeRepr { 35 | case object Number extends TimeRepr 36 | case object String extends TimeRepr 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /plugin/src/test/scala/com/github/eikek/sbt/openapi/CodegenSpec.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | import com.github.eikek.sbt.openapi.impl._ 4 | import munit.FunSuite 5 | 6 | class CodegenSpec extends FunSuite { 7 | 8 | val test1 = getClass.getResource("/test1.yml") 9 | val schema = Parser.parse(test1.toString) 10 | 11 | test("Running scala") { 12 | val config = ScalaConfig.default 13 | .withJson(ScalaJson.circeSemiauto) 14 | .addMapping(CustomMapping.forFormatType { case "ident" => 15 | field => field.copy(typeDef = TypeDef("Ident", Imports("my.common.Ident"))) 16 | }) 17 | .addMapping(CustomMapping.forSource { case s => 18 | s.addParents(Superclass("MyTrait", Imports.empty, false)) 19 | }) 20 | 21 | println("=========") 22 | schema.values.foreach { sc => 23 | println("-------------------------------------------------") 24 | println(ScalaCode.generate(sc, Pkg("com.test"), config)) 25 | } 26 | } 27 | 28 | test("Running elm") { 29 | val config = ElmConfig.default 30 | .addMapping(CustomMapping.forName { case name => name + "Dto" }) 31 | .withJson(ElmJson.decodePipeline) 32 | 33 | println("=========") 34 | schema.values.foreach { sc => 35 | println("-------------------------------------------------") 36 | println(ElmCode.generate(sc, Pkg("Api.Data"), config)) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: assign and label scala-steward's PRs 3 | conditions: 4 | - author=eikek-scala-steward[bot] 5 | actions: 6 | assign: 7 | users: [eikek] 8 | label: 9 | add: ["type: dependencies"] 10 | - name: label scala-steward's breaking PRs 11 | conditions: 12 | - author=eikek-scala-steward[bot] 13 | - "body~=(labels: library-update, early-semver-major)|(labels: sbt-plugin-update, early-semver-major)" 14 | actions: 15 | label: 16 | add: ["type: breaking"] 17 | - name: automatically merge Scala Steward PRs on CI success 18 | conditions: 19 | - author=eikek-scala-steward[bot] 20 | - base=master 21 | - status-success=ci 22 | - "body~=(labels: library-update, early-semver-minor)|(labels: library-update, early-semver-patch)|(labels: sbt-plugin-update, early-semver-minor)|(labels: sbt-plugin-update, early-semver-patch)|(labels: scalafix-rule-update)|(labels: test-library-update)" 23 | actions: 24 | merge: 25 | method: squash 26 | - name: automatically merge my (eikek) PRs on CI success 27 | conditions: 28 | - author=eikek 29 | - base=master 30 | - status-success=ci 31 | actions: 32 | merge: 33 | method: merge 34 | - name: automatic update for dependency update PRs 35 | conditions: 36 | - -conflict # skip PRs with conflicts 37 | - -draft # filter-out GH draft PRs 38 | - "label=type: dependencies" 39 | actions: 40 | update: {} 41 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-scala/src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | import org.myapi._ 2 | import java.time.LocalDate 3 | import io.circe._, io.circe.generic.semiauto._, io.circe.parser._ 4 | import io.circe.syntax._ 5 | 6 | object Main { 7 | 8 | def main(args: Array[String]): Unit = { 9 | testJson(RoomDto("main room dto", Some(68))) 10 | testJson(SimpleStringDto("bla bla bla")) 11 | testJson( 12 | CourseDto( 13 | "course10", 14 | LocalDate.now, 15 | RoomDto("heat room", Some(12)), 16 | List(PersonDto(Some("hugo"), "meyer", None)) 17 | ) 18 | ) 19 | testJson( 20 | ExtractedData1Dto( 21 | Map( 22 | "c1" -> CourseDto( 23 | "course10", 24 | LocalDate.now, 25 | RoomDto("heat room", Some(12)), 26 | List(PersonDto(Some("hugo"), "meyer", None)) 27 | ) 28 | ) 29 | ) 30 | ) 31 | testJson(ExtractedData2Dto(Map("c1" -> "v1", "c2" -> "v2"))) 32 | testJson( 33 | CustomJsonDto( 34 | "a name", 35 | Some(Json.obj("test" -> Json.fromInt(5))), 36 | Json.fromString("help") 37 | ) 38 | ) 39 | } 40 | 41 | def testJson[A](a: A)(implicit d: Decoder[A], e: Encoder[A]): Unit = { 42 | val jsonStr = toJson(a) 43 | val backA = toValue(jsonStr) 44 | println(s"JSON: $jsonStr BACK: $backA") 45 | assert(backA == a) 46 | } 47 | 48 | def toJson[A](a: A)(implicit enc: Encoder[A]): String = 49 | a.asJson.noSpaces 50 | 51 | def toValue[A](json: String)(implicit d: Decoder[A]): A = 52 | parse(json).right.get.as[A].right.get 53 | 54 | } 55 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/Part.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | case class Part(cnt: String) { 4 | def ++(p: Part): Part = 5 | render match { 6 | case "" => p 7 | case s => 8 | p.render match { 9 | case "" => this 10 | case s2 => Part(s + "\n" + s2) 11 | } 12 | } 13 | 14 | def +(p: Part): Part = render match { 15 | case "" => p 16 | case s if s.endsWith(p.render) => this 17 | case s => Part(s + p.render) 18 | } 19 | 20 | def ~(p: Part): Part = 21 | render match { 22 | case s if s.endsWith(" ") => Part(s + p.render) 23 | case s => Part(s + " " + p.render) 24 | } 25 | 26 | def newline: Part = 27 | render match { 28 | case "" => this 29 | case s => Part(s + "\n") 30 | } 31 | 32 | def prefix(s: String): Part = 33 | Part(render.split('\n').toList.map(l => s + l).mkString("\n")) 34 | 35 | def indent(n: Int): Part = 36 | prefix(List.fill(n)(" ").mkString) 37 | 38 | def semicolon: Part = render match { 39 | case "" => this 40 | case s => 41 | Part( 42 | s.split('\n').toList.map(l => if (l.endsWith(";")) l else l + ";").mkString("\n") 43 | ) 44 | } 45 | 46 | def capitalize: Part = 47 | Part(render.capitalize) 48 | 49 | def lower: Part = 50 | Part(render.toLowerCase) 51 | 52 | def quoted: Part = 53 | Part(s""""$render"""") 54 | 55 | def isEmpty: Boolean = 56 | cnt.trim.isEmpty 57 | 58 | def render: String = 59 | if (isEmpty) "" else cnt 60 | } 61 | 62 | object Part { 63 | val empty = Part("") 64 | 65 | def concat(p0: Part, ps: Part*): Part = 66 | Part((p0 :: ps.toList).map(_.cnt).foldLeft("")(_ + _)) 67 | } 68 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-extra-scala/src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | import org.myapi._ 2 | import java.time.LocalDate 3 | import io.circe._, io.circe.generic.extras.semiauto._, io.circe.parser._ 4 | import io.circe.syntax._ 5 | 6 | object Main { 7 | 8 | def main(args: Array[String]): Unit = { 9 | testJson(RoomDto("main room dto", Some(68))) 10 | testJson(SimpleStringDto("bla bla bla")) 11 | testJson( 12 | CourseDto( 13 | "course10", 14 | LocalDate.now, 15 | RoomDto("heat room", Some(12)), 16 | List(PersonDto(Some("hugo"), "meyer", None)) 17 | ) 18 | ) 19 | testJson( 20 | ExtractedData1Dto( 21 | Map( 22 | "c1" -> CourseDto( 23 | "course10", 24 | LocalDate.now, 25 | RoomDto("heat room", Some(12)), 26 | List(PersonDto(Some("hugo"), "meyer", None)) 27 | ) 28 | ) 29 | ) 30 | ) 31 | testJson(ExtractedData2Dto(Map("c1" -> "v1", "c2" -> "v2"))) 32 | val firstDiscriminator: DiscriminatorObjectDto = 33 | DiscriminatorObjectDto.FirstDiscriminatorSubObject( 34 | uniqueString = Some("v1"), 35 | sharedString = Some("v2"), 36 | anotherSharedBoolean = true 37 | ) 38 | val secondDiscriminator: DiscriminatorObjectDto = 39 | DiscriminatorObjectDto.SecondDiscriminatorObject( 40 | uniqueInteger = 2, 41 | otherUniqueBoolean = None, 42 | sharedString = Some("v4"), 43 | anotherSharedBoolean = true 44 | ) 45 | testJson(firstDiscriminator) 46 | testJson(secondDiscriminator) 47 | val catDiscriminator: PetDto = PetDto.Cat("claw", "Sprinkles") 48 | val dogDiscriminator: PetDto = PetDto.Dog(10, "Fido") 49 | testJson(catDiscriminator) 50 | testJson(dogDiscriminator) 51 | } 52 | 53 | def testJson[A](a: A)(implicit d: Decoder[A], e: Encoder[A]): Unit = { 54 | val jsonStr = toJson(a) 55 | val backA = toValue(jsonStr) 56 | println(s"JSON: $jsonStr BACK: $backA") 57 | assert(backA == a) 58 | } 59 | 60 | def toJson[A](a: A)(implicit enc: Encoder[A]): String = 61 | a.asJson.noSpaces 62 | 63 | def toValue[A](json: String)(implicit d: Decoder[A]): A = 64 | parse(json).right.get.as[A].right.get 65 | 66 | } 67 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/impl/SchemaClass.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | import com.github.eikek.sbt.openapi._ 4 | 5 | trait SchemaClass { 6 | val name: String 7 | val properties: List[Property] 8 | val doc: Doc 9 | val wrapper: Boolean 10 | } 11 | 12 | case class SingularSchemaClass( 13 | name: String, 14 | properties: List[Property] = Nil, 15 | doc: Doc = Doc.empty, 16 | wrapper: Boolean = false, 17 | allOfRef: Option[String] = None, 18 | oneOfRef: List[String] = Nil 19 | ) extends SchemaClass { 20 | def +(p: Property): SingularSchemaClass = 21 | copy(properties = p :: properties) 22 | 23 | def withDoc(doc: Doc): SingularSchemaClass = 24 | copy(doc = doc) 25 | } 26 | 27 | case class DiscriminantSchemaClass( 28 | name: String, 29 | properties: List[Property] = Nil, 30 | doc: Doc = Doc.empty, 31 | wrapper: Boolean = false, 32 | subSchemas: List[SingularSchemaClass] 33 | ) extends SchemaClass {} 34 | 35 | object SchemaClass { 36 | 37 | def resolve( 38 | sc: SchemaClass, 39 | pkg: Pkg, 40 | tm: TypeMapping, 41 | cm: CustomMapping 42 | ): SourceFile = { 43 | val topLevelFields = sc.properties.map(p => 44 | Field(p, Nil, tm(p.`type`).getOrElse(sys.error(s"No type for: $p"))) 45 | ) 46 | 47 | val internalSchemas = sc match { 48 | case dsc: DiscriminantSchemaClass => 49 | dsc.subSchemas 50 | .map(ss => resolve(ss, pkg, tm, CustomMapping.none)) 51 | .map(ss => ss.addFields(topLevelFields)) 52 | .map(ss => ss.modify(_.copy(isInternal = true))) 53 | case _ => List.empty 54 | } 55 | 56 | SourceFile( 57 | name = sc.name, 58 | pkg = pkg, 59 | imports = Imports.empty, 60 | annot = Nil, 61 | ctorAnnot = Nil, 62 | doc = sc.doc, 63 | fields = topLevelFields, 64 | wrapper = sc.wrapper, 65 | internalSchemas = internalSchemas 66 | ).modify(cm.changeSource) 67 | .modify(s => s.addImports(Imports.flatten(s.parents.map(_.imports)))) 68 | .modify(s => s.addImports(Imports.flatten(s.internalSchemas.map(_.imports)))) 69 | .modify(resolveFieldImports) 70 | } 71 | 72 | private def resolveFieldImports(src: SourceFile): SourceFile = 73 | src.fields.map(_.typeDef).foldLeft(src) { (s, td) => 74 | s.addImports(td.imports) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /plugin/src/test/scala/com/github/eikek/sbt/openapi/OpenApiSchemaSpec.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | import com.github.eikek.sbt.openapi.impl.{DiscriminantSchemaClass, SingularSchemaClass} 4 | import munit.FunSuite 5 | 6 | class OpenApiSchemaSpec extends FunSuite { 7 | 8 | private val expectedResult = Seq( 9 | SingularSchemaClass( 10 | "SingularThing", 11 | List(Property("first", Type.String), Property("second", Type.Bool)) 12 | ), 13 | DiscriminantSchemaClass( 14 | "Stuff", 15 | List(Property("type", Type.String, false, discriminator = true)), 16 | subSchemas = List( 17 | SingularSchemaClass( 18 | "Foo", 19 | List( 20 | Property("thisIsAString", Type.String), 21 | Property("anotherField", Type.Int32) 22 | ), 23 | allOfRef = Some("Stuff") 24 | ), 25 | SingularSchemaClass( 26 | "Bar", 27 | List(Property("barField", Type.String), Property("newBool", Type.Bool)), 28 | allOfRef = Some("Stuff") 29 | ) 30 | ) 31 | ) 32 | ) 33 | 34 | test("Separating allOf schemas") { 35 | val startingSchemas = Seq( 36 | SingularSchemaClass( 37 | "Foo", 38 | List( 39 | Property("thisIsAString", Type.String), 40 | Property("anotherField", Type.Int32) 41 | ), 42 | allOfRef = Some("Stuff") 43 | ), 44 | SingularSchemaClass( 45 | "Bar", 46 | List(Property("barField", Type.String), Property("newBool", Type.Bool)), 47 | allOfRef = Some("Stuff") 48 | ), 49 | SingularSchemaClass( 50 | "Stuff", 51 | List(Property("type", Type.String, false, discriminator = true)) 52 | ), 53 | SingularSchemaClass( 54 | "SingularThing", 55 | List(Property("first", Type.String), Property("second", Type.Bool)) 56 | ) 57 | ) 58 | val actualSchemas = OpenApiSchema.groupDiscriminantSchemas(startingSchemas) 59 | 60 | assertEquals(actualSchemas, expectedResult) 61 | } 62 | 63 | test("Separating oneOf schemas") { 64 | val startingSchemas = Seq( 65 | SingularSchemaClass( 66 | "Foo", 67 | List(Property("thisIsAString", Type.String), Property("anotherField", Type.Int32)) 68 | ), 69 | SingularSchemaClass( 70 | "Bar", 71 | List(Property("barField", Type.String), Property("newBool", Type.Bool)) 72 | ), 73 | SingularSchemaClass( 74 | "Stuff", 75 | List(Property("type", Type.String, false, discriminator = true)), 76 | oneOfRef = List("Foo", "Bar") 77 | ), 78 | SingularSchemaClass( 79 | "SingularThing", 80 | List(Property("first", Type.String), Property("second", Type.Bool)) 81 | ) 82 | ) 83 | val actualSchemas = OpenApiSchema.groupDiscriminantSchemas(startingSchemas) 84 | 85 | assertEquals(actualSchemas, expectedResult) 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-scala/src/main/resources/test.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | 3 | info: 4 | title: Test Simple 5 | version: 0.0.1 6 | 7 | servers: 8 | - url: /api/v1 9 | description: Current host 10 | 11 | paths: 12 | 13 | components: 14 | schemas: 15 | Room: 16 | description: | 17 | A room with some properties. 18 | required: 19 | - name 20 | properties: 21 | name: 22 | type: string 23 | seats: 24 | type: integer 25 | format: int32 26 | 27 | Person: 28 | description: | 29 | A person. 30 | required: 31 | - lastname 32 | properties: 33 | firstname: 34 | description: | 35 | The first name of the person. 36 | type: string 37 | lastname: 38 | description: | 39 | The last name of the person. 40 | type: string 41 | dob: 42 | description: | 43 | The date of birth of a person. 44 | type: string 45 | format: date 46 | 47 | AnyNumber: 48 | description: | 49 | A number, but nothing specific. 50 | required: 51 | - limit 52 | properties: 53 | limit: 54 | type: number 55 | 56 | Course: 57 | description: | 58 | A course. 59 | required: 60 | - id 61 | - starts 62 | - room 63 | properties: 64 | id: 65 | description: | 66 | A unique id for this course. 67 | type: string 68 | starts: 69 | description: | 70 | The date when this course starts. 71 | type: string 72 | format: date 73 | room: 74 | $ref: '#/components/schemas/Room' 75 | members: 76 | description: | 77 | A list of members currently enrolled in this course. 78 | type: array 79 | items: 80 | $ref: '#/components/schemas/Person' 81 | 82 | NestedArray: 83 | description: | 84 | Test nested array. 85 | properties: 86 | matrix: 87 | type: array 88 | items: 89 | type: array 90 | items: 91 | type: integer 92 | format: int32 93 | 94 | SimpleString: 95 | description: 96 | This is just a string. 97 | type: string 98 | 99 | ExtractedData1: 100 | description: | 101 | Contains data from extraction. 102 | type: object 103 | additionalProperties: 104 | $ref: '#/components/schemas/Course' 105 | 106 | ExtractedData2: 107 | description: | 108 | Contains data from extraction. 109 | type: object 110 | additionalProperties: 111 | type: string 112 | 113 | CustomJson: 114 | description: | 115 | Some data with arbitrary json. 116 | properties: 117 | name: 118 | type: string 119 | maybeData: 120 | type: object 121 | format: json 122 | data: 123 | type: object 124 | format: json 125 | required: 126 | - name 127 | - data 128 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/CustomMapping.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | trait CustomMapping { self => 4 | 5 | def changeType(td: TypeDef): TypeDef 6 | 7 | def changeSource(src: SourceFile): SourceFile 8 | 9 | def andThen(cm: CustomMapping): CustomMapping = new CustomMapping { 10 | def changeType(td: TypeDef): TypeDef = 11 | cm.changeType(self.changeType(td)) 12 | 13 | def changeSource(src: SourceFile): SourceFile = 14 | cm.changeSource(self.changeSource(src)) 15 | } 16 | } 17 | 18 | object CustomMapping { 19 | 20 | def apply( 21 | tf: PartialFunction[TypeDef, TypeDef], 22 | sf: PartialFunction[SourceFile, SourceFile] 23 | ): CustomMapping = 24 | new CustomMapping { 25 | def changeType(td: TypeDef) = tf.lift(td).getOrElse(td) 26 | def changeSource(src: SourceFile) = sf.lift(src).getOrElse(src) 27 | } 28 | 29 | def forType(f: PartialFunction[TypeDef, TypeDef]): CustomMapping = 30 | apply(f, PartialFunction.empty) 31 | 32 | def forSource(f: PartialFunction[SourceFile, SourceFile]): CustomMapping = 33 | apply(PartialFunction.empty, f) 34 | 35 | def forName(f: PartialFunction[String, String]): CustomMapping = { 36 | def changeRef(field: Field): Field = 37 | field.prop.`type` match { 38 | case Type.Ref(name) => 39 | field.copy(prop = 40 | field.prop.copy(`type` = Type.Ref(f.lift(name).getOrElse(name))) 41 | ) 42 | case Type.Sequence(Type.Ref(name)) => 43 | field.copy(prop = 44 | field.prop.copy(`type` = 45 | Type.Sequence(Type.Ref(f.lift(name).getOrElse(name))) 46 | ) 47 | ) 48 | case Type.Map(kt, vt) => 49 | val ktn = kt match { 50 | case Type.Ref(name) => Type.Ref(f.lift(name).getOrElse(name)) 51 | case _ => kt 52 | } 53 | val vtn = vt match { 54 | case Type.Ref(name) => Type.Ref(f.lift(name).getOrElse(name)) 55 | case _ => vt 56 | } 57 | field.copy(prop = field.prop.copy(`type` = Type.Map(ktn, vtn))) 58 | case _ => field 59 | } 60 | 61 | forSource { case s => 62 | s.copy(name = f.lift(s.name).getOrElse(s.name)) 63 | .copy(fields = s.fields.map(changeRef)) 64 | } 65 | } 66 | 67 | def forField(f: PartialFunction[Field, Field]): CustomMapping = 68 | forSource { case s => 69 | val newFields = s.fields.map(field => f.lift(field).getOrElse(field)) 70 | s.copy(fields = newFields) 71 | } 72 | 73 | def forFormatType(f: PartialFunction[String, Field => Field]): CustomMapping = 74 | forField(Function.unlift { field => 75 | (field.prop.format, field.prop.paramFormat) match { 76 | case (Some(ff), None) if f.isDefinedAt(ff) => 77 | Some(f(ff)(field)) 78 | case (None, Some(pf)) if f.isDefinedAt(pf) => 79 | val default = f(pf)(field) 80 | Some( 81 | default.copy(typeDef = 82 | TypeDef(s"List[${default.typeDef.name}]", default.typeDef.imports) 83 | ) 84 | ) 85 | case _ => 86 | None 87 | } 88 | }) 89 | 90 | val none = apply(PartialFunction.empty, PartialFunction.empty) 91 | } 92 | -------------------------------------------------------------------------------- /plugin/src/test/scala/com/github/eikek/sbt/openapi/ParserSpec.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | import com.github.eikek.sbt.openapi.impl.Parser 4 | import munit.FunSuite 5 | 6 | class ParserSpec extends FunSuite { 7 | 8 | test("Parsing out properties from discriminator schema") { 9 | val test1 = getClass.getResource("/test1.yml") 10 | val schema = Parser.parse(test1.toString) 11 | 12 | val actual = schema("DiscriminatorObject") 13 | 14 | assertEquals(actual.name, "DiscriminatorObject") 15 | assertEquals(actual.allOfRef, None) 16 | val propsWithNoDocs: Set[Property] = 17 | actual.properties.map(_.copy(doc = Doc.empty)).toSet 18 | assertEquals( 19 | propsWithNoDocs, 20 | Set( 21 | Property("type", Type.String, false, None, None, None, Doc.empty, true), 22 | Property("sharedString", Type.String, true, None, None, None, Doc.empty, false), 23 | Property( 24 | "anotherSharedBoolean", 25 | Type.Bool, 26 | false, 27 | None, 28 | None, 29 | None, 30 | Doc.empty, 31 | false 32 | ) 33 | ) 34 | ) 35 | } 36 | 37 | test("Parsing out properties from composed schema with discriminator") { 38 | val test1 = getClass.getResource("/test1.yml") 39 | val schema = Parser.parse(test1.toString) 40 | 41 | val actual = schema("FirstDiscriminatorSubObject") 42 | 43 | assertEquals(actual.name, "FirstDiscriminatorSubObject") 44 | assertEquals(actual.allOfRef, Some("DiscriminatorObject")) 45 | val propsWithNoDocs: Set[Property] = 46 | actual.properties.map(_.copy(doc = Doc.empty)).toSet 47 | assertEquals( 48 | propsWithNoDocs, 49 | Set( 50 | Property("uniqueString", Type.String, true, None, None, None, Doc.empty, false) 51 | ) 52 | ) 53 | } 54 | 55 | test( 56 | "Parsing out properties from composed schema with discriminator (different order)" 57 | ) { 58 | val test1 = getClass.getResource("/test1.yml") 59 | val schema = Parser.parse(test1.toString) 60 | 61 | val actual = schema("SecondDiscriminatorObject") 62 | 63 | assertEquals(actual.name, "SecondDiscriminatorObject") 64 | assertEquals(actual.allOfRef, Some("DiscriminatorObject")) 65 | val propsWithNoDocs: Set[Property] = 66 | actual.properties.map(_.copy(doc = Doc.empty)).toSet 67 | assertEquals( 68 | propsWithNoDocs, 69 | Set( 70 | Property("uniqueInteger", Type.Int32, false, None, None, None, Doc.empty, false), 71 | Property("otherUniqueBoolean", Type.Bool, true) 72 | ) 73 | ) 74 | } 75 | 76 | test("Parsing out properties from flat schema") { 77 | val test1 = getClass.getResource("/test1.yml") 78 | val schema = Parser.parse(test1.toString) 79 | 80 | val actual = schema("Room") 81 | 82 | assertEquals(actual.name, "Room") 83 | assertEquals(actual.allOfRef, None) 84 | val propsWithNoDocs: Set[Property] = 85 | actual.properties.map(_.copy(doc = Doc.empty)).toSet 86 | assertEquals( 87 | propsWithNoDocs, 88 | Set( 89 | Property("name", Type.String, false, None, None, None, Doc.empty, false), 90 | Property("seats", Type.Int32, true, Some("int32"), None, None, Doc.empty, false) 91 | ) 92 | ) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/PartConv.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | trait PartConv[A] { self => 4 | def toPart(a: A): Part 5 | 6 | def map(f: Part => Part): PartConv[A] = 7 | PartConv(a => f(self.toPart(a))) 8 | 9 | def contramap[B](f: B => A): PartConv[B] = 10 | b => self.toPart(f(b)) 11 | 12 | def concat(pc: PartConv[A], merge: (Part, Part) => Part): PartConv[A] = 13 | PartConv(a => merge(self.toPart(a), pc.toPart(a))) 14 | 15 | def ++(pc: PartConv[A]): PartConv[A] = 16 | concat(pc, _ ++ _) 17 | 18 | def ~(pc: PartConv[A]): PartConv[A] = 19 | concat(pc, _ ~ _) 20 | 21 | def +(pc: PartConv[A]): PartConv[A] = 22 | concat(pc, _ + _) 23 | 24 | def when(p: A => Boolean): PartConv[A] = 25 | PartConv(a => if (p(a)) self.toPart(a) else Part.empty) 26 | } 27 | 28 | object PartConv { 29 | def apply[A](f: A => Part): PartConv[A] = a => f(a) 30 | 31 | def ofPart[A](p: Part): PartConv[A] = 32 | PartConv(_ => p) 33 | 34 | def of[A](str: String): PartConv[A] = 35 | ofPart(Part(str)) 36 | 37 | def constant[A](str: String): PartConv[A] = of(str) 38 | 39 | def empty[A]: PartConv[A] = PartConv.of("") 40 | 41 | def forList[A](pc: PartConv[A], f: (Part, Part) => Part): PartConv[List[A]] = 42 | PartConv { list => 43 | list.map(pc.toPart).foldLeft(Part.empty)(f) 44 | } 45 | 46 | def forListSep[A](pc: PartConv[A], sep: Part): PartConv[List[A]] = 47 | PartConv { 48 | case Nil => Part.empty 49 | case list => list.map(pc.toPart).reduce((a, b) => a + sep + b) 50 | } 51 | 52 | def listSplit[A](psingle: PartConv[A], plist: PartConv[List[A]]): PartConv[List[A]] = 53 | PartConv { 54 | case Nil => Part.empty 55 | case a :: Nil => psingle.toPart(a) 56 | case a :: as => psingle.toPart(a) ~ plist.toPart(as) 57 | } 58 | 59 | val string: PartConv[String] = 60 | PartConv(s => Part(s)) 61 | 62 | val imports: PartConv[Imports] = 63 | forList(string, _ ++ _).contramap[Imports](_.lines.map(l => "import " + l)) 64 | 65 | val pkg: PartConv[Pkg] = 66 | PartConv(p => Part(s"package ${p.name}")) 67 | 68 | val doc: PartConv[Doc] = PartConv(d => 69 | if (d.isEmpty) Part.empty 70 | else Part("/**") ++ Part(d.text).prefix(" * ") ++ Part(" */") 71 | ) 72 | 73 | val annotation: PartConv[Annotation] = 74 | PartConv(annot => Part(annot.render)) 75 | 76 | val superclass: PartConv[Superclass] = 77 | PartConv(sc => Part(sc.name)) 78 | 79 | val fieldName: PartConv[Field] = 80 | string.contramap(_.prop.name) 81 | 82 | val sourceName: PartConv[SourceFile] = 83 | string.contramap(_.name) 84 | 85 | val fieldType: PartConv[Field] = 86 | string.contramap(_.typeDef.name) 87 | 88 | val discriminantType: PartConv[SourceFile] = 89 | string.contramap( 90 | _.fields 91 | .collectFirst { case f if f.prop.discriminator => f.prop.name } 92 | .getOrElse("type") 93 | ) 94 | 95 | val accessModifier: PartConv[SourceFile] = 96 | cond(_.isInternal, constant("private "), empty) 97 | 98 | def cond[A](p: A => Boolean, when: PartConv[A], otherwise: PartConv[A]): PartConv[A] = 99 | PartConv(a => if (p(a)) when.toPart(a) else otherwise.toPart(a)) 100 | } 101 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/simple-scala/src/main/resources/test.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | 3 | info: 4 | title: Test Simple 5 | version: 0.0.1 6 | 7 | servers: 8 | - url: /api/v1 9 | description: Current host 10 | 11 | paths: 12 | /: 13 | get: 14 | summary: Nothing 15 | responses: 16 | 200: 17 | description: Ok 18 | 19 | components: 20 | schemas: 21 | Mapper: 22 | required: 23 | - id 24 | - secondary 25 | properties: 26 | id: 27 | type: string 28 | format: ident 29 | secondary: 30 | type: array 31 | items: 32 | type: string 33 | format: ident 34 | fallback: 35 | type: string 36 | format: ident 37 | Room: 38 | description: | 39 | A room with some properties. 40 | required: 41 | - name 42 | properties: 43 | name: 44 | type: string 45 | seats: 46 | type: integer 47 | format: int32 48 | 49 | AnyNumber: 50 | description: | 51 | A number, but nothing specific. 52 | required: 53 | - limit 54 | properties: 55 | limit: 56 | type: number 57 | 58 | Person: 59 | description: | 60 | A person. 61 | properties: 62 | firstname: 63 | description: | 64 | The first name of the person. 65 | type: string 66 | lastname: 67 | description: | 68 | The last name of the person. 69 | type: string 70 | dob: 71 | description: | 72 | The date of birth of a person. 73 | type: string 74 | format: date 75 | 76 | Course: 77 | description: | 78 | A course. 79 | required: 80 | - id 81 | - starts 82 | - room 83 | properties: 84 | id: 85 | description: | 86 | A unique id for this course. 87 | type: string 88 | starts: 89 | description: | 90 | The date when this course starts. 91 | type: string 92 | format: date 93 | room: 94 | $ref: '#/components/schemas/Room' 95 | members: 96 | description: | 97 | A list of members currently enrolled in this course. 98 | type: array 99 | items: 100 | $ref: '#/components/schemas/Person' 101 | 102 | NestedArray: 103 | description: | 104 | Test nested array. 105 | properties: 106 | matrix: 107 | type: array 108 | items: 109 | type: array 110 | items: 111 | type: integer 112 | format: int32 113 | 114 | ExtractedData1: 115 | description: | 116 | Contains data from extraction. 117 | type: object 118 | additionalProperties: 119 | $ref: '#/components/schemas/Course' 120 | description: | 121 | Sibling values alongside `$ref` is not allowed. But the 122 | swagger-codegen throws an error if the following type field 123 | is not set. 124 | type: string 125 | 126 | ExtractedData2: 127 | description: | 128 | Contains data from extraction. 129 | type: object 130 | additionalProperties: 131 | type: string 132 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/trait-scala/src/main/resources/test.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | 3 | info: 4 | title: Test Simple 5 | version: 0.0.1 6 | 7 | servers: 8 | - url: /api/v1 9 | description: Current host 10 | 11 | paths: 12 | /: 13 | get: 14 | summary: Nothing 15 | responses: 16 | 200: 17 | description: Ok 18 | 19 | components: 20 | schemas: 21 | Mapper: 22 | required: 23 | - id 24 | - secondary 25 | properties: 26 | id: 27 | type: string 28 | format: ident 29 | secondary: 30 | type: array 31 | items: 32 | type: string 33 | format: ident 34 | fallback: 35 | type: string 36 | format: ident 37 | Room: 38 | description: | 39 | A room with some properties. 40 | required: 41 | - name 42 | properties: 43 | name: 44 | type: string 45 | seats: 46 | type: integer 47 | format: int32 48 | 49 | Person: 50 | description: | 51 | A person. 52 | properties: 53 | firstname: 54 | description: | 55 | The first name of the person. 56 | type: string 57 | lastname: 58 | description: | 59 | The last name of the person. 60 | type: string 61 | dob: 62 | description: | 63 | The date of birth of a person. 64 | type: string 65 | format: date 66 | 67 | Course: 68 | description: | 69 | A course. 70 | required: 71 | - id 72 | - starts 73 | - room 74 | properties: 75 | id: 76 | description: | 77 | A unique id for this course. 78 | type: string 79 | starts: 80 | description: | 81 | The date when this course starts. 82 | type: string 83 | format: date 84 | room: 85 | $ref: '#/components/schemas/Room' 86 | members: 87 | description: | 88 | A list of members currently enrolled in this course. 89 | type: array 90 | items: 91 | $ref: '#/components/schemas/Person' 92 | 93 | NestedArray: 94 | description: | 95 | Test nested array. 96 | properties: 97 | matrix: 98 | type: array 99 | items: 100 | type: array 101 | items: 102 | type: integer 103 | format: int32 104 | 105 | ExtractedData1: 106 | description: | 107 | Contains data from extraction. 108 | type: object 109 | additionalProperties: 110 | $ref: '#/components/schemas/Course' 111 | description: | 112 | Sibling values alongside `$ref` is not allowed. But the 113 | swagger-codegen throws an error if the following type field 114 | is not set. 115 | type: string 116 | 117 | ExtractedData2: 118 | description: | 119 | Contains data from extraction. 120 | type: object 121 | additionalProperties: 122 | type: string 123 | 124 | Pet: 125 | type: object 126 | discriminator: 127 | propertyName: petType 128 | properties: 129 | name: 130 | type: string 131 | petType: 132 | type: string 133 | required: 134 | - name 135 | - petType 136 | Cat: ## "Cat" will be used as the discriminator value 137 | description: A representation of a cat 138 | allOf: 139 | - $ref: '#/components/schemas/Pet' 140 | - type: object 141 | properties: 142 | huntingSkill: 143 | type: string 144 | description: The measured skill for hunting 145 | enum: 146 | - clueless 147 | - lazy 148 | - adventurous 149 | - aggressive 150 | required: 151 | - huntingSkill 152 | Dog: ## "Dog" will be used as the discriminator value 153 | description: A representation of a dog 154 | allOf: 155 | - $ref: '#/components/schemas/Pet' 156 | - type: object 157 | properties: 158 | packSize: 159 | type: integer 160 | format: int32 161 | description: the size of the pack the dog is from 162 | default: 0 163 | minimum: 0 164 | required: 165 | - packSize -------------------------------------------------------------------------------- /plugin/src/test/resources/test1.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | 3 | info: 4 | title: Test 1 5 | version: 0.1.0 6 | 7 | servers: 8 | - url: /api/v1 9 | description: Current host 10 | 11 | paths: 12 | '/test': 13 | get: 14 | responses: 15 | '200': 16 | description: test 17 | content: 18 | applicaton/json: 19 | schema: 20 | $ref: "#/components/schemas/DiscriminatorObject" 21 | components: 22 | schemas: 23 | Mapper: 24 | required: 25 | - id 26 | - secondary 27 | properties: 28 | id: 29 | type: string 30 | format: ident 31 | secondary: 32 | type: array 33 | items: 34 | type: string 35 | format: ident 36 | fallback: 37 | type: string 38 | format: ident 39 | Room: 40 | description: | 41 | A room with some properties. 42 | required: 43 | - name 44 | properties: 45 | name: 46 | type: string 47 | seats: 48 | type: integer 49 | format: int32 50 | 51 | Person: 52 | description: | 53 | A person. 54 | required: 55 | - lastname 56 | properties: 57 | firstname: 58 | description: | 59 | The first name of the person. 60 | type: string 61 | lastname: 62 | description: | 63 | The last name of the person. 64 | type: string 65 | dob: 66 | description: | 67 | The date of birth of a person. 68 | type: string 69 | format: date 70 | 71 | Course: 72 | description: | 73 | A course. 74 | required: 75 | - id 76 | - starts 77 | - room 78 | - mandatory 79 | properties: 80 | id: 81 | description: | 82 | A unique id for this course. 83 | type: string 84 | starts: 85 | description: | 86 | The date when this course starts. 87 | type: string 88 | format: date 89 | mandatory: 90 | description: | 91 | Whether this course is mandatory for all. 92 | type: boolean 93 | room: 94 | $ref: '#/components/schemas/Room' 95 | members: 96 | description: | 97 | A list of members currently enrolled in this course. 98 | type: array 99 | items: 100 | $ref: '#/components/schemas/Person' 101 | 102 | NestedArray: 103 | description: | 104 | Test nested array. 105 | properties: 106 | matrix: 107 | type: array 108 | items: 109 | type: array 110 | items: 111 | type: integer 112 | format: int32 113 | 114 | StringWrapper: 115 | description: | 116 | Just a string, actually. 117 | type: string 118 | 119 | AnyNumber: 120 | description: | 121 | A number, but not specific. 122 | properties: 123 | limit: 124 | type: number 125 | 126 | DiscriminatorObject: 127 | type: object 128 | discriminator: 129 | propertyName: "type" 130 | properties: 131 | type: 132 | type: string 133 | sharedString: 134 | type: string 135 | description: | 136 | Shared string value with all types of this object 137 | anotherSharedBoolean: 138 | type: boolean 139 | description: | 140 | A shared boolean value 141 | required: 142 | - type 143 | - anotherSharedBoolean 144 | 145 | FirstDiscriminatorSubObject: 146 | allOf: 147 | - type: object 148 | properties: 149 | uniqueString: 150 | type: string 151 | description: | 152 | String unique to this instance of discriminator 153 | - $ref: '#/components/schemas/DiscriminatorObject' 154 | 155 | SecondDiscriminatorObject: 156 | allOf: 157 | - $ref: '#/components/schemas/DiscriminatorObject' 158 | - type: object 159 | properties: 160 | uniqueInteger: 161 | type: integer 162 | description: | 163 | String unique to this instance of discriminator 164 | otherUniqueBoolean: 165 | type: boolean 166 | required: 167 | - uniqueInteger 168 | CustomJson: 169 | description: | 170 | Some data with arbitrary json. 171 | properties: 172 | name: 173 | type: string 174 | maybeData: 175 | type: object 176 | format: json 177 | data: 178 | type: object 179 | format: json 180 | required: 181 | - name 182 | - data 183 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/impl/ElmCode.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | import com.github.eikek.sbt.openapi.PartConv._ 4 | import com.github.eikek.sbt.openapi._ 5 | 6 | object ElmCode { 7 | 8 | val module: PartConv[SourceFile] = 9 | PartConv(src => Part(s"module ${src.pkg.name}.${src.name} exposing (..)")) 10 | 11 | val doc: PartConv[Doc] = PartConv(d => 12 | if (d.isEmpty) Part.empty 13 | else Part("{--") ++ Part(d.text).prefix(" - ") ++ Part(" --}") 14 | ) 15 | 16 | val primitiveTypeMapping: TypeMapping = 17 | TypeMapping( 18 | Type.Bool -> TypeDef("Bool", Imports.empty), 19 | Type.String -> TypeDef("String", Imports.empty), 20 | Type.Int32 -> TypeDef("Int", Imports.empty), 21 | Type.Int64 -> TypeDef("Int", Imports.empty), 22 | Type.Float32 -> TypeDef("Float", Imports.empty), 23 | Type.Float64 -> TypeDef("Float", Imports.empty), 24 | Type.BigDecimal -> TypeDef("Float", Imports.empty), 25 | Type.Uuid -> TypeDef("String", Imports.empty), 26 | Type.Url -> TypeDef("String", Imports.empty), 27 | Type.Uri -> TypeDef("String", Imports.empty), 28 | Type.Date(Type.TimeRepr.String) -> TypeDef("String", Imports.empty), 29 | Type.Date(Type.TimeRepr.Number) -> TypeDef("Int", Imports.empty), 30 | Type.DateTime(Type.TimeRepr.String) -> TypeDef("String", Imports.empty), 31 | Type.DateTime(Type.TimeRepr.Number) -> TypeDef("Int", Imports.empty), 32 | Type.Json -> TypeDef("Json", Imports.empty) 33 | ) 34 | 35 | def emptyValue(pkg: Pkg): PartConv[TypeDef] = 36 | PartConv { 37 | case TypeDef("Bool", _) => Part("False") 38 | case TypeDef("String", _) => Part("\"\"") 39 | case TypeDef("Int", _) => Part("0") 40 | case TypeDef("Float", _) => Part("0.0") 41 | case TypeDef(n, _) if n.startsWith("Maybe") => Part("Nothing") 42 | case TypeDef(n, _) if n.startsWith("(List") => Part("[]") 43 | case TypeDef(n, _) if n.startsWith("(Dict") => Part("Dict.empty") 44 | case TypeDef("Encode.Value", _) => Part("Encode.null") 45 | case TypeDef(name, _) => Part(s"${pkg.name}.$name.empty") 46 | } 47 | 48 | def defaultTypeMapping(cm: CustomMapping, pkg: Pkg): TypeMapping = { 49 | case Type.Sequence(param) => 50 | defaultTypeMapping(cm, pkg)(param).map(el => 51 | cm.changeType(TypeDef(s"(List ${el.name})", el.imports)) 52 | ) 53 | case Type.Map(key, value) => 54 | for { 55 | k <- defaultTypeMapping(cm, pkg)(key) 56 | v <- defaultTypeMapping(cm, pkg)(value) 57 | } yield cm.changeType( 58 | TypeDef(s"(Dict ${k.name},${v.name})", k.imports ++ v.imports ++ Imports("Dict")) 59 | ) 60 | case Type.Ref(name) => 61 | val srcRef = SingularSchemaClass(name) 62 | val refName = resolveSchema(srcRef, cm, pkg).name 63 | Some(TypeDef(refName, Imports(s"${pkg.name}.$refName exposing ($refName)"))) 64 | case t => 65 | primitiveTypeMapping(t).map(cm.changeType) 66 | } 67 | 68 | def typeAlias: PartConv[SourceFile] = { 69 | val fieldPart: PartConv[Field] = 70 | cond( 71 | f => f.nullablePrimitive, 72 | fieldName + PartConv.of(": Maybe ") + fieldType, 73 | fieldName + PartConv.of(": ") + fieldType 74 | ) 75 | constant("type alias") ~ sourceName ~ constant("=") ++ 76 | constant(" {") ~ forListSep(fieldPart, Part("\n , ")).contramap(_.fields) ++ 77 | constant(" }") 78 | } 79 | 80 | def defaultValue(pkg: Pkg): PartConv[SourceFile] = { 81 | val fieldPart: PartConv[Field] = 82 | cond( 83 | f => f.nullablePrimitive, 84 | fieldName ~ constant("= Nothing"), 85 | fieldName ~ constant("=") ~ emptyValue(pkg).contramap(_.typeDef) 86 | ) 87 | 88 | constant("empty:") ~ sourceName ++ 89 | constant("empty =") ++ 90 | constant(" {") ~ forListSep(fieldPart, Part("\n , ")).contramap(_.fields) ++ 91 | constant(" }") 92 | } 93 | 94 | def fileHeader: PartConv[SourceFile] = 95 | constant("{-- This file has been generated from an openapi spec. --}").map( 96 | _.newline 97 | ) ++ 98 | module.map(_.newline) ++ 99 | imports.map(_.newline).contramap(_.imports) ++ 100 | doc.contramap(_.doc) 101 | 102 | def generate(sc: SchemaClass, pkg: Pkg, cfg: ElmConfig): (String, String) = { 103 | val src = resolveSchema(sc, cfg.mapping, pkg).copy(pkg = pkg).modify(cfg.json.resolve) 104 | val conv = fileHeader ++ typeAlias.map(_.newline) ++ defaultValue(pkg).map( 105 | _.newline 106 | ) ++ cfg.json.jsonCodec 107 | (src.name, conv.toPart(src).render) 108 | } 109 | 110 | def resolveSchema(sc: SchemaClass, cm: CustomMapping, pkg: Pkg): SourceFile = { 111 | val tm = defaultTypeMapping(cm, pkg) 112 | SchemaClass.resolve(sc, pkg, tm, cm) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/ScalaJson.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | import com.github.eikek.sbt.openapi.PartConv._ 4 | 5 | trait ScalaJson { 6 | 7 | def companion: PartConv[SourceFile] 8 | 9 | def resolve(src: SourceFile): SourceFile 10 | 11 | } 12 | 13 | object ScalaJson { 14 | val none = new ScalaJson { 15 | def companion = PartConv(_ => Part.empty) 16 | def resolve(src: SourceFile): SourceFile = src 17 | } 18 | 19 | private def replaceJsonType(src: SourceFile): SourceFile = { 20 | val circeJson = TypeDef("io.circe.Json", Imports.empty) 21 | def isJson(f: Field) = f.typeDef.name.equalsIgnoreCase("json") 22 | 23 | src.copy(fields = 24 | src.fields.map(f => if (isJson(f)) f.copy(typeDef = circeJson) else f) 25 | ) 26 | } 27 | 28 | val circeSemiauto = new ScalaJson { 29 | val props: PartConv[SourceFile] = 30 | constant("object") ~ sourceName ~ constant("{") ++ 31 | constant("implicit val jsonDecoder: io.circe.Decoder[").map( 32 | _.indent(2) 33 | ) + sourceName + constant( 34 | "] = io.circe.generic.semiauto.deriveDecoder[" 35 | ) + sourceName + constant("]") ++ 36 | constant("implicit val jsonEncoder: io.circe.Encoder[").map( 37 | _.indent(2) 38 | ) + sourceName + constant( 39 | "] = io.circe.generic.semiauto.deriveEncoder[" 40 | ) + sourceName + constant("]") ++ 41 | constant("}") 42 | 43 | val wrapper: PartConv[SourceFile] = 44 | constant("object") ~ sourceName ~ constant("{") ++ 45 | constant("implicit def jsonDecoder(implicit vd: io.circe.Decoder[").map( 46 | _.indent(2) 47 | ) + 48 | fieldType.contramap[SourceFile](_.fields.head) + constant( 49 | "]): io.circe.Decoder[" 50 | ) + sourceName + constant("] =") ++ 51 | constant("vd.map(").map(_.indent(4)) + sourceName + constant(".apply)") ++ 52 | constant("implicit def jsonEncoder(implicit ve: io.circe.Encoder[").map( 53 | _.indent(2) 54 | ) + 55 | fieldType.contramap[SourceFile](_.fields.head) + constant( 56 | "]): io.circe.Encoder[" 57 | ) + sourceName + constant("] =") ++ 58 | constant("ve.contramap(_.value)").map(_.indent(4)) ++ 59 | constant("}") 60 | 61 | def companion = 62 | cond(_.wrapper, wrapper, props) 63 | 64 | def resolve(src: SourceFile): SourceFile = 65 | src.modify(replaceJsonType) 66 | } 67 | 68 | val circeSemiautoExtra = new ScalaJson { 69 | 70 | val discriminantProps: PartConv[SourceFile] = 71 | constant("implicit val jsonDecoder: io.circe.Decoder[") + sourceName + constant( 72 | "] = io.circe.generic.extras.semiauto.deriveDecoder[" 73 | ) + sourceName + constant("]") ++ 74 | constant("implicit val jsonEncoder: io.circe.Encoder[") + sourceName + constant( 75 | "] = io.circe.generic.extras.semiauto.deriveEncoder[" 76 | ) + sourceName + constant("]") 77 | 78 | val props: PartConv[SourceFile] = 79 | constant("object") ~ sourceName ~ constant("{") ++ 80 | constant( 81 | "implicit val customConfig: io.circe.generic.extras.Configuration = io.circe.generic.extras.Configuration.default.withDefaults.withDiscriminator(\"" 82 | ).map(_.indent(2)) + discriminantType + constant("\")") ++ 83 | (accessModifier + constant("implicit val jsonDecoder: io.circe.Decoder[")).map( 84 | _.indent(2) 85 | ) + sourceName + constant( 86 | "] = io.circe.generic.extras.semiauto.deriveDecoder[" 87 | ) + sourceName + constant("]") ++ 88 | (accessModifier + constant("implicit val jsonEncoder: io.circe.Encoder[")).map( 89 | _.indent(2) 90 | ) + sourceName + constant( 91 | "] = io.circe.generic.extras.semiauto.deriveEncoder[" 92 | ) + sourceName + constant("]") ++ 93 | constant("}") 94 | 95 | val wrapper: PartConv[SourceFile] = 96 | constant("object") ~ sourceName ~ constant("{") ++ 97 | constant( 98 | "implicit val customConfig: io.circe.generic.extras.Configuration = io.circe.generic.extras.Configuration.default.withDefaults.withDiscriminator(\"" 99 | ).map(_.indent(2)) + discriminantType + constant("\")") ++ 100 | (accessModifier + constant( 101 | "implicit def jsonDecoder(implicit vd: io.circe.Decoder[" 102 | )).map(_.indent(2)) + 103 | fieldType.contramap[SourceFile](_.fields.head) + constant( 104 | "]): io.circe.Decoder[" 105 | ) + sourceName + constant("] =") ++ 106 | constant("vd.map(").map(_.indent(4)) + sourceName + constant(".apply)") ++ 107 | (accessModifier + constant( 108 | "implicit def jsonEncoder(implicit ve: io.circe.Encoder[" 109 | )).map(_.indent(2)) + 110 | fieldType.contramap[SourceFile](_.fields.head) + constant( 111 | "]): io.circe.Encoder[" 112 | ) + sourceName + constant("] =") ++ 113 | constant("ve.contramap(_.value)").map(_.indent(4)) ++ 114 | constant("}") 115 | 116 | override def companion: PartConv[SourceFile] = 117 | cond(_.internalSchemas.isEmpty, singular, discriminant) 118 | 119 | def singular: PartConv[SourceFile] = cond(_.wrapper, wrapper, props) 120 | def discriminant: PartConv[SourceFile] = 121 | forList(singular, _ ++ _) 122 | .contramap[SourceFile](_.internalSchemas) ++ discriminantProps 123 | 124 | override def resolve(src: SourceFile): SourceFile = 125 | src.modify(replaceJsonType) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /plugin/src/sbt-test/openapi-test/json-extra-scala/src/main/resources/test.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | 3 | info: 4 | title: Test Simple 5 | version: 0.0.1 6 | 7 | servers: 8 | - url: /api/v1 9 | description: Current host 10 | 11 | paths: 12 | '/test': 13 | get: 14 | responses: 15 | '200': 16 | description: test 17 | content: 18 | applicaton/json: 19 | schema: 20 | $ref: "#/components/schemas/DiscriminatorObject" 21 | components: 22 | schemas: 23 | Room: 24 | description: | 25 | A room with some properties. 26 | required: 27 | - name 28 | properties: 29 | name: 30 | type: string 31 | seats: 32 | type: integer 33 | format: int32 34 | 35 | Person: 36 | description: | 37 | A person. 38 | required: 39 | - lastname 40 | properties: 41 | firstname: 42 | description: | 43 | The first name of the person. 44 | type: string 45 | lastname: 46 | description: | 47 | The last name of the person. 48 | type: string 49 | dob: 50 | description: | 51 | The date of birth of a person. 52 | type: string 53 | format: date 54 | 55 | Course: 56 | description: | 57 | A course. 58 | required: 59 | - id 60 | - starts 61 | - room 62 | properties: 63 | id: 64 | description: | 65 | A unique id for this course. 66 | type: string 67 | starts: 68 | description: | 69 | The date when this course starts. 70 | type: string 71 | format: date 72 | room: 73 | $ref: '#/components/schemas/Room' 74 | members: 75 | description: | 76 | A list of members currently enrolled in this course. 77 | type: array 78 | items: 79 | $ref: '#/components/schemas/Person' 80 | 81 | NestedArray: 82 | description: | 83 | Test nested array. 84 | properties: 85 | matrix: 86 | type: array 87 | items: 88 | type: array 89 | items: 90 | type: integer 91 | format: int32 92 | 93 | SimpleString: 94 | description: 95 | This is just a string. 96 | type: string 97 | 98 | ExtractedData1: 99 | description: | 100 | Contains data from extraction. 101 | type: object 102 | additionalProperties: 103 | $ref: '#/components/schemas/Course' 104 | 105 | ExtractedData2: 106 | description: | 107 | Contains data from extraction. 108 | type: object 109 | additionalProperties: 110 | type: string 111 | 112 | DiscriminatorObject: 113 | type: object 114 | discriminator: 115 | propertyName: "type" 116 | properties: 117 | type: 118 | type: string 119 | sharedString: 120 | type: string 121 | description: | 122 | Shared string value with all types of this object 123 | anotherSharedBoolean: 124 | type: boolean 125 | description: | 126 | A shared boolean value 127 | required: 128 | - type 129 | - anotherSharedBoolean 130 | 131 | FirstDiscriminatorSubObject: 132 | allOf: 133 | - $ref: '#/components/schemas/DiscriminatorObject' 134 | - type: object 135 | properties: 136 | uniqueString: 137 | type: string 138 | description: | 139 | String unique to this instance of discriminator 140 | 141 | SecondDiscriminatorObject: 142 | allOf: 143 | - $ref: '#/components/schemas/DiscriminatorObject' 144 | - type: object 145 | properties: 146 | uniqueInteger: 147 | type: integer 148 | description: | 149 | String unique to this instance of discriminator 150 | otherUniqueBoolean: 151 | type: boolean 152 | required: 153 | - uniqueInteger 154 | Pet: 155 | type: object 156 | discriminator: 157 | propertyName: petType 158 | properties: 159 | name: 160 | type: string 161 | petType: 162 | type: string 163 | required: 164 | - name 165 | - petType 166 | Cat: ## "Cat" will be used as the discriminator value 167 | description: A representation of a cat 168 | allOf: 169 | - $ref: '#/components/schemas/Pet' 170 | - type: object 171 | properties: 172 | huntingSkill: 173 | type: string 174 | description: The measured skill for hunting 175 | enum: 176 | - clueless 177 | - lazy 178 | - adventurous 179 | - aggressive 180 | required: 181 | - huntingSkill 182 | Dog: ## "Dog" will be used as the discriminator value 183 | description: A representation of a dog 184 | allOf: 185 | - $ref: '#/components/schemas/Pet' 186 | - type: object 187 | properties: 188 | packSize: 189 | type: integer 190 | format: int32 191 | description: the size of the pack the dog is from 192 | default: 0 193 | minimum: 0 194 | required: 195 | - packSize 196 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/ElmJson.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | import com.github.eikek.sbt.openapi.PartConv._ 4 | 5 | trait ElmJson { 6 | 7 | def resolve(src: SourceFile): SourceFile 8 | 9 | def jsonCodec: PartConv[SourceFile] 10 | } 11 | 12 | object ElmJson { 13 | val none = new ElmJson { 14 | def resolve(src: SourceFile): SourceFile = src 15 | 16 | def jsonCodec = PartConv.empty 17 | } 18 | 19 | private def replaceJsonType(src: SourceFile): SourceFile = { 20 | val jsonValue = TypeDef("Encode.Value", Imports.empty) 21 | def isJson(f: Field) = f.typeDef.name.equalsIgnoreCase("json") 22 | 23 | src.copy(fields = 24 | src.fields.map(f => if (isJson(f)) f.copy(typeDef = jsonValue) else f) 25 | ) 26 | } 27 | 28 | val decodePipeline = new ElmJson { 29 | 30 | sealed trait Direction { 31 | def name: String 32 | } 33 | object Direction { 34 | case object Dec extends Direction { 35 | val name = "Decode" 36 | } 37 | case object Enc extends Direction { 38 | val name = "Encode" 39 | } 40 | } 41 | 42 | def resolve(src: SourceFile): SourceFile = 43 | src 44 | .addImports( 45 | Imports( 46 | "Json.Decode as Decode", 47 | "Json.Decode.Pipeline as P", 48 | "Json.Encode as Encode" 49 | ) 50 | ) 51 | .modify(replaceJsonType) 52 | 53 | private def codecForType(dir: Direction, pkg: Pkg, t: Type): Part = t match { 54 | case Type.Sequence(param) => 55 | val dec = codecForType(dir, pkg, param) 56 | Part.concat(Part(s"(${dir.name}.list "), dec, Part(")")) 57 | case Type.Map(kt, vt) => 58 | Part.concat(Part(s"(${dir.name}.dict "), codecForType(dir, pkg, vt), Part(")")) 59 | case Type.Ref(name) => 60 | if (dir == Direction.Dec) Part(s"${pkg.name}.$name.decoder") 61 | else Part(s"${pkg.name}.$name.encode") 62 | case Type.Bool => 63 | Part(s"${dir.name}.bool") 64 | case Type.Int32 => 65 | Part(s"${dir.name}.int") 66 | case Type.Int64 => 67 | Part(s"${dir.name}.int") 68 | case Type.Float32 => 69 | Part(s"${dir.name}.float") 70 | case Type.Float64 => 71 | Part(s"${dir.name}.float") 72 | case Type.DateTime(Type.TimeRepr.Number) => 73 | Part(s"${dir.name}.int") 74 | case Type.Date(Type.TimeRepr.Number) => 75 | Part(s"${dir.name}.int") 76 | case Type.Json => 77 | if (dir == Direction.Enc) Part("identity") 78 | else Part("Decode.value") 79 | case _ => 80 | Part(s"${dir.name}.string") 81 | } 82 | 83 | def codecForField(dir: Direction): PartConv[(Pkg, Field)] = PartConv { 84 | case (pkg, field) => 85 | val codec = field.prop.`type` match { 86 | case Type.Ref(_) => 87 | if (dir == Direction.Dec) Part(s"${pkg.name}.${field.typeDef.name}.decoder") 88 | else Part(s"${pkg.name}.${field.typeDef.name}.encode") 89 | case _ => 90 | codecForType(dir, pkg, field.prop.`type`) 91 | } 92 | if (field.nullablePrimitive) { 93 | if (dir == Direction.Dec) 94 | Part.concat(Part(s"(${dir.name}.maybe "), codec, Part(")")) ~ Part("Nothing") 95 | else 96 | Part.concat( 97 | Part("(Maybe.map "), 98 | codec, 99 | Part(" >> Maybe.withDefault Encode.null)") 100 | ) 101 | } else { 102 | codec 103 | } 104 | } 105 | 106 | private val requiredOrOptional: PartConv[(Pkg, Field)] = 107 | PartConv { case (_, field) => 108 | if (field.nullablePrimitive) Part("P.optional") 109 | else Part("P.required") 110 | } 111 | 112 | private val decodeField: PartConv[(Pkg, Field)] = 113 | constant("|>").map(_.indent(4)) ~ requiredOrOptional ~ 114 | fieldName.contramap[(Pkg, Field)](_._2).map(_.quoted) ~ codecForField( 115 | Direction.Dec 116 | ) 117 | 118 | private val decoderObject: PartConv[SourceFile] = 119 | constant("decoder: Decode.Decoder") ~ sourceName ++ 120 | constant("decoder =") ++ 121 | constant("Decode.succeed").map(_.indent(2)) ~ sourceName ++ 122 | forList(decodeField, _ ++ _).contramap(src => src.fields.map(f => (src.pkg, f))) 123 | 124 | private val decoderWrapper: PartConv[SourceFile] = 125 | constant("decoder: Decode.Decoder") ~ sourceName ++ 126 | constant("decoder =") ++ 127 | constant("Decode.map").map(_.indent(2)) ~ sourceName ~ codecForField( 128 | Direction.Dec 129 | ).contramap(src => (src.pkg, src.fields.head)) 130 | 131 | private val encodeField: PartConv[(Pkg, Field)] = 132 | constant("(") ~ fieldName.contramap[(Pkg, Field)](_._2).map(_.quoted) + constant( 133 | ", (" 134 | ) + 135 | codecForField(Direction.Enc) ~ constant("value.") + fieldName 136 | .contramap[(Pkg, Field)](_._2) + constant(") )") 137 | 138 | private val encoderObject: PartConv[SourceFile] = 139 | constant("encode:") ~ sourceName ~ constant("->") ~ constant("Encode.Value") ++ 140 | constant("encode value =") ++ 141 | constant("Encode.object").map(_.indent(2)) ++ 142 | constant("[").map(_.indent(4)) ~ 143 | forListSep(encodeField, Part("\n , ")).contramap(src => 144 | src.fields.map(f => (src.pkg, f)) 145 | ) ++ 146 | constant("]").map(_.indent(4)) 147 | 148 | private val encoderWrapper: PartConv[SourceFile] = 149 | constant("encode:") ~ sourceName ~ constant("->") ~ constant("Encode.Value") ++ 150 | constant("encode value =") ++ 151 | codecForField(Direction.Enc) 152 | .map(_.indent(2)) 153 | .contramap[SourceFile](src => (src.pkg, src.fields.head)) ~ constant( 154 | "value.value" 155 | ) 156 | 157 | private val decoder: PartConv[SourceFile] = 158 | cond(_.wrapper, decoderWrapper, decoderObject) 159 | 160 | private val encoder: PartConv[SourceFile] = 161 | cond(_.wrapper, encoderWrapper, encoderObject) 162 | 163 | def jsonCodec = 164 | decoder.map(_.newline) ++ encoder 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/impl/Parser.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | import scala.collection.JavaConverters._ 4 | 5 | import com.github.eikek.sbt.openapi._ 6 | import com.github.eikek.sbt.openapi.impl.StringUtil._ 7 | import io.swagger.v3.oas.models.media._ 8 | import io.swagger.v3.parser.OpenAPIV3Parser 9 | 10 | object Parser { 11 | private val parser = new OpenAPIV3Parser 12 | 13 | def parse(file: String): Map[String, SingularSchemaClass] = { 14 | // see http://javadoc.io/doc/io.swagger.core.v3/swagger-models/2.0.7 15 | // http://javadoc.io/doc/io.swagger.parser.v3/swagger-parser-v3/2.0.9 16 | val oapi = parser.read(file) 17 | 18 | oapi.getComponents.getSchemas.asScala.toMap.map { case (name, schema) => 19 | name -> makeSchemaClass(name, schema) 20 | } 21 | } 22 | 23 | def makeSchemaClass(name: String, schema: Schema[_]): SingularSchemaClass = 24 | schema match { 25 | case cs: ComposedSchema if cs.getAllOf != null => 26 | val allOfSchemas = cs.getAllOf.asScala 27 | 28 | val discriminatorPropertyOpt = allOfSchemas 29 | .collectFirst { 30 | case s: Schema[_] if s.getDiscriminator != null => s.getName 31 | } 32 | .orElse(allOfSchemas.collectFirst { 33 | case s: Schema[_] if s.get$ref() != null => s.get$ref().split('/').last 34 | }) 35 | 36 | val allProperties = allOfSchemas 37 | .collect { 38 | case s if s.getProperties != null => 39 | val required = 40 | Option(s.getRequired).map(_.asScala.toSet).getOrElse(Set.empty) 41 | s.getProperties.asScala.map { case (n, ps) => 42 | makeProperty(n, ps, required, None) 43 | }.toList 44 | } 45 | .flatten 46 | .toList 47 | SingularSchemaClass( 48 | name, 49 | allProperties, 50 | Doc(cs.getDescription.nullToEmpty), 51 | allOfRef = discriminatorPropertyOpt 52 | ) 53 | case cs: ComposedSchema if cs.getOneOf != null => 54 | val oneOfSchemas = cs.getOneOf.asScala 55 | val oneOfFields = oneOfSchemas.map { 56 | case s: Schema[_] if s.get$ref() != null => s.get$ref().split('/').last 57 | }.toList 58 | val discriminatorProperty = 59 | Option(cs.getDiscriminator).map(_.getPropertyName).map { discriminatorName => 60 | Property( 61 | name = discriminatorName, 62 | `type` = Type.String, 63 | format = None, 64 | pattern = None, 65 | doc = Doc.empty, 66 | discriminator = true 67 | ) 68 | } 69 | SingularSchemaClass( 70 | name, 71 | discriminatorProperty.toList, 72 | Doc(cs.getDescription.nullToEmpty), 73 | oneOfRef = oneOfFields 74 | ) 75 | case s if s.getProperties != null => 76 | val required = Option(s.getRequired).map(_.asScala.toSet).getOrElse(Set.empty) 77 | val discriminatorName = Option(s.getDiscriminator).map(_.getPropertyName) 78 | val props = s.getProperties.asScala.map { case (n, ps) => 79 | makeProperty(n, ps, required, discriminatorName) 80 | }.toList 81 | SingularSchemaClass(name, props, Doc(s.getDescription.nullToEmpty)) 82 | case _ => 83 | val discriminatorName = Option(schema.getDiscriminator).map(_.getPropertyName) 84 | SingularSchemaClass(name, wrapper = true) + makeProperty( 85 | "value", 86 | schema, 87 | Set("value"), 88 | discriminatorName 89 | ) 90 | } 91 | 92 | def makeProperty( 93 | name: String, 94 | schema: Schema[_], 95 | required: String => Boolean, 96 | discriminatorName: Option[String] 97 | ): Property = { 98 | val p = Property( 99 | name, 100 | schemaType(schema), 101 | format = schema.getFormat.asNonEmpty, 102 | paramFormat = paramSchemaFormat(schema), 103 | pattern = schema.getPattern.asNonEmpty, 104 | nullable = schema.getNullable == true || !required(name), 105 | doc = Doc(schema.getDescription.nullToEmpty), 106 | discriminator = discriminatorName.contains(name) 107 | ) 108 | p 109 | } 110 | 111 | def paramSchemaFormat(sch: Schema[_]): Option[String] = 112 | sch match { 113 | case s: ArraySchema => 114 | Option(s.getItems()).flatMap(_.getFormat().asNonEmpty) 115 | case _ => 116 | None 117 | } 118 | 119 | // TODO missing: BinarySchema, ByteArraySchema, FileSchema, MapSchema 120 | def schemaType(sch: Schema[_]): Type = 121 | sch match { 122 | case s: ArraySchema => 123 | Type.Sequence(schemaType(s.getItems)) 124 | case _: BooleanSchema => 125 | Type.Bool 126 | case _: DateSchema => 127 | Type.Date(Type.TimeRepr.String) 128 | case _: DateTimeSchema => 129 | Type.DateTime(Type.TimeRepr.String) 130 | case s: IntegerSchema => 131 | if ("int64" == s.getFormat) Type.Int64 132 | else if ("date-time" == s.getFormat) Type.DateTime(Type.TimeRepr.Number) 133 | else if ("date" == s.getFormat) Type.Date(Type.TimeRepr.Number) 134 | else Type.Int32 135 | case s: NumberSchema => 136 | s.getFormat.nullToEmpty.toLowerCase match { 137 | case "float" => Type.Float32 138 | case "double" => Type.Float64 139 | case _ => Type.BigDecimal 140 | } 141 | case _: PasswordSchema => 142 | Type.String 143 | case _: EmailSchema => 144 | Type.String 145 | case s: StringSchema if "url".equalsIgnoreCase(s.getFormat) => 146 | Type.Url 147 | case s: StringSchema if "uri".equalsIgnoreCase(s.getFormat) => 148 | Type.Uri 149 | case _: StringSchema => 150 | Type.String 151 | case _: UUIDSchema => 152 | Type.Uuid 153 | case s: ObjectSchema if s.getAdditionalProperties != null => 154 | s.getAdditionalProperties match { 155 | case ps: Schema[_] => 156 | Type.Map(Type.String, schemaType(ps)) 157 | case _ => 158 | sys.error( 159 | "An object schema with value types `Object` and `AnyRef`, respectively, is not supported." 160 | ) 161 | } 162 | case s: ObjectSchema 163 | if s.getAdditionalProperties == null && Option(s.getFormat).exists( 164 | _.equalsIgnoreCase("json") 165 | ) => 166 | Type.Json 167 | 168 | case s: MapSchema if s.getAdditionalProperties != null => 169 | s.getAdditionalProperties match { 170 | case ps: Schema[_] => 171 | Type.Map(Type.String, schemaType(ps)) 172 | case _ => 173 | sys.error( 174 | "An object schema with value types `Object` and `AnyRef`, respectively, is not supported." 175 | ) 176 | } 177 | case s if s.get$ref != null => 178 | Type.Ref(s.get$ref.split('/').last) 179 | 180 | case cs: ComposedSchema 181 | if Option(cs.getAllOf()).exists(_.size == 1) || 182 | Option(cs.getAnyOf()).exists(_.size == 1) || 183 | Option(cs.getOneOf()).exists(_.size == 1) => 184 | def getFirst(l: java.util.List[Schema[_]]) = 185 | Option(l).filter(!_.isEmpty).map(_.get(0)) 186 | 187 | val singleEntry = getFirst(cs.getAllOf()) 188 | .orElse(getFirst(cs.getAnyOf())) 189 | .orElse(getFirst(cs.getOneOf())) 190 | .getOrElse(sys.error("No single schema found")) 191 | 192 | schemaType(singleEntry) 193 | 194 | case _ => 195 | sys.error(s"Unsupported schema: $sch") 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/impl/ScalaCode.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi.impl 2 | 3 | import com.github.eikek.sbt.openapi.PartConv._ 4 | import com.github.eikek.sbt.openapi._ 5 | 6 | object ScalaCode { 7 | val primitiveTypeMapping: TypeMapping = 8 | TypeMapping( 9 | Type.Bool -> TypeDef("Boolean", Imports.empty), 10 | Type.String -> TypeDef("String", Imports.empty), 11 | Type.Int32 -> TypeDef("Int", Imports.empty), 12 | Type.Int64 -> TypeDef("Long", Imports.empty), 13 | Type.Float32 -> TypeDef("Float", Imports.empty), 14 | Type.Float64 -> TypeDef("Double", Imports.empty), 15 | Type.BigDecimal -> TypeDef("BigDecimal", Imports.empty), 16 | Type.Uuid -> TypeDef("UUID", Imports("java.util.UUID")), 17 | Type.Url -> TypeDef("URL", Imports("java.net.URL")), 18 | Type.Uri -> TypeDef("URI", Imports("java.net.URI")), 19 | Type.Date(Type.TimeRepr.String) -> TypeDef( 20 | "LocalDate", 21 | Imports("java.time.LocalDate") 22 | ), 23 | Type.Date(Type.TimeRepr.Number) -> TypeDef( 24 | "LocalDate", 25 | Imports("java.time.LocalDate") 26 | ), 27 | Type.DateTime(Type.TimeRepr.String) -> TypeDef( 28 | "LocalDateTime", 29 | Imports("java.time.LocalDateTime") 30 | ), 31 | Type.DateTime(Type.TimeRepr.Number) -> TypeDef( 32 | "LocalDateTime", 33 | Imports("java.time.LocalDateTime") 34 | ), 35 | Type.Json -> TypeDef("Json", Imports.empty) 36 | ) 37 | 38 | def defaultTypeMapping(cm: CustomMapping): TypeMapping = { 39 | case Type.Sequence(param) => 40 | defaultTypeMapping(cm)(param).map(el => 41 | cm.changeType(TypeDef(s"List[${el.name}]", el.imports)) 42 | ) 43 | case Type.Map(key, value) => 44 | for { 45 | k <- defaultTypeMapping(cm)(key) 46 | v <- defaultTypeMapping(cm)(value) 47 | } yield cm.changeType(TypeDef(s"Map[${k.name},${v.name}]", k.imports ++ v.imports)) 48 | case Type.Ref(name) => 49 | val srcRef = SingularSchemaClass(name) 50 | Some(TypeDef(resolveSchema(srcRef, cm).name, Imports.empty)) 51 | case t => 52 | primitiveTypeMapping(t).map(cm.changeType) 53 | } 54 | 55 | def enclosingObject( 56 | enclosingTraitName: String, 57 | cfg: ScalaConfig 58 | ): PartConv[SourceFile] = { 59 | val parents: PartConv[List[Superclass]] = 60 | listSplit( 61 | constant[Superclass]("extends") ~ superclass, 62 | constant("with") ~ forListSep(superclass, Part(", ")) 63 | ) 64 | 65 | val fieldPart: PartConv[Field] = { 66 | val prefix = cfg.modelType match { 67 | case ScalaModelType.CaseClass => "" 68 | case ScalaModelType.Trait => "val " 69 | } 70 | cond( 71 | f => f.nullablePrimitive, 72 | PartConv.of(prefix) + fieldName + PartConv.of(": Option[") + fieldType + PartConv 73 | .of("]"), 74 | PartConv.of(prefix) + fieldName + PartConv.of(": ") + fieldType 75 | ) 76 | } 77 | 78 | val internalModel = cfg.modelType match { 79 | case ScalaModelType.CaseClass => 80 | constant("case class") ~ sourceName ~ forList(annotation, _ ++ _) 81 | .contramap[SourceFile](_.ctorAnnot) ~ constant("(") ++ 82 | forListSep(fieldPart, Part(", ")) 83 | .map(_.indent(2)) 84 | .contramap(_.fields.filterNot(_.prop.discriminator)) ++ 85 | constant(s") extends $enclosingTraitName") 86 | case ScalaModelType.Trait => 87 | constant("trait") ~ sourceName ~ forList(annotation, _ ++ _) 88 | .contramap[SourceFile](_.ctorAnnot) ~ constant( 89 | s"extends $enclosingTraitName" 90 | ) ~ constant("{") ++ 91 | forList(fieldPart, _ ++ _) 92 | .map(_.indent(2)) 93 | .contramap(_.fields.filterNot(_.prop.discriminator)) ++ 94 | constant(s"}") 95 | } 96 | 97 | val internalModels: PartConv[SourceFile] = 98 | forList(internalModel, _ ++ _).contramap(_.internalSchemas) 99 | 100 | val discriminantImports = cfg.modelType match { 101 | case ScalaModelType.CaseClass => 102 | constant( 103 | "implicit val customConfig: io.circe.generic.extras.Configuration = io.circe.generic.extras.Configuration.default.withDefaults.withDiscriminator(\"" 104 | ).map(_.indent(2)) + discriminantType + constant("\")") 105 | case ScalaModelType.Trait => 106 | PartConv.empty[SourceFile] 107 | } 108 | 109 | constant("object") ~ sourceName ~ forList(annotation, _ ++ _) 110 | .contramap[SourceFile](_.ctorAnnot) ~ constant("{") ++ discriminantImports ++ 111 | internalModels.map(_.indent(2)) ++ 112 | cfg.json.companion.map(_.indent(2)) ++ 113 | constant("}") ~ parents.map(_.newline).contramap(_.parents) 114 | } 115 | 116 | def sealedTrait: PartConv[SourceFile] = { 117 | val fieldPart: PartConv[Field] = 118 | cond( 119 | f => f.nullablePrimitive, 120 | constant("val") ~ fieldName + PartConv.of(": Option[") + fieldType + PartConv.of( 121 | "]" 122 | ), 123 | constant("val") ~ fieldName + PartConv.of(": ") + fieldType 124 | ) 125 | val parents: PartConv[List[Superclass]] = 126 | listSplit( 127 | constant[Superclass]("extends") ~ superclass, 128 | constant("with") ~ forListSep(superclass, Part(", ")) 129 | ) 130 | constant("sealed trait") ~ sourceName ~ forList(annotation, _ ++ _) 131 | .contramap[SourceFile](_.ctorAnnot) ~ constant("{") ++ 132 | forListSep(fieldPart, Part("; ")) 133 | .map(_.indent(2)) 134 | .contramap(_.fields.filterNot(_.prop.discriminator)) ++ 135 | constant("}") ~ parents.map(_.newline).contramap(_.parents) 136 | } 137 | 138 | def caseClass: PartConv[SourceFile] = { 139 | val fieldPart: PartConv[Field] = 140 | cond( 141 | f => f.nullablePrimitive, 142 | fieldName + PartConv.of(": Option[") + fieldType + PartConv.of("]"), 143 | fieldName + PartConv.of(": ") + fieldType 144 | ) 145 | val parents: PartConv[List[Superclass]] = 146 | listSplit( 147 | constant[Superclass]("extends") ~ superclass, 148 | constant("with") ~ forListSep(superclass, Part(", ")) 149 | ) 150 | constant("case class") ~ sourceName ~ forList(annotation, _ ++ _) 151 | .contramap[SourceFile](_.ctorAnnot) ~ constant("(") ++ 152 | forListSep(fieldPart, Part(", ")).map(_.indent(2)).contramap(_.fields) ++ 153 | constant(")") ~ parents.map(_.newline).contramap(_.parents) 154 | } 155 | 156 | def traitCode: PartConv[SourceFile] = { 157 | val fieldPart: PartConv[Field] = 158 | cond( 159 | f => f.nullablePrimitive, 160 | PartConv.of("val ") + fieldName + PartConv.of(": Option[") + fieldType + PartConv 161 | .of("]"), 162 | PartConv.of("val ") + fieldName + PartConv.of(": ") + fieldType 163 | ) 164 | val parents: PartConv[List[Superclass]] = 165 | listSplit( 166 | constant[Superclass]("extends") ~ superclass, 167 | constant("with") ~ forListSep(superclass, Part(", ")) 168 | ) 169 | constant("trait") ~ sourceName ~ forList(annotation, _ ++ _) 170 | .contramap[SourceFile](_.ctorAnnot) ~ parents 171 | .map(_.newline) 172 | .contramap(_.parents) ~ constant("{") ++ 173 | forList(fieldPart, _ ++ _).map(_.indent(2)).contramap(_.fields) ++ 174 | constant("}") 175 | } 176 | 177 | def fileHeader: PartConv[SourceFile] = 178 | pkg.contramap[SourceFile](_.pkg) ++ 179 | imports.map(_.newline).contramap(_.imports) ++ 180 | doc.contramap(_.doc) 181 | 182 | def generate(sc: SchemaClass, pkg: Pkg, cfg: ScalaConfig): (String, String) = 183 | sc match { 184 | case _: SingularSchemaClass => 185 | val src = resolveSchema(sc, cfg.mapping).copy(pkg = pkg).modify(cfg.json.resolve) 186 | val scalaModel = cfg.modelType match { 187 | case ScalaModelType.CaseClass => caseClass 188 | case ScalaModelType.Trait => traitCode 189 | } 190 | val conv = fileHeader ++ scalaModel ++ cfg.json.companion 191 | (src.name, conv.toPart(src).render) 192 | case _: DiscriminantSchemaClass => 193 | val src = resolveSchema(sc, cfg.mapping).copy(pkg = pkg).modify(cfg.json.resolve) 194 | val conv = fileHeader ++ sealedTrait ++ enclosingObject(src.name, cfg) 195 | (src.name, conv.toPart(src).render) 196 | } 197 | 198 | def resolveSchema(sc: SchemaClass, cm: CustomMapping): SourceFile = { 199 | val tm = defaultTypeMapping(cm) 200 | SchemaClass.resolve(sc, Pkg(""), tm, cm) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /plugin/src/main/scala/com/github/eikek/sbt/openapi/OpenApiSchema.scala: -------------------------------------------------------------------------------- 1 | package com.github.eikek.sbt.openapi 2 | 3 | import scala.concurrent.duration._ 4 | 5 | import _root_.io.swagger.codegen.v3.cli.SwaggerCodegen 6 | import com.github.eikek.sbt.openapi.impl._ 7 | import sbt.Keys._ 8 | import sbt._ 9 | 10 | object OpenApiSchema extends AutoPlugin { 11 | 12 | object autoImport { 13 | sealed trait Language { 14 | self: Product => 15 | def extension: String = productPrefix.toLowerCase 16 | } 17 | object Language { 18 | case object Scala extends Language 19 | case object Elm extends Language 20 | } 21 | 22 | sealed trait OpenApiDocGenerator {} 23 | object OpenApiDocGenerator { 24 | case object Swagger extends OpenApiDocGenerator 25 | case object Redoc extends OpenApiDocGenerator 26 | } 27 | 28 | val openapiSpec = settingKey[File]("The openapi specification") 29 | val openapiPackage = settingKey[Pkg]("The package to place the generated files into") 30 | val openapiScalaConfig = 31 | settingKey[ScalaConfig]("Configuration for generating scala files") 32 | val openapiElmConfig = settingKey[ElmConfig]("Configuration for generating elm files") 33 | val openapiTargetLanguage = 34 | settingKey[Language]("The target language: either Language.Scala or Language.Elm.") 35 | val openapiOutput = settingKey[File]("The directory where files are generated") 36 | val openapiCodegen = taskKey[Seq[File]]("Run the code generation") 37 | 38 | val openapiRedoclyConfig = 39 | settingKey[Option[File]]("The config file to use for redocly/cli") 40 | val openapiRedoclyCmd = settingKey[Seq[String]]( 41 | "The redocli-cli command to use. Defaults to ['npx', '@redocly/cli']." 42 | ) 43 | 44 | val openapiStaticDoc = taskKey[File]("Generate a static HTML documentation") 45 | val openapiStaticGen = settingKey[OpenApiDocGenerator]( 46 | "The documentation generator to user. Possible values OpenApiDocGenerator.[Swagger,Redoc]. Default is Swagger, because Redoc requires Nodejs installed." 47 | ) 48 | val openapiStaticOut = 49 | settingKey[File]("The target directory for static documentation") 50 | val openapiLint = taskKey[Unit]( 51 | "Runs the redoc openapi-cli linter against the openapi spec." 52 | ) 53 | } 54 | 55 | import autoImport._ 56 | 57 | val defaultSettings = Seq( 58 | openapiPackage := Pkg("org.myapi"), 59 | openapiScalaConfig := ScalaConfig(), 60 | openapiElmConfig := ElmConfig(), 61 | openapiOutput := { 62 | openapiTargetLanguage.value match { 63 | case Language.Elm => (Compile / target).value / "elm-src" 64 | case _ => (Compile / sourceManaged).value 65 | } 66 | }, 67 | openapiRedoclyCmd := Seq("npx", "@redocly/cli"), 68 | openapiRedoclyConfig := None, 69 | openapiCodegen := { 70 | val out = openapiOutput.value 71 | val logger = streams.value.log 72 | val cfgScala = openapiScalaConfig.value 73 | val cfgElm = openapiElmConfig.value 74 | val spec = openapiSpec.value 75 | val pkg = openapiPackage.value 76 | val lang = openapiTargetLanguage.value 77 | generateCode(logger, out, lang, cfgScala, cfgElm, spec, pkg) 78 | }, 79 | openapiStaticOut := (Compile / resourceManaged).value / "openapiDoc", 80 | openapiStaticGen := OpenApiDocGenerator.Swagger, 81 | openapiStaticDoc := { 82 | val logger = streams.value.log 83 | val out = openapiStaticOut.value 84 | val spec = openapiSpec.value 85 | val gen = openapiStaticGen.value 86 | val config = openapiRedoclyConfig.value 87 | val redocly = openapiRedoclyCmd.value 88 | createOpenapiStaticDoc(logger, spec, gen, out, redoclyCmd(redocly, config)) 89 | }, 90 | openapiLint := { 91 | val logger = streams.value.log 92 | val spec = openapiSpec.value 93 | val config = openapiRedoclyConfig.value 94 | val redocly = openapiRedoclyCmd.value 95 | runOpenapiLinter(logger, spec, redoclyCmd(redocly, config)) 96 | } 97 | ) 98 | 99 | override def projectSettings = 100 | defaultSettings ++ Seq( 101 | Compile / sourceGenerators ++= { 102 | if (openapiTargetLanguage.value == Language.Elm) Seq.empty 103 | else Seq((Compile / openapiCodegen).taskValue) 104 | } 105 | ) 106 | 107 | def generateCode( 108 | logger: Logger, 109 | out: File, 110 | lang: Language, 111 | cfgScala: ScalaConfig, 112 | cfgElm: ElmConfig, 113 | spec: File, 114 | pkg: Pkg 115 | ): Seq[File] = { 116 | 117 | val targetPath = pkg.name.split('.').foldLeft(out)(_ / _) 118 | IO.createDirectories(Seq(targetPath)) 119 | 120 | val schemas: Seq[SingularSchemaClass] = Parser.parse(spec.toString).values.toList 121 | val groupedSchemas = groupDiscriminantSchemas(schemas) 122 | 123 | val files = groupedSchemas.map { sc => 124 | val (name, code) = (lang, sc) match { 125 | case (Language.Scala, _) => ScalaCode.generate(sc, pkg, cfgScala) 126 | case (Language.Elm, _: SingularSchemaClass) => ElmCode.generate(sc, pkg, cfgElm) 127 | case _ => sys.error(s"Elm not yet supported for discriminants") 128 | } 129 | val file = targetPath / (name + "." + lang.extension) 130 | if (!file.exists || IO.read(file) != code) { 131 | logger.info(s"Writing file $file") 132 | IO.write(file, code) 133 | } 134 | file 135 | } 136 | 137 | IO.listFiles(targetPath).filter(f => !files.contains(f)).foreach { f => 138 | logger.info(s"Deleting unused file $f") 139 | IO.delete(f) 140 | } 141 | 142 | files 143 | } 144 | 145 | def groupDiscriminantSchemas( 146 | parserResult: Seq[SingularSchemaClass] 147 | ): Seq[SchemaClass] = { 148 | val allSchemas = parserResult.map { ssc => 149 | val referencing = parserResult.find(_.oneOfRef.contains(ssc.name)) 150 | referencing match { 151 | case Some(ref) => ssc.copy(allOfRef = Some(ref.name)) 152 | case None => ssc 153 | } 154 | } 155 | 156 | val discriminantSchemasMap = 157 | allSchemas.filter(_.allOfRef.isDefined).groupBy(_.allOfRef.get) 158 | val discriminantSchemas = discriminantSchemasMap.map { 159 | case (allOfRef, childSchemas) => 160 | val allOfRoot = allSchemas.collectFirst { 161 | case ssc if ssc.name == allOfRef => ssc 162 | }.get 163 | DiscriminantSchemaClass( 164 | allOfRoot.name, 165 | allOfRoot.properties, 166 | allOfRoot.doc, 167 | allOfRoot.wrapper, 168 | childSchemas.toList 169 | ) 170 | }.toSeq 171 | 172 | val singularSchemas: Seq[SingularSchemaClass] = allSchemas 173 | .filterNot(ssc => discriminantSchemasMap.contains(ssc.name)) 174 | .filterNot(ssc => 175 | discriminantSchemasMap.values.toList.flatten.map(_.name).contains(ssc.name) 176 | ) 177 | 178 | singularSchemas ++ discriminantSchemas 179 | } 180 | 181 | private def redoclyCmd(base: Seq[String], config: Option[File]): Seq[String] = 182 | config.map(c => base ++ Seq("--config", c.toString)).getOrElse(base) 183 | 184 | def createOpenapiStaticDoc( 185 | logger: Logger, 186 | openapi: File, 187 | gen: OpenApiDocGenerator, 188 | out: File, 189 | redoclyCmd: Seq[String] 190 | ): File = 191 | gen match { 192 | case OpenApiDocGenerator.Swagger => 193 | createOpenapiStaticDocSwagger(logger, openapi, out) 194 | case OpenApiDocGenerator.Redoc => 195 | createOpenapiStaticDocRedoc(logger, openapi, out, redoclyCmd) 196 | } 197 | 198 | def createOpenapiStaticDocRedoc( 199 | logger: Logger, 200 | openapi: File, 201 | out: File, 202 | redoclyCmd: Seq[String] 203 | ): File = { 204 | logger.info("Generating static documentation for openapi spec via redoc…") 205 | val outFile = out / "index.html" 206 | val cmd = redoclyCmd ++ Seq("build-docs", openapi.toString, "-o", outFile.toString) 207 | Sys(logger).execSuccess(cmd) 208 | if (!out.exists) { 209 | sys.error("Generation did not produce a file") 210 | } 211 | outFile 212 | } 213 | 214 | def createOpenapiStaticDocSwagger( 215 | logger: Logger, 216 | openapi: File, 217 | out: File 218 | ): File = { 219 | val cl = Thread.currentThread.getContextClassLoader 220 | val command = 221 | Seq("generate", "-i", openapi.toString, "-l", "html2", "-o", out.toString) 222 | logger.info(s"Creating static html rest documentation: ${command.toList}") 223 | IO.createDirectory(out) 224 | val file = out / "index.html" 225 | Thread.currentThread.setContextClassLoader(classOf[SwaggerCodegen].getClassLoader) 226 | try { 227 | SwaggerCodegen.main(command.toArray) 228 | // the above command starts a new thread that does the work so 229 | // the call returns immediately, but the file is still being 230 | // generated 231 | val sw = Stopwatch.start 232 | while (!file.exists && sw.isBelow(20.seconds)) 233 | Thread.sleep(100) 234 | } finally Thread.currentThread.setContextClassLoader(cl) 235 | if (!file.exists) { 236 | sys.error( 237 | s"Documentation generation failed. No file produced. '$file' doesn't exist." 238 | ) 239 | } 240 | logger.info(s"Generated static file $file") 241 | file 242 | } 243 | 244 | def runOpenapiLinter(logger: Logger, openapi: File, redoclyCmd: Seq[String]): Unit = 245 | Sys(logger).execSuccess(redoclyCmd ++ Seq("lint", openapi.toString)) 246 | 247 | final case class Stopwatch(start: Long) { 248 | def isBelow(fd: FiniteDuration): Boolean = 249 | Duration.fromNanos(System.nanoTime - start) < fd 250 | } 251 | object Stopwatch { 252 | def start: Stopwatch = Stopwatch(System.nanoTime) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SBT OpenApi Schema Codegen 2 | 3 | [![CI](https://github.com/eikek/sbt-openapi-schema/actions/workflows/ci.yml/badge.svg)](https://github.com/eikek/sbt-openapi-schema/actions/workflows/ci.yml) 4 | [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org) 5 | [![License](https://img.shields.io/github/license/eikek/sbt-openapi-schema.svg?style=flat-square&color=steelblue)](https://github.com/eikek/sbt-openapi-schema/blob/master/LICENSE.txt) 6 | 7 | 8 | This is an sbt plugin to generate Scala or Elm code given an openapi 9 | 3.x specification. Unlike other codegen tools, this focuses only on 10 | the `#/components/schema` section. Also, it generates immutable 11 | classes and optionally the corresponding JSON conversions. 12 | 13 | - Scala: `case class`es are generated and JSON conversion via circe. 14 | - Elm: records are generated and constructors for "empty" values. It 15 | works only for objects. JSON conversion is generated using Elm's 16 | default encoding support and the 17 | [json-decode-pipeline](https://github.com/NoRedInk/elm-json-decode-pipeline) 18 | module for decoding. 19 | - JSON support is optional. 20 | 21 | The implementation is based on the 22 | [swagger-parser](https://github.com/swagger-api/swagger-parser) 23 | project. 24 | 25 | It is possible to customize the code generation. 26 | 27 | ## Usage 28 | 29 | Add this plugin to your build in `project/plugins.sbt`: 30 | 31 | ``` 32 | addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % "x.y.z") 33 | ``` 34 | 35 | Please check the git tags or maven central for the current version. 36 | Then enable the plugin in some project: 37 | 38 | ``` 39 | enablePlugins(OpenApiSchema) 40 | ``` 41 | 42 | There are two required settings: `openapiSpec` and 43 | `openapiTargetLanguage`. The first defines the openapi.yml file and 44 | the other is a constant from the `Language` object: 45 | 46 | ```scala 47 | import com.github.eikek.sbt.openapi._ 48 | 49 | project. 50 | enablePlugins(OpenApiSchema). 51 | settings( 52 | openapiTargetLanguage := Language.Scala 53 | openapiSpec := (Compile/resourceDirectory).value/"test.yml" 54 | ) 55 | ``` 56 | 57 | The sources are automatically generated when you run `compile`. The 58 | task `openapiCodegen` can be used to run the generation independent 59 | from the `compile` task. 60 | 61 | ## Configuration 62 | 63 | The configuration is specific to the target language. There exists a 64 | separate configuration object for Scala and Elm. 65 | 66 | The key `openapiScalaConfig` defines some configuration to customize 67 | the code generation. 68 | 69 | For Scala, it looks like this: 70 | ```scala 71 | case class ScalaConfig( 72 | mapping: CustomMapping = CustomMapping.none, 73 | json: ScalaJson = ScalaJson.none 74 | ) { 75 | 76 | def withJson(json: ScalaJson): ScalaConfig = 77 | copy(json = json) 78 | 79 | def addMapping(cm: CustomMapping): ScalaConfig = 80 | copy(mapping = mapping.andThen(cm)) 81 | 82 | def setMapping(cm: CustomMapping): ScalaConfig = 83 | copy(mapping = cm) 84 | } 85 | ``` 86 | 87 | By default, no JSON support is added to the generated classes. This 88 | can be changed via: 89 | 90 | ``` 91 | openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto) 92 | ``` 93 | 94 | This generates the encoder and decoder using 95 | [circe](https://github.com/circe/circe). Note, that this plugin 96 | doesn't change your `libraryDependencies` setting. So you need to add 97 | the circe dependencies yourself. 98 | 99 | The `CustomMapping` class allows to change the class names or use 100 | different types (for example, you might want to change `LocalDate` to 101 | `Date`). 102 | 103 | It looks like this: 104 | ```scala 105 | trait CustomMapping { self => 106 | 107 | def changeType(td: TypeDef): TypeDef 108 | 109 | def changeSource(src: SourceFile): SourceFile 110 | 111 | def andThen(cm: CustomMapping): CustomMapping = new CustomMapping { 112 | def changeType(td: TypeDef): TypeDef = 113 | cm.changeType(self.changeType(td)) 114 | 115 | def changeSource(src: SourceFile): SourceFile = 116 | cm.changeSource(self.changeSource(src)) 117 | } 118 | } 119 | ``` 120 | 121 | There are convenient constructors in its companion object. 122 | 123 | It allows to use different types via `changeType` or change the source 124 | file. Here is a `build.sbt` example snippet: 125 | 126 | ```scala 127 | import com.github.eikek.sbt.openapi._ 128 | 129 | val CirceVersion = "0.14.1" 130 | libraryDependencies ++= Seq( 131 | "io.circe" %% "circe-generic" % CirceVersion, 132 | "io.circe" %% "circe-parser" % CirceVersion 133 | ) 134 | 135 | openapiSpec := (Compile/resourceDirectory).value/"test.yml" 136 | openapiTargetLanguage := Language.Scala 137 | Compile/openapiScalaConfig := ScalaConfig() 138 | .withJson(ScalaJson.circeSemiauto) 139 | .addMapping(CustomMapping.forType({ case TypeDef("LocalDateTime", _) => 140 | TypeDef("Timestamp", Imports("com.mypackage.Timestamp")) 141 | })) 142 | .addMapping(CustomMapping.forName({ case s => s + "Dto" })) 143 | 144 | enablePlugins(OpenApiSchema) 145 | ``` 146 | 147 | It adds circe JSON support and changes the name of all classes by 148 | appending the suffix "Dto". It also changes the type used for local 149 | dates to be `com.mypackage.Timestamp`. 150 | 151 | 152 | ## Elm 153 | 154 | There is some experimental support for generating Elm data structures 155 | and corresponding JSON conversion functions. When using the 156 | `decodePipeline` json variant, you need to install these packages: 157 | 158 | ``` 159 | elm install elm/json 160 | elm install NoRedInk/elm-json-decode-pipeline 161 | ``` 162 | 163 | The default output path for elm sources is `target/elm-src`. So in 164 | your `elm.json` file, add this directory to the `source-directories` 165 | list along with the main source dir. It may look something like this: 166 | 167 | ```json 168 | { 169 | "type": "application", 170 | "source-directories": [ 171 | "modules/webapp/target/elm-src", 172 | "modules/webapp/src/main/elm" 173 | ], 174 | "elm-version": "0.19.0", 175 | "dependencies": { 176 | "direct": { 177 | "NoRedInk/elm-json-decode-pipeline": "1.0.0", 178 | "elm/browser": "1.0.1", 179 | "elm/core": "1.0.2", 180 | "elm/html": "1.0.0", 181 | "elm/json": "1.1.3" 182 | }, 183 | "indirect": { 184 | "elm/time": "1.0.0", 185 | "elm/url": "1.0.0", 186 | "elm/virtual-dom": "1.0.2" 187 | } 188 | }, 189 | "test-dependencies": { 190 | "direct": {}, 191 | "indirect": {} 192 | } 193 | } 194 | ``` 195 | 196 | It always generates type aliases for records. 197 | 198 | While source files for scala are added to sbt's `sourceGenerators` so 199 | that they get compiled with your sources, the elm source files are not 200 | added anywhere, because there is no support for Elm in sbt. However, 201 | in the `build.sbt` file, you can tell sbt to generate the files before 202 | compiling your elm app. This can be configured to run during resource 203 | generation. Example: 204 | 205 | ``` scala 206 | 207 | // Put resulting js file into the webjar location 208 | def compileElm(logger: Logger, wd: File, outBase: File, artifact: String, version: String): Seq[File] = { 209 | logger.info("Compile elm files ...") 210 | val target = outBase/"META-INF"/"resources"/"webjars"/artifact/version/"my-app.js" 211 | val proc = Process(Seq("elm", "make", "--output", target.toString) ++ Seq(wd/"src"/"main"/"elm"/"Main.elm").map(_.toString), Some(wd)) 212 | val out = proc.!! 213 | logger.info(out) 214 | Seq(target) 215 | } 216 | 217 | val webapp = project.in(file("webapp")). 218 | enablePlugins(OpenApiSchema). 219 | settings( 220 | openapiTargetLanguage := Language.Elm, 221 | openapiPackage := Pkg("Api.Model"), 222 | openapiSpec := (Compile/resourceDirectory).value/"openapi.yml", 223 | openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline), 224 | Compile/resourceGenerators += (Def.task { 225 | openapiCodegen.value // generate api model files 226 | compileElm(streams.value.log 227 | , (Compile/baseDirectory).value 228 | , (Compile/resourceManaged).value 229 | , name.value 230 | , version.value) 231 | }).taskValue, 232 | watchSources += Watched.WatchSource( 233 | (Compile/sourceDirectory).value/"elm" 234 | , FileFilter.globFilter("*.elm") 235 | , HiddenFileFilter 236 | ) 237 | ) 238 | ``` 239 | 240 | This example assumes a `elm.json` project file in the source root. 241 | 242 | ## Discriminator Support 243 | 244 | OpenAPI 3.0 enables to introduce subtyping on generated schemas by using [discriminators](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#discriminatorObject). 245 | 246 | Two of these are currently supported only in Scala : `oneOf` and `allOf`. 247 | 248 | #### Setup 249 | 250 | In order to provide JSON conversion for these discriminators with Circe, we need to make use of [circe-generic-extras](https://github.com/circe/circe-generic-extras) 251 | 252 | An example build.sbt using the plugin would look like the following: 253 | ```scala 254 | import com.github.eikek.sbt.openapi._ 255 | 256 | libraryDependencies ++= Seq( 257 | "io.circe" %% "circe-generic-extras" % "0.11.1", 258 | "io.circe" %% "circe-core" % "0.11.1", 259 | "io.circe" %% "circe-generic" % "0.11.1", 260 | "io.circe" %% "circe-parser" % "0.11.1" 261 | ) 262 | 263 | openapiSpec := (Compile/resourceDirectory).value/"test.yml" 264 | openapiTargetLanguage := Language.Scala 265 | openapiScalaConfig := ScalaConfig(). 266 | withJson(ScalaJson.circeSemiautoExtra). 267 | addMapping(CustomMapping.forName({ case s => s + "Dto" })) 268 | 269 | enablePlugins(OpenApiSchema) 270 | ``` 271 | 272 | 273 | #### Handle `allOf` keywords in Scala 274 | 275 | Here is an example OpenAPI spec and the resulting Scala models with JSON conversions 276 | 277 | ```yaml 278 | components: 279 | schemas: 280 | Pet: 281 | type: object 282 | discriminator: 283 | propertyName: petType 284 | properties: 285 | name: 286 | type: string 287 | petType: 288 | type: string 289 | required: 290 | - name 291 | - petType 292 | Cat: ## "Cat" will be used as the discriminator value 293 | description: A representation of a cat 294 | allOf: 295 | - $ref: '#/components/schemas/Pet' 296 | - type: object 297 | properties: 298 | huntingSkill: 299 | type: string 300 | description: The measured skill for hunting 301 | required: 302 | - huntingSkill 303 | Dog: ## "Dog" will be used as the discriminator value 304 | description: A representation of a dog 305 | allOf: 306 | - $ref: '#/components/schemas/Pet' 307 | - type: object 308 | properties: 309 | packSize: 310 | type: integer 311 | format: int32 312 | description: the size of the pack the dog is from 313 | required: 314 | - packSize 315 | ``` 316 | 317 | ```scala 318 | import io.circe._ 319 | import io.circe.generic.extras.semiauto._ 320 | import io.circe.generic.extras.Configuration 321 | 322 | sealed trait PetDto { 323 | val name: String 324 | } 325 | object PetDto { 326 | implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType") 327 | 328 | case class Cat ( 329 | huntingSkill: String, name: String 330 | ) extends PetDto 331 | 332 | case class Dog ( 333 | packSize: Int, name: String 334 | ) extends PetDto 335 | 336 | object Cat { 337 | implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType") 338 | private implicit val jsonDecoder: Decoder[Cat] = deriveDecoder[Cat] 339 | private implicit val jsonEncoder: Encoder[Cat] = deriveEncoder[Cat] 340 | } 341 | 342 | object Dog { 343 | implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType") 344 | private implicit val jsonDecoder: Decoder[Dog] = deriveDecoder[Dog] 345 | private implicit val jsonEncoder: Encoder[Dog] = deriveEncoder[Dog] 346 | } 347 | 348 | implicit val jsonDecoder: Decoder[PetDto] = deriveDecoder[PetDto] 349 | implicit val jsonEncoder: Encoder[PetDto] = deriveEncoder[PetDto] 350 | } 351 | 352 | ``` 353 | 354 | Notes about the above example: 355 | - The internal schemas ("Dog" and "Cat") have private encoder/decoders so that they are only encoded and decoded as the trait interface. If you try to decode as a Dog or Cat type, the circe-generic-extras doesn't include the discriminant type 356 | - The mapping functionality (adding "Dto") is only used on the sealed trait since the discriminant type uses the name of the inner case classes ("Dog" and "Cat"). 357 | 358 | #### Handle `oneOf` keywords in Scala 359 | 360 | Another way of transform composed schemas into `sealed trait` hierarchies is to use `oneOf`. 361 | 362 | ```yaml 363 | Pet: 364 | type: object 365 | discriminator: 366 | propertyName: petType 367 | oneOf: 368 | - $ref: '#/components/schemas/Cat' 369 | - $ref: '#/components/schemas/Dog' 370 | Cat: ## "Cat" will be used as the discriminator value 371 | description: A representation of a cat 372 | properties: 373 | huntingSkill: 374 | type: string 375 | description: The measured skill for hunting 376 | enum: 377 | - clueless 378 | - lazy 379 | - adventurous 380 | - aggressive 381 | name: 382 | type: string 383 | petType: 384 | type: string 385 | required: 386 | - huntingSkill 387 | - name 388 | - petType 389 | Dog: ## "Dog" will be used as the discriminator value 390 | description: A representation of a dog 391 | properties: 392 | packSize: 393 | type: integer 394 | format: int32 395 | description: the size of the pack the dog is from 396 | default: 0 397 | minimum: 0 398 | name: 399 | type: string 400 | petType: 401 | type: string 402 | required: 403 | - packSize 404 | - name 405 | - petType 406 | ``` 407 | 408 | ```scala 409 | import io.circe._ 410 | import io.circe.generic.extras.semiauto._ 411 | import io.circe.generic.extras.Configuration 412 | 413 | sealed trait PetDto { 414 | } 415 | object PetDto { 416 | implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType") 417 | case class Cat ( 418 | huntingSkill: String, name: String, petType: String 419 | ) extends PetDto 420 | case class Dog ( 421 | packSize: Int, name: String, petType: String 422 | ) extends PetDto 423 | object Cat { 424 | implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType") 425 | private implicit val jsonDecoder: Decoder[Cat] = deriveDecoder[Cat] 426 | private implicit val jsonEncoder: Encoder[Cat] = deriveEncoder[Cat] 427 | } 428 | object Dog { 429 | implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType") 430 | private implicit val jsonDecoder: Decoder[Dog] = deriveDecoder[Dog] 431 | private implicit val jsonEncoder: Encoder[Dog] = deriveEncoder[Dog] 432 | } 433 | implicit val jsonDecoder: Decoder[PetDto] = deriveDecoder[PetDto] 434 | implicit val jsonEncoder: Encoder[PetDto] = deriveEncoder[PetDto] 435 | } 436 | ``` 437 | 438 | Unlike `allOf`, `oneOf` doesn't permit subschemas to inherit fields from their parent. This kind of relation fits well to algebraic data types encodings in Scala. 439 | 440 | ## Static Documentation 441 | 442 | The plugin can run the [swagger codegen 443 | tool](https://github.com/swagger-api/swagger-codegen) or 444 | [redoc](https://github.com/Redocly/redoc) to produce a static HTML 445 | page of the OpenAPI specification file. 446 | 447 | Define which generator to use via: 448 | 449 | ``` scala 450 | openapiStaticGen := OpenApiDocGenerator.Redoc //or 451 | openapiStaticGen := OpenApiDocGenerator.Swagger 452 | ``` 453 | 454 | Note that nodejs (the `npx` command) is required for redoc! The 455 | default is swagger. 456 | 457 | Then use the `openapiStaticDoc` task to generate the documentation 458 | from your openapi specification. 459 | 460 | 461 | Additionally, there is also a task that runs [`openapi-cli 462 | lint`](https://redoc.ly/docs/cli/) against your specification file. 463 | This also requires to have nodejs installed. 464 | 465 | 466 | 467 | ## Credits 468 | 469 | First, thank you all who reported issues! It follows a list of 470 | contributions in form of code. If you find yourself missing, please 471 | let me know or open a PR. 472 | 473 | - @xela85 for adding `oneOf` keywords support (#42) 474 | - @mhertogs for adding support for discriminators (#8) 475 | --------------------------------------------------------------------------------