From 036f03f0ba859a87902c561859d0d27f6b9cbda2 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Mon, 26 Aug 2024 11:01:16 +1000 Subject: [PATCH] [OpenAPI] case class field being an Option of an ADT are not rendered as nullable --- .../endpoint/openapi/OpenAPIGenSpec.scala | 124 +++++++++++++++++- .../http/endpoint/openapi/JsonSchema.scala | 31 +++-- .../http/endpoint/openapi/OpenAPIGen.scala | 2 +- 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala index 967bd1630d5..efe02f838f9 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala @@ -160,6 +160,11 @@ object OpenAPIGenSpec extends ZIOSpecDefault { implicit def schema[T: Schema]: Schema[WithGenericPayload[T]] = DeriveSchema.gen } + final case class WithOptionalAdtPayload(optionalAdtField: Option[SealedTraitCustomDiscriminator]) + object WithOptionalAdtPayload { + implicit val schema: Schema[WithOptionalAdtPayload] = DeriveSchema.gen + } + private val simpleEndpoint = Endpoint( (GET / "static" / int("id") / uuid("uuid") ?? Doc.p("user id") / string("name")) ?? Doc.p("get path"), @@ -2231,9 +2236,9 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "discriminator" : { | "propertyName" : "type", | "mapping" : { - | "One" : "#/components/schemas/One}", - | "Two" : "#/components/schemas/Two}", - | "three" : "#/components/schemas/Three}" + | "One" : "#/components/schemas/One", + | "Two" : "#/components/schemas/Two", + | "three" : "#/components/schemas/Three" | } | } | }, @@ -2810,7 +2815,10 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | ] | }, | "nestedOption" : { - | "$ref" : "#/components/schemas/Recursive" + | "anyOf" : [ + | { "type" : "null" }, + | { "$ref" : "#/components/schemas/Recursive" } + | ] | }, | "nestedMap" : { | "type" : "object", @@ -2989,6 +2997,114 @@ object OpenAPIGenSpec extends ZIOSpecDefault { |""".stripMargin assertTrue(json == toJsonAst(expectedJson)) }, + test("Optional ADT payload") { + val endpoint = Endpoint(GET / "static").in[WithOptionalAdtPayload] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/WithOptionalAdtPayload" + | } + | } + | }, + | "required" : true + | } + | } + | } + | }, + | "components" : { + | "schemas" : { + | "One" : + | { + | "type" : + | "object", + | "properties" : {} + | }, + | "SealedTraitCustomDiscriminator" : + | { + | "oneOf" : [ + | { + | "$ref" : "#/components/schemas/One" + | }, + | { + | "$ref" : "#/components/schemas/Two" + | }, + | { + | "$ref" : "#/components/schemas/Three" + | } + | ], + | "discriminator" : { + | "propertyName" : "type", + | "mapping" : { + | "One" : "#/components/schemas/One", + | "Two" : "#/components/schemas/Two", + | "three" : "#/components/schemas/Three" + | } + | } + | }, + | "WithOptionalAdtPayload" : + | { + | "type" : + | "object", + | "properties" : { + | "optionalAdtField" : { + | "anyOf": [ + | { "type": "null" }, + | { "$ref": "#/components/schemas/SealedTraitCustomDiscriminator" } + | ] + | } + | }, + | "required" : [ + | "optionalAdtField" + | ] + | }, + | "Two" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "required" : [ + | "name" + | ] + | }, + | "Three" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "required" : [ + | "name" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, ) } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala index ed8e1bc2847..8e4668435a8 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala @@ -12,7 +12,7 @@ import zio.schema.codec._ import zio.schema.codec.json._ import zio.schema.validation._ -import zio.http.codec.{PathCodec, SegmentCodec, TextCodec} +import zio.http.codec.{SegmentCodec, TextCodec} @nowarn("msg=possible missing interpolator") private[openapi] case class SerializableJsonSchema( @@ -47,23 +47,35 @@ private[openapi] case class SerializableJsonSchema( uniqueItems: Option[Boolean] = None, minItems: Option[Int] = None, ) { - def asNullableType(nullable: Boolean): SerializableJsonSchema = + def asNullableType(nullable: Boolean): SerializableJsonSchema = { + import SerializableJsonSchema.typeNull + if (nullable && schemaType.isDefined) copy(schemaType = Some(schemaType.get.add("null"))) else if (nullable && oneOf.isDefined) - copy(oneOf = Some(oneOf.get :+ SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("null"))))) + copy(oneOf = Some(oneOf.get :+ typeNull)) else if (nullable && allOf.isDefined) - SerializableJsonSchema(allOf = - Some(Chunk(this, SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("null"))))), - ) + SerializableJsonSchema(allOf = Some(Chunk(this, typeNull))) else if (nullable && anyOf.isDefined) - copy(anyOf = Some(anyOf.get :+ SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("null"))))) + copy(anyOf = Some(anyOf.get :+ typeNull)) + else if (nullable && ref.isDefined && default.isEmpty) + SerializableJsonSchema(anyOf = Some(Chunk(typeNull, this))) else this - + } } private[openapi] object SerializableJsonSchema { + + /** + * Used to generate a OpenAPI schema part looking like this: + * {{{ + * { "type": "null"} + * }}} + */ + private[SerializableJsonSchema] val typeNull: SerializableJsonSchema = + SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("null"))) + implicit val doubleOrLongSchema: Schema[Either[Double, Long]] = Schema.fallback(Schema[Double], Schema[Long]).transform(_.toEither, Fallback.fromEither) @@ -766,7 +778,8 @@ object JsonSchema { case nominal: TypeId.Nominal if referenceType == SchemaStyle.Reference => Some(s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}") case nominal: TypeId.Nominal if referenceType == SchemaStyle.Compact => - Some(s"#/components/schemas/${nominal.typeName}") + val toto = s"#/components/schemas/${nominal.typeName}" + Some(toto) case _ => None } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala index 2e4d0e500f4..dee8fe54764 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala @@ -988,7 +988,7 @@ object OpenAPIGen { } private def schemaReferencePath(nominal: TypeId.Nominal, referenceType: SchemaStyle): String = { referenceType match { - case SchemaStyle.Compact => s"#/components/schemas/${nominal.typeName}}" + case SchemaStyle.Compact => s"#/components/schemas/${nominal.typeName}" case _ => s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}}" } }