From 3880806e6c2e6b49df49fe084df24bab50cf9eb9 Mon Sep 17 00:00:00 2001 From: hughsimpson Date: Wed, 20 Mar 2024 07:34:35 +0000 Subject: [PATCH] codegen: Support default values in schema objects (#3614) --- .../codegen/ClassDefinitionGenerator.scala | 24 +- .../openapi/models/OpenapiModels.scala | 1 + .../openapi/models/OpenapiSchemaType.scala | 19 +- .../codegen/openapi/models/Renderer.scala | 112 +++++++++ .../tapir/codegen/BasicGeneratorSpec.scala | 24 ++ .../ClassDefinitionGeneratorSpec.scala | 118 ++++++--- .../tapir/codegen/EndpointGeneratorSpec.scala | 3 +- .../sttp/tapir/codegen/TestHelpers.scala | 226 ++++++++++++++++-- .../codegen/models/ModelParserSpec.scala | 11 + .../codegen/models/SchemaParserSpec.scala | 10 +- 10 files changed, 475 insertions(+), 73 deletions(-) create mode 100644 openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/Renderer.scala diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala index 2efc403df6..5a7d2e2e68 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala @@ -1,14 +1,16 @@ package sttp.tapir.codegen +import io.circe.Json import sttp.tapir.codegen.BasicGenerator.{indent, mapSchemaSimpleTypeToType} import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument -import sttp.tapir.codegen.openapi.models.OpenapiSchemaType +import sttp.tapir.codegen.openapi.models.{OpenapiSchemaType, Renderer} import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._ import scala.annotation.tailrec class ClassDefinitionGenerator { - val jsoniterDefaultConfig = "com.github.plokhotnyuk.jsoniter_scala.macros.CodecMakerConfig.withAllowRecursiveTypes(true).withDiscriminatorFieldName(scala.None)" + val jsoniterDefaultConfig = + "com.github.plokhotnyuk.jsoniter_scala.macros.CodecMakerConfig.withAllowRecursiveTypes(true).withDiscriminatorFieldName(scala.None)" def classDefs( doc: OpenapiDocument, @@ -59,7 +61,7 @@ class ClassDefinitionGenerator { val defns = doc.components .map(_.schemas.flatMap { case (name, obj: OpenapiSchemaObject) => - generateClass(name, obj, jsonSerdeLib, allTransitiveJsonParamRefs) + generateClass(allSchemas, name, obj, jsonSerdeLib, allTransitiveJsonParamRefs) case (name, obj: OpenapiSchemaEnum) => generateEnum(name, obj, targetScala3, queryParamRefs, jsonSerdeLib, allTransitiveJsonParamRefs) case (name, OpenapiSchemaMap(valueSchema, _)) => generateMap(name, valueSchema, jsonSerdeLib, allTransitiveJsonParamRefs) @@ -139,7 +141,7 @@ class ClassDefinitionGenerator { case OpenapiSchemaObject(properties, _, _) if properties.isEmpty => None case OpenapiSchemaObject(properties, required, nullable) => val propToCheck = properties.head - val (propToCheckName, propToCheckType) = propToCheck + val (propToCheckName, OpenapiSchemaField(propToCheckType, _)) = propToCheck val objectWithoutHeadField = OpenapiSchemaObject(properties - propToCheckName, required, nullable) Some((propToCheckType, checked, objectWithoutHeadField +: tail)) case _ => None @@ -236,6 +238,7 @@ class ClassDefinitionGenerator { } private[codegen] def generateClass( + allSchemas: Map[String, OpenapiSchemaType], name: String, obj: OpenapiSchemaObject, jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, @@ -245,25 +248,28 @@ class ClassDefinitionGenerator { def rec(name: String, obj: OpenapiSchemaObject, acc: List[String]): Seq[String] = { val innerClasses = obj.properties .collect { - case (propName, st: OpenapiSchemaObject) => + case (propName, OpenapiSchemaField(st: OpenapiSchemaObject, _)) => val newName = addName(name, propName) rec(newName, st, Nil) - case (propName, OpenapiSchemaMap(st: OpenapiSchemaObject, _)) => + case (propName, OpenapiSchemaField(OpenapiSchemaMap(st: OpenapiSchemaObject, _), _)) => val newName = addName(addName(name, propName), "item") rec(newName, st, Nil) - case (propName, OpenapiSchemaArray(st: OpenapiSchemaObject, _)) => + case (propName, OpenapiSchemaField(OpenapiSchemaArray(st: OpenapiSchemaObject, _), _)) => val newName = addName(addName(name, propName), "item") rec(newName, st, Nil) } .flatten .toList - val properties = obj.properties.map { case (key, schemaType) => + val properties = obj.properties.map { case (key, OpenapiSchemaField(schemaType, maybeDefault)) => val tpe = mapSchemaTypeToType(name, key, obj.required.contains(key), schemaType, isJson) val fixedKey = fixKey(key) - s"$fixedKey: $tpe" + val optional = schemaType.nullable || !obj.required.contains(key) + val maybeExplicitDefault = maybeDefault.map(" = " + Renderer.render(allModels = allSchemas, thisType = schemaType, optional)(_)) + val default = maybeExplicitDefault getOrElse (if (optional) " = None" else "") + s"$fixedKey: $tpe$default" } val uncapitalisedName = name.head.toLower +: name.tail diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala index fc22b87521..dc6d043f9d 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala @@ -166,6 +166,7 @@ object OpenapiModels { implicit def ResolvableDecoder[T: Decoder]: Decoder[Resolvable[T]] = { (c: HCursor) => c.as[T].map(Resolved(_)).orElse(c.as[OpenapiSchemaRef].map(r => Ref(r.name))) } + implicit val PartialOpenapiPathMethodDecoder: Decoder[OpenapiPathMethod] = { (c: HCursor) => for { parameters <- c.getOrElse[Seq[Resolvable[OpenapiParameter]]]("parameters")(Nil) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiSchemaType.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiSchemaType.scala index 427b3252a3..beceb3118f 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiSchemaType.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiSchemaType.scala @@ -1,5 +1,7 @@ package sttp.tapir.codegen.openapi.models +import io.circe.Json + sealed trait OpenapiSchemaType { def nullable: Boolean } @@ -106,9 +108,13 @@ object OpenapiSchemaType { nullable: Boolean ) extends OpenapiSchemaType + case class OpenapiSchemaField( + `type`: OpenapiSchemaType, + default: Option[Json] + ) // no readOnly/writeOnly, minProperties/maxProperties support case class OpenapiSchemaObject( - properties: Map[String, OpenapiSchemaType], + properties: Map[String, OpenapiSchemaField], required: Seq[String], nullable: Boolean ) extends OpenapiSchemaType @@ -253,14 +259,21 @@ object OpenapiSchemaType { } yield OpenapiSchemaEnum(tpe, items, nb.getOrElse(false)) } + implicit val SchemaTypeWithDefaultDecoder: Decoder[(OpenapiSchemaType, Option[Json])] = { (c: HCursor) => + for { + schemaType <- c.as[OpenapiSchemaType] + maybeDefault <- c.downField("default").as[Option[Json]] + } yield (schemaType, maybeDefault) + } implicit val OpenapiSchemaObjectDecoder: Decoder[OpenapiSchemaObject] = { (c: HCursor) => for { _ <- c.downField("type").as[String].ensure(DecodingFailure("Given type is not object!", c.history))(v => v == "object") - f <- c.downField("properties").as[Option[Map[String, OpenapiSchemaType]]] + fieldsWithDefaults <- c.downField("properties").as[Option[Map[String, (OpenapiSchemaType, Option[Json])]]] r <- c.downField("required").as[Option[Seq[String]]] nb <- c.downField("nullable").as[Option[Boolean]] + fields = fieldsWithDefaults.getOrElse(Map.empty).map { case (k, (f, d)) => k -> OpenapiSchemaField(f, d) } } yield { - OpenapiSchemaObject(f.getOrElse(Map.empty), r.getOrElse(Seq.empty), nb.getOrElse(false)) + OpenapiSchemaObject(fields, r.getOrElse(Seq.empty), nb.getOrElse(false)) } } diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/Renderer.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/Renderer.scala new file mode 100644 index 0000000000..0a835c0abd --- /dev/null +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/Renderer.scala @@ -0,0 +1,112 @@ +package sttp.tapir.codegen.openapi.models + +import io.circe.Json +import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{ + OpenapiSchemaArray, + OpenapiSchemaBinary, + OpenapiSchemaBoolean, + OpenapiSchemaDateTime, + OpenapiSchemaDouble, + OpenapiSchemaEnum, + OpenapiSchemaFloat, + OpenapiSchemaInt, + OpenapiSchemaLong, + OpenapiSchemaMap, + OpenapiSchemaObject, + OpenapiSchemaRef, + OpenapiSchemaString, + OpenapiSchemaUUID +} + +object Renderer { + private def lookup(allModels: Map[String, OpenapiSchemaType], ref: OpenapiSchemaRef): OpenapiSchemaType = allModels( + ref.name.stripPrefix("#/components/schemas/") + ) + + private def renderStringWithName( + value: String + )(allModels: Map[String, OpenapiSchemaType], thisType: OpenapiSchemaType, name: String): String = + thisType match { + case ref: OpenapiSchemaRef => + renderStringWithName(value)(allModels, lookup(allModels, ref), ref.name.stripPrefix("#/components/schemas/")) + case OpenapiSchemaString(_) => '"' +: value :+ '"' + case OpenapiSchemaEnum(_, _, _) => s"$name.$value" + case OpenapiSchemaDateTime(_) => s"""java.time.Instant.parse("$value")""" + case OpenapiSchemaBinary(_) => s""""$value".getBytes("utf-8")""" + case OpenapiSchemaUUID(_) => s"""java.util.UUID.fromString("$value")""" + case other => throw new IllegalArgumentException(s"Cannot render a string as type ${other.getClass.getName}") + } + private def renderMapWithName( + kvs: Map[String, Json] + )(allModels: Map[String, OpenapiSchemaType], thisType: OpenapiSchemaType, name: String): String = { + def errorForKey(k: String): Nothing = throw new IllegalArgumentException( + s"Cannot find property $k in schema $name when constructing default value" + ) + thisType match { + case ref: OpenapiSchemaRef => renderMapWithName(kvs)(allModels, lookup(allModels, ref), ref.name.stripPrefix("#/components/schemas/")) + case OpenapiSchemaMap(types, _) => + s"Map(${kvs.map { case (k, v) => s""""$k" -> ${render(allModels, types, isOptional = false)(v)}""" }.mkString(", ")})" + case OpenapiSchemaObject(properties, required, _) => + val kvsWithProps = kvs.map { case (k, v) => (k, (v, properties.get(k).getOrElse(errorForKey(k)))) } + s"$name(${kvsWithProps + .map { case (k, (v, p)) => s"""$k = ${render(allModels, p.`type`, p.`type`.nullable || !required.contains(k))(v)}""" } + .mkString(", ")})" + case other => throw new IllegalArgumentException(s"Cannot render a map as type ${other.getClass.getName}") + } + } + + def render(allModels: Map[String, OpenapiSchemaType], thisType: OpenapiSchemaType, isOptional: Boolean)(json: Json): String = + if (json == Json.Null) { + if (isOptional) "None" else "null" + } else { + def fail(tpe: String, schemaType: OpenapiSchemaType, reason: Option[String] = None): Nothing = + throw new IllegalArgumentException( + s"Cannot render a $tpe as type ${schemaType.getClass.getName}.${reason.map(" " + _).getOrElse("")}" + ) + val base: String = json.fold[String]( + "null", + jsBool => + thisType match { + case ref: OpenapiSchemaRef => render(allModels, lookup(allModels, ref), isOptional = false)(json) + case OpenapiSchemaBoolean(_) => jsBool.toString + case other => fail("boolean", other) + }, + jsonNumber => + thisType match { + case ref: OpenapiSchemaRef => render(allModels, lookup(allModels, ref), isOptional = false)(json) + case l @ OpenapiSchemaLong(_) => s"${jsonNumber.toLong.getOrElse(fail("number", l, Some(s"$jsonNumber is not a long")))}L" + case i @ OpenapiSchemaInt(_) => jsonNumber.toInt.getOrElse(fail("number", i, Some(s"$jsonNumber is not an int"))).toString + case OpenapiSchemaFloat(_) => s"${jsonNumber.toFloat}f" + case OpenapiSchemaDouble(_) => s"${jsonNumber.toDouble}d" + case other => fail("number", other) + }, + jsonString => + thisType match { + case ref: OpenapiSchemaRef => + renderStringWithName(jsonString)(allModels, lookup(allModels, ref), ref.name.stripPrefix("#/components/schemas/")) + case OpenapiSchemaString(_) => '"' +: jsonString :+ '"' + case OpenapiSchemaDateTime(_) => s"""java.time.Instant.parse("$jsonString")""" + case OpenapiSchemaBinary(_) => s""""$jsonString".getBytes("utf-8")""" + case OpenapiSchemaUUID(_) => s"""java.util.UUID.fromString("$jsonString")""" + // case OpenapiSchemaEnum(_, _, _) => // inline enum definitions are not currently supported, so let it throw + case other => fail("string", other) + }, + jsonArray => + thisType match { + case ref: OpenapiSchemaRef => render(allModels, lookup(allModels, ref), isOptional = false)(json) + case OpenapiSchemaArray(items, _) => s"Vector(${jsonArray.map(render(allModels, items, isOptional = false)).mkString(", ")})" + case other => fail("list", other) + }, + jsonObject => + thisType match { + case ref: OpenapiSchemaRef => + renderMapWithName(jsonObject.toMap)(allModels, lookup(allModels, ref), ref.name.stripPrefix("#/components/schemas/")) + case OpenapiSchemaMap(types, _) => + s"Map(${jsonObject.toMap.map { case (k, v) => s""""$k" -> ${render(allModels, types, isOptional = false)(v)}""" }.mkString(", ")})" + case other => fail("map", other) + } + ) + if (isOptional) s"Some($base)" else base + } + +} diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/BasicGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/BasicGeneratorSpec.scala index 3bbcbd57c4..90698b4263 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/BasicGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/BasicGeneratorSpec.scala @@ -48,6 +48,30 @@ class BasicGeneratorSpec extends CompileCheckTestBase { )("TapirGeneratedEndpoints") shouldCompile () } + it should s"compile endpoints with default params using ${jsonSerdeLib} serdes" in { + val genWithParams = BasicGenerator.generateObjects( + TestHelpers.withDefaultsDocs, + "sttp.tapir.generated", + "TapirGeneratedEndpoints", + targetScala3 = false, + useHeadTagForObjectNames = false, + jsonSerdeLib = jsonSerdeLib + )("TapirGeneratedEndpoints") + + val expectedDefaultDeclarations = Seq( + """f1: String = "default string"""", + """f2: Option[Int] = Some(1977)""", + """g1: Option[java.util.UUID] = Some(java.util.UUID.fromString("default string"))""", + """g2: Float = 1977.0f""", + """g3: Option[AnEnum] = Some(AnEnum.v1)""", + """g4: Option[Seq[AnEnum]] = Some(Vector(AnEnum.v1, AnEnum.v2, AnEnum.v3))""", + """sub: Option[SubObject] = Some(SubObject(subsub = SubSubObject(value = "hi there", value2 = Some(java.util.UUID.fromString("ac8113ed-6105-4f65-a393-e88be2c5d585")))))""" + ) + expectedDefaultDeclarations foreach (decln => genWithParams should include(decln)) + + genWithParams shouldCompile () + } + } Seq("circe", "jsoniter") foreach testJsonLib diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala index 27e6a04ea0..a78425d471 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala @@ -1,12 +1,15 @@ package sttp.tapir.codegen -import sttp.tapir.codegen.openapi.models.OpenapiComponent +import sttp.tapir.codegen.openapi.models.{OpenapiComponent, OpenapiSchemaType} import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument import sttp.tapir.codegen.openapi.models.OpenapiSchemaType import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._ import sttp.tapir.codegen.testutils.CompileCheckTestBase +import scala.util.Try + class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { + def noDefault(f: OpenapiSchemaType): OpenapiSchemaField = OpenapiSchemaField(f, None) it should "generate the example class defs" in { new ClassDefinitionGenerator().classDefs(TestHelpers.myBookshopDoc).get shouldCompile () @@ -20,7 +23,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(false)), Seq("text"), false) + "Test" -> OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(false))), Seq("text"), false) ) ) ) @@ -58,7 +61,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("type" -> OpenapiSchemaString(false)), Seq("type"), false) + "Test" -> OpenapiSchemaObject(Map("type" -> noDefault(OpenapiSchemaString(false))), Seq("type"), false) ) ) ) @@ -75,7 +78,11 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("texts" -> OpenapiSchemaArray(OpenapiSchemaString(false), false)), Seq("texts"), false) + "Test" -> OpenapiSchemaObject( + Map("texts" -> noDefault(OpenapiSchemaArray(OpenapiSchemaString(false), false))), + Seq("texts"), + false + ) ) ) ) @@ -92,7 +99,11 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("texts" -> OpenapiSchemaMap(OpenapiSchemaString(false), false)), Seq("texts"), false) + "Test" -> OpenapiSchemaObject( + Map("texts" -> noDefault(OpenapiSchemaMap(OpenapiSchemaString(false), false))), + Seq("texts"), + false + ) ) ) ) @@ -109,7 +120,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("anyType" -> OpenapiSchemaAny(false)), Seq("anyType"), false) + "Test" -> OpenapiSchemaObject(Map("anyType" -> noDefault(OpenapiSchemaAny(false))), Seq("anyType"), false) ) ) ) @@ -128,7 +139,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Map( "Test" -> OpenapiSchemaObject( Map( - "inner" -> OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(false)), Seq("text"), false) + "inner" -> noDefault(OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(false))), Seq("text"), false)) ), Seq("inner"), false @@ -149,16 +160,19 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject( - Map( - "objects" -> OpenapiSchemaArray( - OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(false)), Seq("text"), false), - false - ) - ), - Seq("objects"), - false - ) + "Test" -> + OpenapiSchemaObject( + Map( + "objects" -> noDefault( + OpenapiSchemaArray( + OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(false))), Seq("text"), false), + false + ) + ) + ), + Seq("objects"), + false + ) ) ) ) @@ -175,16 +189,19 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject( - Map( - "objects" -> OpenapiSchemaMap( - OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(false)), Seq("text"), false), - false - ) - ), - Seq("objects"), - false - ) + "Test" -> + OpenapiSchemaObject( + Map( + "objects" -> noDefault( + OpenapiSchemaMap( + OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(false))), Seq("text"), false), + false + ) + ) + ), + Seq("objects"), + false + ) ) ) ) @@ -201,7 +218,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(false)), Seq.empty, false) + "Test" -> OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(false))), Seq.empty, false) ) ) ) @@ -213,7 +230,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(false)), Seq("text"), false) + "Test" -> OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(false))), Seq("text"), false) ) ) ) @@ -233,7 +250,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(false)), Seq.empty, false) + "Test" -> OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(false))), Seq.empty, false) ) ) ) @@ -245,7 +262,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "Test" -> OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(true)), Seq("text"), false) + "Test" -> OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(true))), Seq("text"), false) ) ) ) @@ -322,7 +339,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { Some( OpenapiComponent( Map( - "MyObject" -> OpenapiSchemaObject(Map("text" -> OpenapiSchemaString(true)), Seq("text"), false), + "MyObject" -> OpenapiSchemaObject(Map("text" -> noDefault(OpenapiSchemaString(true))), Seq("text"), false), "MyEnum" -> OpenapiSchemaEnum("string", Seq(OpenapiSchemaConstantString("enum1"), OpenapiSchemaConstantString("enum2")), false), "MyMapPrimitive" -> OpenapiSchemaMap(OpenapiSchemaString(false), false), "MyMapObject" -> OpenapiSchemaMap(OpenapiSchemaRef("#/components/schemas/MyObject"), false), @@ -405,11 +422,11 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { "OneOfValue" -> OpenapiSchemaArray(OpenapiSchemaBinary(false), false), "TopObject" -> OpenapiSchemaObject( Map( - "innerMap" -> OpenapiSchemaRef("#/components/schemas/TopMap"), - "innerArray" -> OpenapiSchemaRef("#/components/schemas/TopArray"), - "innerOneOf" -> OpenapiSchemaRef("#/components/schemas/TopOneOf"), - "innerBoolean" -> OpenapiSchemaBoolean(false), - "recursiveEntry" -> OpenapiSchemaRef("#/components/schemas/TopObject") + "innerMap" -> OpenapiSchemaField(OpenapiSchemaRef("#/components/schemas/TopMap"), None), + "innerArray" -> OpenapiSchemaField(OpenapiSchemaRef("#/components/schemas/TopArray"), None), + "innerOneOf" -> OpenapiSchemaField(OpenapiSchemaRef("#/components/schemas/TopOneOf"), None), + "innerBoolean" -> OpenapiSchemaField(OpenapiSchemaBoolean(false), None), + "recursiveEntry" -> OpenapiSchemaField(OpenapiSchemaRef("#/components/schemas/TopObject"), None) ), Nil, false @@ -439,4 +456,31 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { ) shouldEqual allSchemas.keySet } + + it should "error on illegal default declarations" in { + val yaml = + """ + |schemas: + | ReqWithDefaults: + | required: + | - f1 + | type: object + | properties: + | f1: + | type: string + | default: 1977 + |""".stripMargin + + val doc: OpenapiComponent = parser + .parse(yaml) + .leftMap(err => err: Error) + .flatMap(_.as[OpenapiComponent]) + .toTry + .get + val gen = new ClassDefinitionGenerator() + val res1 = Try(gen.classDefs(OpenapiDocument("", null, null, Some(doc)))).toEither + + res1.left.get.getMessage shouldEqual "Cannot render a number as type sttp.tapir.codegen.openapi.models.OpenapiSchemaType$OpenapiSchemaString." + + } } diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala index e5bab6ecf6..8f9ca7bfb4 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala @@ -20,6 +20,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiSecuritySchemeType.{ import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{ OpenapiSchemaArray, OpenapiSchemaBinary, + OpenapiSchemaField, OpenapiSchemaObject, OpenapiSchemaRef, OpenapiSchemaString @@ -223,7 +224,7 @@ class EndpointGeneratorSpec extends CompileCheckTestBase { schemas = Map( "FileUpload" -> OpenapiSchemaObject( properties = Map( - "file" -> OpenapiSchemaBinary(false) + "file" -> OpenapiSchemaField(OpenapiSchemaBinary(false), None) ), required = Seq("file"), nullable = false diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/TestHelpers.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/TestHelpers.scala index e84024803a..41df5a39b4 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/TestHelpers.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/TestHelpers.scala @@ -1,27 +1,19 @@ package sttp.tapir.codegen -import sttp.tapir.codegen.openapi.models.OpenapiComponent -import sttp.tapir.codegen.openapi.models.OpenapiModels.{ - OpenapiDocument, - OpenapiInfo, - OpenapiParameter, - OpenapiPath, - OpenapiPathMethod, - OpenapiRequestBody, - OpenapiRequestBodyContent, - OpenapiResponse, - OpenapiResponseContent, - Ref, - Resolved -} +import io.circe.Json +import sttp.tapir.codegen.openapi.models._ +import sttp.tapir.codegen.openapi.models.OpenapiModels._ import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{ OpenapiSchemaArray, OpenapiSchemaConstantString, OpenapiSchemaEnum, + OpenapiSchemaField, + OpenapiSchemaFloat, OpenapiSchemaInt, OpenapiSchemaObject, OpenapiSchemaRef, - OpenapiSchemaString + OpenapiSchemaString, + OpenapiSchemaUUID } object TestHelpers { @@ -232,7 +224,7 @@ object TestHelpers { Some( OpenapiComponent( schemas = Map( - "Book" -> OpenapiSchemaObject(Map("title" -> OpenapiSchemaString(false)), Seq("title"), false) + "Book" -> OpenapiSchemaObject(Map("title" -> OpenapiSchemaField(OpenapiSchemaString(false), None)), Seq("title"), false) ), securitySchemes = Map.empty, parameters = Map( @@ -366,12 +358,12 @@ object TestHelpers { Some( OpenapiComponent( Map( - "Author" -> OpenapiSchemaObject(Map("name" -> OpenapiSchemaString(false)), List("name"), false), + "Author" -> OpenapiSchemaObject(Map("name" -> OpenapiSchemaField(OpenapiSchemaString(false), None)), List("name"), false), "Book" -> OpenapiSchemaObject( properties = Map( - "title" -> OpenapiSchemaString(false), - "year" -> OpenapiSchemaInt(false), - "author" -> OpenapiSchemaRef("#/components/schemas/Author") + "title" -> OpenapiSchemaField(OpenapiSchemaString(false), None), + "year" -> OpenapiSchemaField(OpenapiSchemaInt(false), None), + "author" -> OpenapiSchemaField(OpenapiSchemaRef("#/components/schemas/Author"), None) ), required = Seq("title", "year", "author"), nullable = false @@ -577,4 +569,198 @@ object TestHelpers { ) ) ) + + val withDefaultsYaml = + """ + |openapi: 3.1.0 + |info: + | title: default test + | version: '1.0' + |paths: + | /hello: + | post: + | requestBody: + | description: Foo + | required: true + | content: + | application/json: + | schema: + | $ref: '#/components/schemas/ReqWithDefaults' + | responses: + | '200': + | description: Bar + | content: + | application/json: + | schema: + | type: array + | items: + | $ref: '#/components/schemas/RespWithDefaults' + |components: + | schemas: + | ReqWithDefaults: + | required: + | - f1 + | type: object + | properties: + | f1: + | type: string + | default: default string + | f2: + | type: integer + | format: int32 + | default: 1977 + | RespWithDefaults: + | required: + | - g2 + | type: object + | properties: + | g1: + | type: string + | format: uuid + | default: default string + | g2: + | type: number + | format: float + | default: 1977 + | g3: + | $ref: '#/components/schemas/AnEnum' + | default: v1 + | g4: + | type: array + | items: + | $ref: '#/components/schemas/AnEnum' + | default: [v1, v2, v3] + | sub: + | $ref: '#/components/schemas/SubObject' + | default: + | subsub: + | value: hi there + | value2: ac8113ed-6105-4f65-a393-e88be2c5d585 + | AnEnum: + | title: AnEnum + | type: string + | enum: + | - v1 + | - v2 + | - v3 + | SubObject: + | required: + | - subsub + | type: object + | properties: + | subsub: + | $ref: '#/components/schemas/SubSubObject' + | SubSubObject: + | required: + | - value + | type: object + | properties: + | value: + | type: string + | value2: + | type: string + | format: uuid + |""".stripMargin + + val withDefaultsDocs = OpenapiDocument( + "3.1.0", + OpenapiInfo("default test", "1.0"), + List( + OpenapiPath( + "/hello", + List( + OpenapiPathMethod( + "post", + List(), + List( + OpenapiResponse( + "200", + "Bar", + List( + OpenapiResponseContent( + "application/json", + OpenapiSchemaArray(OpenapiSchemaRef("#/components/schemas/RespWithDefaults"), false) + ) + ) + ) + ), + Some( + OpenapiRequestBody( + true, + Some("Foo"), + List(OpenapiRequestBodyContent("application/json", OpenapiSchemaRef("#/components/schemas/ReqWithDefaults"))) + ) + ), + List(), + None, + None, + None + ) + ), + List() + ) + ), + Some( + OpenapiComponent( + Map( + "ReqWithDefaults" -> OpenapiSchemaObject( + Map( + "f1" -> OpenapiSchemaField(OpenapiSchemaString(false), Some(Json.fromString("default string"))), + "f2" -> OpenapiSchemaField(OpenapiSchemaInt(false), Some(Json.fromLong(1977))) + ), + List("f1"), + false + ), + "RespWithDefaults" -> OpenapiSchemaObject( + Map( + "g1" -> OpenapiSchemaField(OpenapiSchemaUUID(false), Some(Json.fromString("default string"))), + "g2" -> OpenapiSchemaField(OpenapiSchemaFloat(false), Some(Json.fromLong(1977))), + "g3" -> OpenapiSchemaField(OpenapiSchemaRef("#/components/schemas/AnEnum"), Some(Json.fromString("v1"))), + "g4" -> OpenapiSchemaField( + OpenapiSchemaArray(OpenapiSchemaRef("#/components/schemas/AnEnum"), false), + Some(Json.fromValues(Vector(Json.fromString("v1"), Json.fromString("v2"), Json.fromString("v3")))) + ), + "sub" -> OpenapiSchemaField( + OpenapiSchemaRef("#/components/schemas/SubObject"), + Some( + Json.fromFields( + Map( + "subsub" -> Json.fromFields( + Map( + "value" -> Json.fromString("hi there"), + "value2" -> Json.fromString("ac8113ed-6105-4f65-a393-e88be2c5d585") + ) + ) + ) + ) + ) + ) + ), + List("g2"), + false + ), + "AnEnum" -> OpenapiSchemaEnum( + "string", + List(OpenapiSchemaConstantString("v1"), OpenapiSchemaConstantString("v2"), OpenapiSchemaConstantString("v3")), + false + ), + "SubObject" -> OpenapiSchemaObject( + Map("subsub" -> OpenapiSchemaField(OpenapiSchemaRef("#/components/schemas/SubSubObject"), None)), + List("subsub"), + false + ), + "SubSubObject" -> OpenapiSchemaObject( + Map( + "value" -> OpenapiSchemaField(OpenapiSchemaString(false), None), + "value2" -> OpenapiSchemaField(OpenapiSchemaUUID(false), None) + ), + List("value"), + false + ) + ), + Map(), + Map() + ) + ) + ) } diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/ModelParserSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/ModelParserSpec.scala index 1489ad50bb..5c640f7945 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/ModelParserSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/ModelParserSpec.scala @@ -167,4 +167,15 @@ class ModelParserSpec extends AnyFlatSpec with Matchers with Checkers { OpenapiSchemaEnum("string", Seq(OpenapiSchemaConstantString("paperback"), OpenapiSchemaConstantString("hardback")), false) ) } + + it should "parse endpoint with defaults" in { + val res = parser + .parse(TestHelpers.withDefaultsYaml) + .leftMap(err => err: Error) + .flatMap(_.as[OpenapiDocument]) + + res shouldBe (Right( + TestHelpers.withDefaultsDocs + )) + } } diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/SchemaParserSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/SchemaParserSpec.scala index 36c0ed6cb7..4a7c10a90a 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/SchemaParserSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/models/SchemaParserSpec.scala @@ -4,6 +4,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiResponseContent import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{ OpenapiSchemaAny, OpenapiSchemaArray, + OpenapiSchemaField, OpenapiSchemaInt, OpenapiSchemaMap, OpenapiSchemaObject, @@ -49,7 +50,10 @@ class SchemaParserSpec extends AnyFlatSpec with Matchers with Checkers { OpenapiComponent( Map( "User" -> OpenapiSchemaObject( - Map("id" -> OpenapiSchemaInt(false), "name" -> OpenapiSchemaString(false)), + Map( + "id" -> OpenapiSchemaField(OpenapiSchemaInt(false), None), + "name" -> OpenapiSchemaField(OpenapiSchemaString(false), None) + ), Seq("id", "name"), false ) @@ -81,7 +85,7 @@ class SchemaParserSpec extends AnyFlatSpec with Matchers with Checkers { OpenapiComponent( Map( "User" -> OpenapiSchemaObject( - Map("attributes" -> OpenapiSchemaMap(OpenapiSchemaString(false), false)), + Map("attributes" -> OpenapiSchemaField(OpenapiSchemaMap(OpenapiSchemaString(false), false), None)), Seq("attributes"), false ) @@ -109,7 +113,7 @@ class SchemaParserSpec extends AnyFlatSpec with Matchers with Checkers { OpenapiComponent( Map( "User" -> OpenapiSchemaObject( - Map("anyValue" -> OpenapiSchemaAny(false)), + Map("anyValue" -> OpenapiSchemaField(OpenapiSchemaAny(false), None)), Seq("anyValue"), false )