This is an sbt plugin to generate Scala or Elm code given an openapi
3.x specification. Unlike other codegen tools, this focuses only on
the #/components/schema
section. Also, it generates immutable
classes and optionally the corresponding JSON conversions.
- Scala:
case class
es are generated and JSON conversion via circe. - Elm: records are generated and constructors for "empty" values. It works only for objects. JSON conversion is generated using Elm's default encoding support and the json-decode-pipeline module for decoding.
- JSON support is optional.
The implementation is based on the swagger-parser project.
It is possible to customize the code generation.
Add this plugin to your build in project/plugins.sbt
:
addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % "0.5.0")
Please check the git tags or maven central for the current version. Then enable the plugin in some project:
enablePlugins(OpenApiSchema)
There are two required settings: openapiSpec
and
openapiTargetLanguage
. The first defines the openapi.yml file and
the other is a constant from the Language
object:
import com.github.eikek.sbt.openapi._
project.
enablePlugins(OpenApiSchema).
settings(
openapiTargetLanguage := Language.Scala
openapiSpec := (Compile/resourceDirectory).value/"test.yml"
)
The sources are automatically generated when you run compile
. The
task openapiCodegen
can be used to run the generation independent
from the compile
task.
The configuration is specific to the target language. There exists a separate configuration object for Scala and Elm.
The key openapiScalaConfig
defines some configuration to customize
the code generation.
For Scala, it looks like this:
case class ScalaConfig(
mapping: CustomMapping = CustomMapping.none,
json: ScalaJson = ScalaJson.none
) {
def withJson(json: ScalaJson): ScalaConfig =
copy(json = json)
def addMapping(cm: CustomMapping): ScalaConfig =
copy(mapping = mapping.andThen(cm))
def setMapping(cm: CustomMapping): ScalaConfig =
copy(mapping = cm)
}
By default, no JSON support is added to the generated classes. This can be changed via:
openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto)
This generates the encoder and decoder using
circe. Note, that this plugin
doesn't change your libraryDependencies
setting. So you need to add
the circe dependencies yourself.
The CustomMapping
class allows to change the class names or use
different types (for example, you might want to change LocalDate
to
Date
).
It looks like this:
trait CustomMapping { self =>
def changeType(td: TypeDef): TypeDef
def changeSource(src: SourceFile): SourceFile
def andThen(cm: CustomMapping): CustomMapping = new CustomMapping {
def changeType(td: TypeDef): TypeDef =
cm.changeType(self.changeType(td))
def changeSource(src: SourceFile): SourceFile =
cm.changeSource(self.changeSource(src))
}
}
There are convenient constructors in its companion object.
It allows to use different types via changeType
or change the source
file. Here is a build.sbt
example snippet:
import com.github.eikek.sbt.openapi._
val CirceVersion = "0.14.1"
libraryDependencies ++= Seq(
"io.circe" %% "circe-generic" % CirceVersion,
"io.circe" %% "circe-parser" % CirceVersion
)
openapiSpec := (Compile/resourceDirectory).value/"test.yml"
openapiTargetLanguage := Language.Scala
Compile/openapiScalaConfig := ScalaConfig()
.withJson(ScalaJson.circeSemiauto)
.addMapping(CustomMapping.forType({ case TypeDef("LocalDateTime", _) =>
TypeDef("Timestamp", Imports("com.mypackage.Timestamp"))
}))
.addMapping(CustomMapping.forName({ case s => s + "Dto" }))
enablePlugins(OpenApiSchema)
It adds circe JSON support and changes the name of all classes by
appending the suffix "Dto". It also changes the type used for local
dates to be com.mypackage.Timestamp
.
There is some experimental support for generating Elm data structures
and corresponding JSON conversion functions. When using the
decodePipeline
json variant, you need to install these packages:
elm install elm/json
elm install NoRedInk/elm-json-decode-pipeline
The default output path for elm sources is target/elm-src
. So in
your elm.json
file, add this directory to the source-directories
list along with the main source dir. It may look something like this:
{
"type": "application",
"source-directories": [
"modules/webapp/target/elm-src",
"modules/webapp/src/main/elm"
],
"elm-version": "0.19.0",
"dependencies": {
"direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
"elm/browser": "1.0.1",
"elm/core": "1.0.2",
"elm/html": "1.0.0",
"elm/json": "1.1.3"
},
"indirect": {
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}
It always generates type aliases for records.
While source files for scala are added to sbt's sourceGenerators
so
that they get compiled with your sources, the elm source files are not
added anywhere, because there is no support for Elm in sbt. However,
in the build.sbt
file, you can tell sbt to generate the files before
compiling your elm app. This can be configured to run during resource
generation. Example:
// Put resulting js file into the webjar location
def compileElm(logger: Logger, wd: File, outBase: File, artifact: String, version: String): Seq[File] = {
logger.info("Compile elm files ...")
val target = outBase/"META-INF"/"resources"/"webjars"/artifact/version/"my-app.js"
val proc = Process(Seq("elm", "make", "--output", target.toString) ++ Seq(wd/"src"/"main"/"elm"/"Main.elm").map(_.toString), Some(wd))
val out = proc.!!
logger.info(out)
Seq(target)
}
val webapp = project.in(file("webapp")).
enablePlugins(OpenApiSchema).
settings(
openapiTargetLanguage := Language.Elm,
openapiPackage := Pkg("Api.Model"),
openapiSpec := (Compile/resourceDirectory).value/"openapi.yml",
openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline),
Compile/resourceGenerators += (Def.task {
openapiCodegen.value // generate api model files
compileElm(streams.value.log
, (Compile/baseDirectory).value
, (Compile/resourceManaged).value
, name.value
, version.value)
}).taskValue,
watchSources += Watched.WatchSource(
(Compile/sourceDirectory).value/"elm"
, FileFilter.globFilter("*.elm")
, HiddenFileFilter
)
)
This example assumes a elm.json
project file in the source root.
OpenAPI 3.0 enables to introduce subtyping on generated schemas by using discriminators.
Two of these are currently supported only in Scala : oneOf
and allOf
.
In order to provide JSON conversion for these discriminators with Circe, we need to make use of circe-generic-extras
An example build.sbt using the plugin would look like the following:
import com.github.eikek.sbt.openapi._
libraryDependencies ++= Seq(
"io.circe" %% "circe-generic-extras" % "0.11.1",
"io.circe" %% "circe-core" % "0.11.1",
"io.circe" %% "circe-generic" % "0.11.1",
"io.circe" %% "circe-parser" % "0.11.1"
)
openapiSpec := (Compile/resourceDirectory).value/"test.yml"
openapiTargetLanguage := Language.Scala
openapiScalaConfig := ScalaConfig().
withJson(ScalaJson.circeSemiautoExtra).
addMapping(CustomMapping.forName({ case s => s + "Dto" }))
enablePlugins(OpenApiSchema)
Here is an example OpenAPI spec and the resulting Scala models with JSON conversions
components:
schemas:
Pet:
type: object
discriminator:
propertyName: petType
properties:
name:
type: string
petType:
type: string
required:
- name
- petType
Cat: ## "Cat" will be used as the discriminator value
description: A representation of a cat
allOf:
- $ref: '#/components/schemas/Pet'
- type: object
properties:
huntingSkill:
type: string
description: The measured skill for hunting
required:
- huntingSkill
Dog: ## "Dog" will be used as the discriminator value
description: A representation of a dog
allOf:
- $ref: '#/components/schemas/Pet'
- type: object
properties:
packSize:
type: integer
format: int32
description: the size of the pack the dog is from
required:
- packSize
import io.circe._
import io.circe.generic.extras.semiauto._
import io.circe.generic.extras.Configuration
sealed trait PetDto {
val name: String
}
object PetDto {
implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType")
case class Cat (
huntingSkill: String, name: String
) extends PetDto
case class Dog (
packSize: Int, name: String
) extends PetDto
object Cat {
implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType")
private implicit val jsonDecoder: Decoder[Cat] = deriveDecoder[Cat]
private implicit val jsonEncoder: Encoder[Cat] = deriveEncoder[Cat]
}
object Dog {
implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType")
private implicit val jsonDecoder: Decoder[Dog] = deriveDecoder[Dog]
private implicit val jsonEncoder: Encoder[Dog] = deriveEncoder[Dog]
}
implicit val jsonDecoder: Decoder[PetDto] = deriveDecoder[PetDto]
implicit val jsonEncoder: Encoder[PetDto] = deriveEncoder[PetDto]
}
Notes about the above example:
- 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
- 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").
Another way of transform composed schemas into sealed trait
hierarchies is to use oneOf
.
Pet:
type: object
discriminator:
propertyName: petType
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
Cat: ## "Cat" will be used as the discriminator value
description: A representation of a cat
properties:
huntingSkill:
type: string
description: The measured skill for hunting
enum:
- clueless
- lazy
- adventurous
- aggressive
name:
type: string
petType:
type: string
required:
- huntingSkill
- name
- petType
Dog: ## "Dog" will be used as the discriminator value
description: A representation of a dog
properties:
packSize:
type: integer
format: int32
description: the size of the pack the dog is from
default: 0
minimum: 0
name:
type: string
petType:
type: string
required:
- packSize
- name
- petType
import io.circe._
import io.circe.generic.extras.semiauto._
import io.circe.generic.extras.Configuration
sealed trait PetDto {
}
object PetDto {
implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType")
case class Cat (
huntingSkill: String, name: String, petType: String
) extends PetDto
case class Dog (
packSize: Int, name: String, petType: String
) extends PetDto
object Cat {
implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType")
private implicit val jsonDecoder: Decoder[Cat] = deriveDecoder[Cat]
private implicit val jsonEncoder: Encoder[Cat] = deriveEncoder[Cat]
}
object Dog {
implicit val customConfig: Configuration = Configuration.default.withDefaults.withDiscriminator("petType")
private implicit val jsonDecoder: Decoder[Dog] = deriveDecoder[Dog]
private implicit val jsonEncoder: Encoder[Dog] = deriveEncoder[Dog]
}
implicit val jsonDecoder: Decoder[PetDto] = deriveDecoder[PetDto]
implicit val jsonEncoder: Encoder[PetDto] = deriveEncoder[PetDto]
}
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.
The plugin can run the swagger codegen tool or redoc to produce a static HTML page of the OpenAPI specification file.
Define which generator to use via:
openapiStaticGen := OpenApiDocGenerator.Redoc //or
openapiStaticGen := OpenApiDocGenerator.Swagger
Note that nodejs (the npx
command) is required for redoc! The
default is swagger.
Then use the openapiStaticDoc
task to generate the documentation
from your openapi specification.
Additionally, there is also a task that runs openapi-cli lint
against your specification file.
This also requires to have nodejs installed.
First, thank you all who reported issues! It follows a list of contributions in form of code. If you find yourself missing, please let me know or open a PR.
- @xela85 for adding
oneOf
keywords support (#42) - @mhertogs for adding support for discriminators (#8)