Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

codegen: Jsoniter support (#840) #3610

Merged
merged 8 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -1971,10 +1971,12 @@ lazy val openapiCodegenCore: ProjectMatrix = (projectMatrix in file("openapi-cod
scalaOrganization.value % "scala-reflect" % scalaVersion.value,
scalaOrganization.value % "scala-compiler" % scalaVersion.value % Test,
"com.beachape" %% "enumeratum" % "1.7.3" % Test,
"com.beachape" %% "enumeratum-circe" % "1.7.3" % Test
"com.beachape" %% "enumeratum-circe" % "1.7.3" % Test,
"com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core" % "2.28.2" % Test,
"com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % "2.28.2" % Provided
)
)
.dependsOn(core % Test, circeJson % Test)
.dependsOn(core % Test, circeJson % Test, jsoniterScala % Test)

lazy val openapiCodegenSbt: ProjectMatrix = (projectMatrix in file("openapi-codegen/sbt-plugin"))
.enablePlugins(SbtPlugin)
Expand Down
50 changes: 36 additions & 14 deletions doc/generator/sbt-openapi-codegen.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ Add your OpenApi file to the project, and override the `openapiSwaggerFile` sett
openapiSwaggerFile := baseDirectory.value / "swagger.yaml"
```

At this point your compile step will try to generate the endpoint definitions
to the `sttp.tapir.generated.TapirGeneratedEndpoints` object, where you can access the
At this point your compile step will try to generate the endpoint definitions
to the `sttp.tapir.generated.TapirGeneratedEndpoints` object, where you can access the
defined case-classes and endpoint definitions.

## Usage and options
Expand All @@ -42,6 +42,7 @@ openapiSwaggerFile baseDirectory.value / "swagger.yaml" The swagger
openapiPackage sttp.tapir.generated The name for the generated package.
openapiObject TapirGeneratedEndpoints The name for the generated object.
openapiUseHeadTagForObjectName false If true, put endpoints in separate files based on first declared tag.
openapiJsonSerdeLib circe The json serde library to use.
=============================== ==================================== =====================================================================
```

Expand All @@ -58,6 +59,7 @@ val docs = TapirGeneratedEndpoints.generatedEndpoints.toOpenAPI("My Bookshop", "
### Output files

To expand on the `openapiUseHeadTagForObjectName` setting a little more, suppose we have the following endpoints:

```yaml
paths:
/foo:
Expand All @@ -66,33 +68,53 @@ paths:
- Baz
- Foo
put:
tags: []
tags: [ ]
/bar:
get:
tags:
- Baz
- Bar
```
In this case 'head' tag for `GET /foo` and `GET /bar` would be 'Baz', and `PUT /foo` has no tags (and thus no 'head' tag).

If `openapiUseHeadTagForObjectName = false` (assuming default settings for the other flags) then all endpoint definitions
will be output to the `TapirGeneratedEndpoints.scala` file, which will contain a single `object TapirGeneratedEndpoints`.
In this case 'head' tag for `GET /foo` and `GET /bar` would be 'Baz', and `PUT /foo` has no tags (and thus no 'head'
tag).

If `openapiUseHeadTagForObjectName = false` (assuming default settings for the other flags) then all endpoint
definitions
will be output to the `TapirGeneratedEndpoints.scala` file, which will contain a
single `object TapirGeneratedEndpoints`.

If `openapiUseHeadTagForObjectName = true`, then the `GET /foo` and `GET /bar` endpoints would be output to a
`Baz.scala` file, containing a single `object Baz` with those endpoint definitions; the `PUT /foo` endpoint, by dint of
having no tags, would be output to the `TapirGeneratedEndpoints` file, along with any schema and parameter definitions.

### Limitations
### Json Support

Currently, the generated code depends on `"io.circe" %% "circe-generic"`. In the future probably we will make the encoder/decoder json lib configurable (PRs welcome).
```eval_rst
===================== ================================================================== ===================================================================
openapiJsonSerdeLib required dependencies Conditional requirements
===================== ================================================================== ===================================================================
circe "io.circe" %% "circe-core" "com.beachape" %% "enumeratum-circe" (scala 2 enum support).
"io.circe" %% "circe-generic" "org.latestbit" %% "circe-tagged-adt-codec" (scala 3 enum support).
jsoniter "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core"
"com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros"
===================== ================================================================== ===================================================================
```

String-like enums in Scala 2 depend on both `"com.beachape" %% "enumeratum"` and `"com.beachape" %% "enumeratum-circe"`.
For Scala 3 we derive native enums, and depend on `"org.latestbit" %% "circe-tagged-adt-codec"` for json serdes and `"io.github.bishabosha" %% "enum-extensions"` for query param serdes.
### Limitations

Currently, string-like enums in Scala 2 depend upon the enumeratum library (`"com.beachape" %% "enumeratum"`).
For Scala 3 we derive native enums, and depend on `"io.github.bishabosha" %% "enum-extensions"` for generating query
param serdes.
Other forms of OpenApi enum are not currently supported.

Models containing binary data cannot be re-used between json and multi-part form endpoints, due to having different
representation types for the binary data

We currently miss a lot of OpenApi features like:
- tags
- ADTs
- missing model types and meta descriptions (like date, minLength)
- file handling

- tags
- ADTs
- missing model types and meta descriptions (like date, minLength)
- file handling

Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ object GenScala {
private val headTagForNamesOpt: Opts[Boolean] =
Opts.flag("headTagForNames", "Whether to group generated endpoints by first declared tag", "t").orFalse

private val jsonLibOpt: Opts[Option[String]] =
Opts.option[String]("jsonLib", "Json library to use for serdes", "j").orNone

private val destDirOpt: Opts[File] =
Opts
.option[String]("destdir", "Destination directory", "d")
Expand All @@ -59,15 +62,15 @@ object GenScala {
}

val cmd: Command[IO[ExitCode]] = Command("genscala", "Generate Scala classes", helpFlag = true) {
(fileOpt, packageNameOpt, destDirOpt, objectNameOpt, targetScala3Opt, headTagForNamesOpt).mapN {
case (file, packageName, destDir, maybeObjectName, targetScala3, headTagForNames) =>
(fileOpt, packageNameOpt, destDirOpt, objectNameOpt, targetScala3Opt, headTagForNamesOpt, jsonLibOpt).mapN {
case (file, packageName, destDir, maybeObjectName, targetScala3, headTagForNames, jsonLib) =>
val objectName = maybeObjectName.getOrElse(DefaultObjectName)

def generateCode(doc: OpenapiDocument): IO[Unit] = for {
contents <- IO.pure(
BasicGenerator.generateObjects(doc, packageName, objectName, targetScala3, headTagForNames)
BasicGenerator.generateObjects(doc, packageName, objectName, targetScala3, headTagForNames, jsonLib.getOrElse("circe"))
)
destFiles <- contents.toVector.traverse{ case (fileName, content) => writeGeneratedFile(destDir, fileName, content) }
destFiles <- contents.toVector.traverse { case (fileName, content) => writeGeneratedFile(destDir, fileName, content) }
_ <- IO.println(s"Generated endpoints written to: ${destFiles.mkString(", ")}")
} yield ()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{
OpenapiSchemaUUID
}

object JsonSerdeLib extends Enumeration {
val Circe, Jsoniter = Value
type JsonSerdeLib = Value
}

object BasicGenerator {

val classGenerator = new ClassDefinitionGenerator()
Expand All @@ -26,9 +31,20 @@ object BasicGenerator {
packagePath: String,
objName: String,
targetScala3: Boolean,
useHeadTagForObjectNames: Boolean
useHeadTagForObjectNames: Boolean,
jsonSerdeLib: String
): Map[String, String] = {
val EndpointDefs(endpointsByTag, queryParamRefs) = endpointGenerator.endpointDefs(doc, useHeadTagForObjectNames)
val normalisedJsonLib = jsonSerdeLib.toLowerCase match {
case "circe" => JsonSerdeLib.Circe
case "jsoniter" => JsonSerdeLib.Jsoniter
case _ =>
System.err.println(
s"!!! Unrecognised value $jsonSerdeLib for json serde lib -- should be one of circe, jsoniter. Defaulting to circe !!!"
)
JsonSerdeLib.Circe
}

val EndpointDefs(endpointsByTag, queryParamRefs, jsonParamRefs) = endpointGenerator.endpointDefs(doc, useHeadTagForObjectNames)
val taggedObjs = endpointsByTag.collect {
case (Some(headTag), body) if body.nonEmpty =>
val taggedObj =
Expand All @@ -38,7 +54,7 @@ object BasicGenerator {
|
|object $headTag {
|
|${indent(2)(imports)}
|${indent(2)(imports(normalisedJsonLib))}
|
|${indent(2)(body)}
|
Expand All @@ -50,9 +66,9 @@ object BasicGenerator {
|
|object $objName {
|
|${indent(2)(imports)}
|${indent(2)(imports(normalisedJsonLib))}
|
|${indent(2)(classGenerator.classDefs(doc, targetScala3, queryParamRefs).getOrElse(""))}
|${indent(2)(classGenerator.classDefs(doc, targetScala3, queryParamRefs, normalisedJsonLib, jsonParamRefs).getOrElse(""))}
|
|${indent(2)(endpointsByTag.getOrElse(None, ""))}
|
Expand All @@ -61,19 +77,28 @@ object BasicGenerator {
taggedObjs + (objName -> mainObj)
}

private[codegen] def imports: String =
"""import sttp.tapir._
|import sttp.tapir.model._
|import sttp.tapir.json.circe._
|import sttp.tapir.generic.auto._
|import io.circe.generic.auto._
|""".stripMargin
private[codegen] def imports(jsonSerdeLib: JsonSerdeLib.JsonSerdeLib): String = {
val jsonImports = jsonSerdeLib match {
case JsonSerdeLib.Circe =>
"""import sttp.tapir.json.circe._
|import io.circe.generic.semiauto._""".stripMargin
case JsonSerdeLib.Jsoniter =>
"""import sttp.tapir.json.jsoniter._
|import com.github.plokhotnyuk.jsoniter_scala.macros._
|import com.github.plokhotnyuk.jsoniter_scala.core._""".stripMargin
}
s"""import sttp.tapir._
|import sttp.tapir.model._
|import sttp.tapir.generic.auto._
|$jsonImports
|""".stripMargin
}

def indent(i: Int)(str: String): String = {
str.linesIterator.map(" " * i + _).mkString("\n")
}

def mapSchemaSimpleTypeToType(osst: OpenapiSchemaSimpleType): (String, Boolean) = {
def mapSchemaSimpleTypeToType(osst: OpenapiSchemaSimpleType, multipartForm: Boolean = false): (String, Boolean) = {
osst match {
case OpenapiSchemaDouble(nb) =>
("Double", nb)
Expand All @@ -91,8 +116,10 @@ object BasicGenerator {
("String", nb)
case OpenapiSchemaBoolean(nb) =>
("Boolean", nb)
case OpenapiSchemaBinary(nb) =>
case OpenapiSchemaBinary(nb) if multipartForm =>
("sttp.model.Part[java.io.File]", nb)
case OpenapiSchemaBinary(nb) =>
("Array[Byte]", nb)
case OpenapiSchemaAny(nb) =>
("io.circe.Json", nb)
case OpenapiSchemaRef(t) =>
Expand Down
Loading
Loading