From 5632d8b070beae972d27fa71e1571ab1013977e3 Mon Sep 17 00:00:00 2001 From: Zsolt Takacs Date: Thu, 9 May 2024 19:25:47 +0200 Subject: [PATCH] add zio-json support to openapi-codegen (#3728) --- .../sttp/tapir/codegen/BasicGenerator.scala | 6 +- .../tapir/codegen/JsonSerdeGenerator.scala | 115 ++++++++++++++ .../Expected.scala.txt | 63 ++++++++ .../ExpectedJsonSerdes.scala.txt | 63 ++++++++ .../ExpectedSchemas.scala.txt | 40 +++++ .../oneOf-json-roundtrip-zio/build.sbt | 39 +++++ .../project/build.properties | 1 + .../project/plugins.sbt | 11 ++ .../src/main/scala/Main.scala | 12 ++ .../src/test/scala/JsonRoundtrip.scala | 141 ++++++++++++++++++ .../oneOf-json-roundtrip-zio/swagger.yaml | 133 +++++++++++++++++ .../oneOf-json-roundtrip-zio/test | 6 + 12 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/build.properties create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/plugins.sbt create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/main/scala/Main.scala create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml create mode 100644 openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/test diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala index 7d1b8fce5a..6e34c92d23 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala @@ -18,7 +18,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{ import sttp.tapir.codegen.openapi.models.SpecificationExtensionRenderer object JsonSerdeLib extends Enumeration { - val Circe, Jsoniter = Value + val Circe, Jsoniter, Zio = Value type JsonSerdeLib = Value } @@ -40,6 +40,7 @@ object BasicGenerator { val normalisedJsonLib = jsonSerdeLib.toLowerCase match { case "circe" => JsonSerdeLib.Circe case "jsoniter" => JsonSerdeLib.Jsoniter + case "zio" => JsonSerdeLib.Zio case _ => System.err.println( s"!!! Unrecognised value $jsonSerdeLib for json serde lib -- should be one of circe, jsoniter. Defaulting to circe !!!" @@ -166,6 +167,9 @@ object BasicGenerator { """import sttp.tapir.json.jsoniter._ |import com.github.plokhotnyuk.jsoniter_scala.macros._ |import com.github.plokhotnyuk.jsoniter_scala.core._""".stripMargin + case JsonSerdeLib.Zio => + """import sttp.tapir.json.zio._ + |import zio.json._""".stripMargin } s"""import sttp.tapir._ |import sttp.tapir.model._ diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala index e1add60704..95e6eb8096 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala @@ -43,6 +43,7 @@ object JsonSerdeGenerator { if (fullModelPath.isEmpty) None else Some(fullModelPath), validateNonDiscriminatedOneOfs ) + case JsonSerdeLib.Zio => genZioSerdes(doc, allSchemas, allTransitiveJsonParamRefs, validateNonDiscriminatedOneOfs) } } @@ -367,4 +368,118 @@ object JsonSerdeGenerator { serde } } + + /// + /// Zio + /// + private def genZioSerdes( + doc: OpenapiDocument, + allSchemas: Map[String, OpenapiSchemaType], + allTransitiveJsonParamRefs: Set[String], + validateNonDiscriminatedOneOfs: Boolean + ): Option[String] = { + doc.components + .map(_.schemas.flatMap { + // Enum serdes are generated at the declaration site + case (_, _: OpenapiSchemaEnum) => None + // We generate the serde if it's referenced in any json model + case (name, schema: OpenapiSchemaObject) if allTransitiveJsonParamRefs.contains(name) => + Some(genZioObjectSerde(name, schema)) + case (name, schema: OpenapiSchemaMap) if allTransitiveJsonParamRefs.contains(name) => + Some(genZioMapSerde(name, schema)) + case (name, schema: OpenapiSchemaOneOf) if allTransitiveJsonParamRefs.contains(name) => + Some(genZioAdtSerde(allSchemas, schema, name, validateNonDiscriminatedOneOfs)) + case (_, _: OpenapiSchemaObject | _: OpenapiSchemaMap | _: OpenapiSchemaEnum | _: OpenapiSchemaOneOf) => None + case (n, x) => throw new NotImplementedError(s"Only objects, enums, maps and oneOf supported! (for $n found ${x})") + }) + .map(_.mkString("\n")) + } + + private def genZioObjectSerde(name: String, schema: OpenapiSchemaObject): String = { + val subs = schema.properties.collect { + case (k, OpenapiSchemaField(`type`: OpenapiSchemaObject, _)) => genZioObjectSerde(s"$name${k.capitalize}", `type`) + case (k, OpenapiSchemaField(OpenapiSchemaArray(`type`: OpenapiSchemaObject, _), _)) => + genZioObjectSerde(s"$name${k.capitalize}Item", `type`) + case (k, OpenapiSchemaField(OpenapiSchemaMap(`type`: OpenapiSchemaObject, _), _)) => + genZioObjectSerde(s"$name${k.capitalize}Item", `type`) + } match { + case Nil => "" + case s => s.mkString("", "\n", "\n") + } + val uncapitalisedName = BasicGenerator.uncapitalise(name) + s"""${subs}implicit lazy val ${uncapitalisedName}JsonDecoder: zio.json.JsonDecoder[$name] = zio.json.DeriveJsonDecoder.gen[$name] + |implicit lazy val ${uncapitalisedName}JsonEncoder: zio.json.JsonEncoder[$name] = zio.json.DeriveJsonEncoder.gen[$name]""".stripMargin + } + + private def genZioMapSerde(name: String, schema: OpenapiSchemaMap): String = { + val subs = schema.items match { + case `type`: OpenapiSchemaObject => Some(genZioObjectSerde(s"${name}ObjectsItem", `type`)) + case _ => None + } + subs.fold("")("\n" + _) + } + + private def genZioAdtSerde( + allSchemas: Map[String, OpenapiSchemaType], + schema: OpenapiSchemaOneOf, + name: String, + validateNonDiscriminatedOneOfs: Boolean + ): String = { + val uncapitalisedName = BasicGenerator.uncapitalise(name) + + schema match { + case OpenapiSchemaOneOf(_, Some(discriminator)) => + val subtypeNames = schema.types.map { + case ref: OpenapiSchemaRef => ref.stripped + case other => throw new IllegalArgumentException(s"oneOf subtypes must be refs to explicit schema models, found $other for $name") + } + val schemaToJsonMapping = discriminator.mapping match { + case Some(mapping) => + mapping.map { case (jsonValue, fullRef) => fullRef.stripPrefix("#/components/schemas/") -> jsonValue } + case None => subtypeNames.map(s => s -> s).toMap + } + val encoders = subtypeNames + .map { t => + val jsonTypeName = schemaToJsonMapping(t) + s"""case x: $t => zio.json.ast.Json.decoder.decodeJson(zio.json.JsonEncoder[$t].encodeJson(x)).getOrElse(throw new RuntimeException("Unable to encode tagged ADT type ${name} to json")).mapObject(_.add("${discriminator.propertyName}", zio.json.ast.Json.Str("$jsonTypeName")))""" + } + .mkString("\n") + val decoders = subtypeNames + .map { t => s"""case zio.json.ast.Json.Str("${schemaToJsonMapping(t)}") => zio.json.JsonDecoder[$t].fromJsonAST(json)""" } + .mkString("\n") + s"""implicit lazy val ${uncapitalisedName}JsonEncoder: zio.json.JsonEncoder[$name] = zio.json.JsonEncoder[zio.json.ast.Json].contramap { + |${indent(2)(encoders)} + |} + |implicit lazy val ${uncapitalisedName}JsonDecoder: zio.json.JsonDecoder[$name] = zio.json.JsonDecoder[zio.json.ast.Json].mapOrFail { + | case json@zio.json.ast.Json.Obj(fields) => + | (fields.find(_._1 == "type") match { + | case None => Left("Unable to decode json to tagged ADT type ${name}") + | case Some(r) => Right(r._2) + | }).flatMap { + |${indent(6)(decoders)} + | case _ => Left("Unable to decode json to tagged ADT type ${name}") + | } + | case _ => Left("Unable to decode json to tagged ADT type ${name}") + |}""".stripMargin + case OpenapiSchemaOneOf(_, None) => + val subtypeNames = schema.types.map { + case ref: OpenapiSchemaRef => ref.stripped + case other => throw new IllegalArgumentException(s"oneOf subtypes must be refs to explicit schema models, found $other for $name") + } + if (validateNonDiscriminatedOneOfs) checkForSoundness(allSchemas)(schema.types.map(_.asInstanceOf[OpenapiSchemaRef])) + val encoders = subtypeNames.map(t => s"case x: $t => zio.json.JsonEncoder[$t].unsafeEncode(x, indent, out)").mkString("\n") + val decoders = subtypeNames.map(t => s"zio.json.JsonDecoder[$t].asInstanceOf[zio.json.JsonDecoder[$name]]").mkString(",\n") + s"""implicit lazy val ${uncapitalisedName}JsonEncoder: zio.json.JsonEncoder[$name] = new zio.json.JsonEncoder[$name] { + | override def unsafeEncode(v: $name, indent: Option[Int], out: zio.json.internal.Write): Unit = { + | v match { + |${indent(6)(encoders)} + | } + | } + |} + |implicit lazy val ${uncapitalisedName}JsonDecoder: zio.json.JsonDecoder[$name] = + | List[zio.json.JsonDecoder[$name]]( + |${indent(4)(decoders)} + | ).reduceLeft(_ orElse _)""".stripMargin + } + } } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt new file mode 100644 index 0000000000..26474edf5c --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt @@ -0,0 +1,63 @@ + +package sttp.tapir.generated + +object TapirGeneratedEndpoints { + + import sttp.tapir._ + import sttp.tapir.model._ + import sttp.tapir.generic.auto._ + import sttp.tapir.json.zio._ + import zio.json._ + + import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ + import TapirGeneratedEndpointsSchemas._ + + sealed trait ADTWithoutDiscriminator + sealed trait ADTWithDiscriminator + sealed trait ADTWithDiscriminatorNoMapping + case class SubtypeWithoutD1 ( + s: String, + i: Option[Int] = None, + a: Seq[String], + absent: Option[String] = None + ) extends ADTWithoutDiscriminator + case class SubtypeWithD1 ( + s: String, + i: Option[Int] = None, + d: Option[Double] = None + ) extends ADTWithDiscriminator with ADTWithDiscriminatorNoMapping + case class SubtypeWithoutD3 ( + s: String, + i: Option[Int] = None, + d: Option[Double] = None, + absent: Option[String] = None + ) extends ADTWithoutDiscriminator + case class SubtypeWithoutD2 ( + a: Seq[String], + absent: Option[String] = None + ) extends ADTWithoutDiscriminator + case class SubtypeWithD2 ( + s: String, + a: Option[Seq[String]] = None + ) extends ADTWithDiscriminator with ADTWithDiscriminatorNoMapping + + + + lazy val putAdtTest = + endpoint + .put + .in(("adt" / "test")) + .in(jsonBody[ADTWithoutDiscriminator]) + .out(jsonBody[ADTWithoutDiscriminator].description("successful operation")) + + lazy val postAdtTest = + endpoint + .post + .in(("adt" / "test")) + .in(jsonBody[ADTWithDiscriminatorNoMapping]) + .out(jsonBody[ADTWithDiscriminator].description("successful operation")) + + + lazy val generatedEndpoints = List(putAdtTest, postAdtTest) + +} diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt new file mode 100644 index 0000000000..2949c34956 --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt @@ -0,0 +1,63 @@ +package sttp.tapir.generated + +object TapirGeneratedEndpointsJsonSerdes { + import sttp.tapir.generated.TapirGeneratedEndpoints._ + import sttp.tapir.generic.auto._ + implicit lazy val aDTWithDiscriminatorJsonEncoder: zio.json.JsonEncoder[ADTWithDiscriminator] = zio.json.JsonEncoder[zio.json.ast.Json].contramap { + case x: SubtypeWithD1 => zio.json.ast.Json.decoder.decodeJson(zio.json.JsonEncoder[SubtypeWithD1].encodeJson(x)).getOrElse(throw new RuntimeException("Unable to encode tagged ADT type ADTWithDiscriminator to json")).mapObject(_.add("type", zio.json.ast.Json.Str("SubA"))) + case x: SubtypeWithD2 => zio.json.ast.Json.decoder.decodeJson(zio.json.JsonEncoder[SubtypeWithD2].encodeJson(x)).getOrElse(throw new RuntimeException("Unable to encode tagged ADT type ADTWithDiscriminator to json")).mapObject(_.add("type", zio.json.ast.Json.Str("SubB"))) + } + implicit lazy val aDTWithDiscriminatorJsonDecoder: zio.json.JsonDecoder[ADTWithDiscriminator] = zio.json.JsonDecoder[zio.json.ast.Json].mapOrFail { + case json@zio.json.ast.Json.Obj(fields) => + (fields.find(_._1 == "type") match { + case None => Left("Unable to decode json to tagged ADT type ADTWithDiscriminator") + case Some(r) => Right(r._2) + }).flatMap { + case zio.json.ast.Json.Str("SubA") => zio.json.JsonDecoder[SubtypeWithD1].fromJsonAST(json) + case zio.json.ast.Json.Str("SubB") => zio.json.JsonDecoder[SubtypeWithD2].fromJsonAST(json) + case _ => Left("Unable to decode json to tagged ADT type ADTWithDiscriminator") + } + case _ => Left("Unable to decode json to tagged ADT type ADTWithDiscriminator") + } + implicit lazy val subtypeWithoutD1JsonDecoder: zio.json.JsonDecoder[SubtypeWithoutD1] = zio.json.DeriveJsonDecoder.gen[SubtypeWithoutD1] + implicit lazy val subtypeWithoutD1JsonEncoder: zio.json.JsonEncoder[SubtypeWithoutD1] = zio.json.DeriveJsonEncoder.gen[SubtypeWithoutD1] + implicit lazy val subtypeWithD1JsonDecoder: zio.json.JsonDecoder[SubtypeWithD1] = zio.json.DeriveJsonDecoder.gen[SubtypeWithD1] + implicit lazy val subtypeWithD1JsonEncoder: zio.json.JsonEncoder[SubtypeWithD1] = zio.json.DeriveJsonEncoder.gen[SubtypeWithD1] + implicit lazy val aDTWithDiscriminatorNoMappingJsonEncoder: zio.json.JsonEncoder[ADTWithDiscriminatorNoMapping] = zio.json.JsonEncoder[zio.json.ast.Json].contramap { + case x: SubtypeWithD1 => zio.json.ast.Json.decoder.decodeJson(zio.json.JsonEncoder[SubtypeWithD1].encodeJson(x)).getOrElse(throw new RuntimeException("Unable to encode tagged ADT type ADTWithDiscriminatorNoMapping to json")).mapObject(_.add("type", zio.json.ast.Json.Str("SubtypeWithD1"))) + case x: SubtypeWithD2 => zio.json.ast.Json.decoder.decodeJson(zio.json.JsonEncoder[SubtypeWithD2].encodeJson(x)).getOrElse(throw new RuntimeException("Unable to encode tagged ADT type ADTWithDiscriminatorNoMapping to json")).mapObject(_.add("type", zio.json.ast.Json.Str("SubtypeWithD2"))) + } + implicit lazy val aDTWithDiscriminatorNoMappingJsonDecoder: zio.json.JsonDecoder[ADTWithDiscriminatorNoMapping] = zio.json.JsonDecoder[zio.json.ast.Json].mapOrFail { + case json@zio.json.ast.Json.Obj(fields) => + (fields.find(_._1 == "type") match { + case None => Left("Unable to decode json to tagged ADT type ADTWithDiscriminatorNoMapping") + case Some(r) => Right(r._2) + }).flatMap { + case zio.json.ast.Json.Str("SubtypeWithD1") => zio.json.JsonDecoder[SubtypeWithD1].fromJsonAST(json) + case zio.json.ast.Json.Str("SubtypeWithD2") => zio.json.JsonDecoder[SubtypeWithD2].fromJsonAST(json) + case _ => Left("Unable to decode json to tagged ADT type ADTWithDiscriminatorNoMapping") + } + case _ => Left("Unable to decode json to tagged ADT type ADTWithDiscriminatorNoMapping") + } + implicit lazy val subtypeWithoutD3JsonDecoder: zio.json.JsonDecoder[SubtypeWithoutD3] = zio.json.DeriveJsonDecoder.gen[SubtypeWithoutD3] + implicit lazy val subtypeWithoutD3JsonEncoder: zio.json.JsonEncoder[SubtypeWithoutD3] = zio.json.DeriveJsonEncoder.gen[SubtypeWithoutD3] + implicit lazy val subtypeWithoutD2JsonDecoder: zio.json.JsonDecoder[SubtypeWithoutD2] = zio.json.DeriveJsonDecoder.gen[SubtypeWithoutD2] + implicit lazy val subtypeWithoutD2JsonEncoder: zio.json.JsonEncoder[SubtypeWithoutD2] = zio.json.DeriveJsonEncoder.gen[SubtypeWithoutD2] + implicit lazy val subtypeWithD2JsonDecoder: zio.json.JsonDecoder[SubtypeWithD2] = zio.json.DeriveJsonDecoder.gen[SubtypeWithD2] + implicit lazy val subtypeWithD2JsonEncoder: zio.json.JsonEncoder[SubtypeWithD2] = zio.json.DeriveJsonEncoder.gen[SubtypeWithD2] + implicit lazy val aDTWithoutDiscriminatorJsonEncoder: zio.json.JsonEncoder[ADTWithoutDiscriminator] = new zio.json.JsonEncoder[ADTWithoutDiscriminator] { + override def unsafeEncode(v: ADTWithoutDiscriminator, indent: Option[Int], out: zio.json.internal.Write): Unit = { + v match { + case x: SubtypeWithoutD1 => zio.json.JsonEncoder[SubtypeWithoutD1].unsafeEncode(x, indent, out) + case x: SubtypeWithoutD2 => zio.json.JsonEncoder[SubtypeWithoutD2].unsafeEncode(x, indent, out) + case x: SubtypeWithoutD3 => zio.json.JsonEncoder[SubtypeWithoutD3].unsafeEncode(x, indent, out) + } + } + } + implicit lazy val aDTWithoutDiscriminatorJsonDecoder: zio.json.JsonDecoder[ADTWithoutDiscriminator] = + List[zio.json.JsonDecoder[ADTWithoutDiscriminator]]( + zio.json.JsonDecoder[SubtypeWithoutD1].asInstanceOf[zio.json.JsonDecoder[ADTWithoutDiscriminator]], + zio.json.JsonDecoder[SubtypeWithoutD2].asInstanceOf[zio.json.JsonDecoder[ADTWithoutDiscriminator]], + zio.json.JsonDecoder[SubtypeWithoutD3].asInstanceOf[zio.json.JsonDecoder[ADTWithoutDiscriminator]] + ).reduceLeft(_ orElse _) +} diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt new file mode 100644 index 0000000000..dd0c6da56c --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt @@ -0,0 +1,40 @@ +package sttp.tapir.generated + +object TapirGeneratedEndpointsSchemas { + import sttp.tapir.generated.TapirGeneratedEndpoints._ + import sttp.tapir.generic.auto._ + implicit lazy val subtypeWithD1TapirSchema: sttp.tapir.Schema[SubtypeWithD1] = sttp.tapir.Schema.derived + implicit lazy val subtypeWithD2TapirSchema: sttp.tapir.Schema[SubtypeWithD2] = sttp.tapir.Schema.derived + implicit lazy val subtypeWithoutD1TapirSchema: sttp.tapir.Schema[SubtypeWithoutD1] = sttp.tapir.Schema.derived + implicit lazy val subtypeWithoutD2TapirSchema: sttp.tapir.Schema[SubtypeWithoutD2] = sttp.tapir.Schema.derived + implicit lazy val subtypeWithoutD3TapirSchema: sttp.tapir.Schema[SubtypeWithoutD3] = sttp.tapir.Schema.derived + implicit lazy val aDTWithDiscriminatorTapirSchema: sttp.tapir.Schema[ADTWithDiscriminator] = { + val derived = implicitly[sttp.tapir.generic.Derived[sttp.tapir.Schema[ADTWithDiscriminator]]].value + derived.schemaType match { + case s: sttp.tapir.SchemaType.SCoproduct[_] => derived.copy(schemaType = s.addDiscriminatorField( + sttp.tapir.FieldName("type"), + sttp.tapir.Schema.string, + Map( + "SubA" -> sttp.tapir.SchemaType.SRef(sttp.tapir.Schema.SName("sttp.tapir.generated.TapirGeneratedEndpoints.SubtypeWithD1")), + "SubB" -> sttp.tapir.SchemaType.SRef(sttp.tapir.Schema.SName("sttp.tapir.generated.TapirGeneratedEndpoints.SubtypeWithD2")) + ) + )) + case _ => throw new IllegalStateException("Derived schema for ADTWithDiscriminator should be a coproduct") + } + } + implicit lazy val aDTWithDiscriminatorNoMappingTapirSchema: sttp.tapir.Schema[ADTWithDiscriminatorNoMapping] = { + val derived = implicitly[sttp.tapir.generic.Derived[sttp.tapir.Schema[ADTWithDiscriminatorNoMapping]]].value + derived.schemaType match { + case s: sttp.tapir.SchemaType.SCoproduct[_] => derived.copy(schemaType = s.addDiscriminatorField( + sttp.tapir.FieldName("type"), + sttp.tapir.Schema.string, + Map( + "SubtypeWithD1" -> sttp.tapir.SchemaType.SRef(sttp.tapir.Schema.SName("sttp.tapir.generated.TapirGeneratedEndpoints.SubtypeWithD1")), + "SubtypeWithD2" -> sttp.tapir.SchemaType.SRef(sttp.tapir.Schema.SName("sttp.tapir.generated.TapirGeneratedEndpoints.SubtypeWithD2")) + ) + )) + case _ => throw new IllegalStateException("Derived schema for ADTWithDiscriminatorNoMapping should be a coproduct") + } + } + implicit lazy val aDTWithoutDiscriminatorTapirSchema: sttp.tapir.Schema[ADTWithoutDiscriminator] = sttp.tapir.Schema.derived +} diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt new file mode 100644 index 0000000000..ec6234654a --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt @@ -0,0 +1,39 @@ +lazy val root = (project in file(".")) + .enablePlugins(OpenapiCodegenPlugin) + .settings( + scalaVersion := "2.13.13", + version := "0.1", + openapiJsonSerdeLib := "zio" + ) + +libraryDependencies ++= Seq( + "com.softwaremill.sttp.tapir" %% "tapir-json-zio" % "1.10.0", + "com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % "1.10.0", + "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.8.0", + "org.scalatest" %% "scalatest" % "3.2.18" % Test, + "com.softwaremill.sttp.tapir" %% "tapir-sttp-stub-server" % "1.10.0" % Test +) + +import scala.io.Source + +TaskKey[Unit]("check") := { + def check(generatedFileName: String, expectedFileName: String) = { + val generatedCode = + Source.fromFile(s"target/scala-2.13/src_managed/main/sbt-openapi-codegen/$generatedFileName").getLines.mkString("\n") + val expectedCode = Source.fromFile(expectedFileName).getLines.mkString("\n") + val generatedTrimmed = + generatedCode.linesIterator.zipWithIndex.filterNot(_._1.forall(_.isWhitespace)).map { case (a, i) => a.trim -> i }.toSeq + val expectedTrimmed = expectedCode.linesIterator.filterNot(_.forall(_.isWhitespace)).map(_.trim).toSeq + if (generatedTrimmed.size != expectedTrimmed.size) + sys.error(s"expected ${expectedTrimmed.size} non-empty lines, found ${generatedTrimmed.size}") + generatedTrimmed.zip(expectedTrimmed).foreach { case ((a, i), b) => + if (a != b) sys.error(s"Generated code in file $generatedCode did not match (expected '$b' on line $i, found '$a')") + } + } + Seq( + "TapirGeneratedEndpoints.scala" -> "Expected.scala.txt", + "TapirGeneratedEndpointsJsonSerdes.scala" -> "ExpectedJsonSerdes.scala.txt", + "TapirGeneratedEndpointsSchemas.scala" -> "ExpectedSchemas.scala.txt" + ).foreach { case (generated, expected) => check(generated, expected) } + () +} diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/build.properties b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/build.properties new file mode 100644 index 0000000000..04267b14af --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.9 diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/plugins.sbt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/plugins.sbt new file mode 100644 index 0000000000..2e0b229d2a --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/project/plugins.sbt @@ -0,0 +1,11 @@ +{ + val pluginVersion = System.getProperty("plugin.version") + if (pluginVersion == null) + throw new RuntimeException("""| + | + |The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D. + | + |""".stripMargin) + else addSbtPlugin("com.softwaremill.sttp.tapir" % "sbt-openapi-codegen" % pluginVersion) +} \ No newline at end of file diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/main/scala/Main.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/main/scala/Main.scala new file mode 100644 index 0000000000..9b79493aa7 --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/main/scala/Main.scala @@ -0,0 +1,12 @@ +object Main extends App { + import sttp.apispec.openapi.circe.yaml._ + import sttp.tapir.generated._ + import sttp.tapir.docs.openapi._ + + val docs = OpenAPIDocsInterpreter().toOpenAPI(TapirGeneratedEndpoints.generatedEndpoints, "My Bookshop", "1.0") + + import java.nio.file.{Paths, Files} + import java.nio.charset.StandardCharsets + + Files.write(Paths.get("target/swagger.yaml"), docs.toYaml.getBytes(StandardCharsets.UTF_8)) +} diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala new file mode 100644 index 0000000000..81dc62e3f5 --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala @@ -0,0 +1,141 @@ +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers +import sttp.client3.UriContext +import sttp.client3.testing.SttpBackendStub +import sttp.tapir.generated.{TapirGeneratedEndpoints, TapirGeneratedEndpointsJsonSerdes} +import sttp.tapir.generated.TapirGeneratedEndpoints._ +import sttp.tapir.server.stub.TapirStubInterpreter +import zio.json._ + +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, Future} +import scala.concurrent.ExecutionContext.Implicits.global + +class JsonRoundtrip extends AnyFreeSpec with Matchers { + "oneOf without discriminator can be round-tripped by generated serdes" in { + val route = TapirGeneratedEndpoints.putAdtTest.serverLogic[Future]({ + case foo: SubtypeWithoutD1 => + Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD1(foo.s + "+SubtypeWithoutD1", foo.i, foo.a)) + case foo: SubtypeWithoutD2 => Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD2(foo.a :+ "+SubtypeWithoutD2")) + case foo: SubtypeWithoutD3 => + Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.d)) + }) + + val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) + .whenServerEndpoint(route) + .thenRunLogic() + .backend() + + locally { + val reqBody: ADTWithoutDiscriminator = SubtypeWithoutD1("a string", Some(123), Seq("string 1", "string 2")) + val reqJsonBody = reqBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) + val respBody: ADTWithoutDiscriminator = SubtypeWithoutD1("a string+SubtypeWithoutD1", Some(123), Seq("string 1", "string 2")) + val respJsonBody = respBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) + reqJsonBody shouldEqual """{"s":"a string","i":123,"a":["string 1","string 2"]}""" + respJsonBody shouldEqual """{"s":"a string+SubtypeWithoutD1","i":123,"a":["string 1","string 2"]}""" + Await.result( + sttp.client3.basicRequest + .put(uri"http://test.com/adt/test") + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.code.code shouldEqual 200 + resp.body shouldEqual Right(respJsonBody) + }, + 1.second + ) + } + + locally { + val reqBody: ADTWithoutDiscriminator = SubtypeWithoutD2(Seq("string 1", "string 2")) + val reqJsonBody = reqBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) + val respBody: ADTWithoutDiscriminator = SubtypeWithoutD2(Seq("string 1", "string 2", "+SubtypeWithoutD2")) + val respJsonBody = respBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) + reqJsonBody shouldEqual """{"a":["string 1","string 2"]}""" + respJsonBody shouldEqual """{"a":["string 1","string 2","+SubtypeWithoutD2"]}""" + Await.result( + sttp.client3.basicRequest + .put(uri"http://test.com/adt/test") + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.body shouldEqual Right(respJsonBody) + resp.code.code shouldEqual 200 + }, + 1.second + ) + } + + locally { + val reqBody: ADTWithoutDiscriminator = SubtypeWithoutD3("a string", Some(123), Some(23.4)) + val reqJsonBody = reqBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) + val respBody: ADTWithoutDiscriminator = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(23.4)) + val respJsonBody = respBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) + reqJsonBody shouldEqual """{"s":"a string","i":123,"d":23.4}""" + respJsonBody shouldEqual """{"s":"a string+SubtypeWithoutD3","i":123,"d":23.4}""" + Await.result( + sttp.client3.basicRequest + .put(uri"http://test.com/adt/test") + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.body shouldEqual Right(respJsonBody) + resp.code.code shouldEqual 200 + }, + 1.second + ) + } + } + "oneOf with discriminator can be round-tripped by generated serdes" in { + val route = TapirGeneratedEndpoints.postAdtTest.serverLogic[Future]({ + case foo: SubtypeWithD1 => Future successful Right[Unit, ADTWithDiscriminator](SubtypeWithD1(foo.s + "+SubtypeWithD1", foo.i, foo.d)) + case foo: SubtypeWithD2 => Future successful Right[Unit, ADTWithDiscriminator](SubtypeWithD2(foo.s + "+SubtypeWithD2", foo.a)) + }) + + val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) + .whenServerEndpoint(route) + .thenRunLogic() + .backend() + + locally { + val reqBody: ADTWithDiscriminatorNoMapping = SubtypeWithD1("a string", Some(123), Some(23.4)) + val reqJsonBody = reqBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithDiscriminatorNoMappingJsonEncoder) + val respBody: ADTWithDiscriminator = SubtypeWithD1("a string+SubtypeWithD1", Some(123), Some(23.4)) + val respJsonBody = respBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithDiscriminatorJsonEncoder) + reqJsonBody shouldEqual """{"s":"a string","i":123,"d":23.4,"type":"SubtypeWithD1"}""" + respJsonBody shouldEqual """{"s":"a string+SubtypeWithD1","i":123,"d":23.4,"type":"SubA"}""" + Await.result( + sttp.client3.basicRequest + .post(uri"http://test.com/adt/test") + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.code.code shouldEqual 200 + resp.body shouldEqual Right(respJsonBody) + }, + 1.second + ) + } + + locally { + val reqBody: ADTWithDiscriminatorNoMapping = SubtypeWithD2("a string", Some(Seq("string 1", "string 2"))) + val reqJsonBody = reqBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithDiscriminatorNoMappingJsonEncoder) + val respBody: ADTWithDiscriminator = SubtypeWithD2("a string+SubtypeWithD2", Some(Seq("string 1", "string 2"))) + val respJsonBody = respBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithDiscriminatorJsonEncoder) + reqJsonBody shouldEqual """{"s":"a string","a":["string 1","string 2"],"type":"SubtypeWithD2"}""" + respJsonBody shouldEqual """{"s":"a string+SubtypeWithD2","a":["string 1","string 2"],"type":"SubB"}""" + Await.result( + sttp.client3.basicRequest + .post(uri"http://test.com/adt/test") + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.code.code shouldEqual 200 + resp.body shouldEqual Right(respJsonBody) + }, + 1.second + ) + } + + } +} diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml new file mode 100644 index 0000000000..590ba46952 --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml @@ -0,0 +1,133 @@ +openapi: 3.0.3 +servers: + - url: /v3 +info: + description: File for testing json roundtripping of oneOf defns in scala 2.x with circe + version: 1.0.20-SNAPSHOT + title: OneOf Json test for scala 2 +tags: [] +paths: + '/adt/test': + post: + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ADTWithDiscriminator' + requestBody: + required: true + description: Update an existent user in the store + content: + application/json: + schema: + $ref: '#/components/schemas/ADTWithDiscriminatorNoMapping' + put: + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ADTWithoutDiscriminator' + requestBody: + required: true + description: Update an existent user in the store + content: + application/json: + schema: + $ref: '#/components/schemas/ADTWithoutDiscriminator' + +components: + schemas: + ADTWithDiscriminator: + type: object + oneOf: + - $ref: '#/components/schemas/SubtypeWithD1' + - $ref: '#/components/schemas/SubtypeWithD2' + discriminator: + propertyName: type + mapping: + 'SubA': '#/components/schemas/SubtypeWithD1' + 'SubB': '#/components/schemas/SubtypeWithD2' + # This has the same members as ADTWithDiscriminator, to test that we can extend multiple sealed traits in our ADT mappings + ADTWithDiscriminatorNoMapping: + type: object + oneOf: + - $ref: '#/components/schemas/SubtypeWithD1' + - $ref: '#/components/schemas/SubtypeWithD2' + discriminator: + propertyName: type + SubtypeWithD1: + type: object + required: + - s + properties: + s: + type: string + i: + type: integer + d: + type: number + format: double + SubtypeWithD2: + type: object + required: + - s + properties: + s: + type: string + a: + type: array + items: + type: string + ADTWithoutDiscriminator: + type: object + oneOf: + ## A 'SubtypeWithoutD1' with only 'a' and 'd' fields set could be decoded as either a SubtypeWithoutD2 or SubtypeWithoutD3, + ## and so must be defined first here, or else we'd fail validation + - $ref: '#/components/schemas/SubtypeWithoutD1' + - $ref: '#/components/schemas/SubtypeWithoutD2' + - $ref: '#/components/schemas/SubtypeWithoutD3' + SubtypeWithoutD1: + type: object + required: + - s + - a + properties: + s: + type: string + i: + type: integer + a: + type: array + items: + type: string + absent: + type: string + SubtypeWithoutD2: + type: object + required: + - a + properties: + a: + type: array + items: + type: string + absent: + type: string + SubtypeWithoutD3: + type: object + required: + - s + properties: + s: + type: string + i: + type: integer + d: + type: number + format: double + absent: + type: string \ No newline at end of file diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/test b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/test new file mode 100644 index 0000000000..5f09819dff --- /dev/null +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/test @@ -0,0 +1,6 @@ +> clean +> generateTapirDefinitions +> run +> test +> check +