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

[OpenAPI] case class field being an Option of an ADT are not rendered as nullable #3053

Merged
merged 5 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"
| }
| }
| },
Expand Down Expand Up @@ -2810,7 +2815,10 @@ object OpenAPIGenSpec extends ZIOSpecDefault {
| ]
| },
| "nestedOption" : {
| "$ref" : "#/components/schemas/Recursive"
| "anyOf" : [
| { "type" : "null" },
| { "$ref" : "#/components/schemas/Recursive" }
| ]
| },
| "nestedMap" : {
| "type" : "object",
Expand Down Expand Up @@ -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" }
| ]
| }
Comment on lines +3063 to +3068
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now correctly have the anyOf

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder, if this should be anyOf, or if the field should just be missing from the required fields list. I'd tend to the second. Most Scala json serialization does not write the field if the option is None

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe both. To be accept all possible encodings

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. They are now both nullable and non-required

| },
| "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))
},
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

case SchemaStyle.Compact => s"#/components/schemas/${nominal.typeName}"
case _ => s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}}"
}
}
Expand Down
Loading