softwaremill / tapir

Rapid development of self-documenting APIs
https://tapir.softwaremill.com
Apache License 2.0
1.32k stars 403 forks source link

[BUG] Enumeration schema is inconsistent depending on the encoding function #3900

Open seveneves opened 6 days ago

seveneves commented 6 days ago

Tapir version: 1.10.10

Scala version: 2.13/3.3.3

I am deriving apispec.Schema from tapir.Schema for an enumeration and found that the default encode function produces inconsistent apispec.Schemas. Such inconsistency is exposed when apispec.Schema is encoded and decoded to/form json.

This problem is highlighted with the following code

Given following sealed trait

sealed trait Gender
case object Female extends Gender
case object Male extends Gender

with helper methods

import io.circe.syntax.*
import sttp.apispec.circe.*
import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema
import sttp.{apispec, tapir}

def decodeJson(jsonSchema: String): apispec.Schema =
  io.circe.parser
    .decode[apispec.Schema](jsonSchema)
    .toTry
    .get

def encodeJson(schema: apispec.Schema) =  schema.asJson.deepDropNullValues.toString()

def apiSpecSchema[T: tapir.Schema] = TapirSchemaToJsonSchema(
  implicitly[tapir.Schema[T]],
  markOptionsAsNullable = true,
)

Then the following works correctly and assertion passes

implicit val GenderSchema = tapir.Schema.derivedEnumeration[Gender](encode = Some(_.toString))
val derivedSchema: apispec.Schema = apiSpecSchema[Gender]
val decodedSchema = decodeJson(encodeJson(derivedSchema))
assert(derivedSchema == decodedSchema)

But when using derivedEnumeration with the default encode function, the following code fails

implicit val GenderSchema = tapir.Schema.derivedEnumeration[Gender]()
val derivedSchema: apispec.Schema = apiSpecSchema[Gender]
val decodedSchema: apispec.Schema = decodeJson(encodeJson(derivedSchema))
assert(derivedSchema == decodedSchema)
adamw commented 5 days ago

Hm it seems that this should work, being encoded to json here:

https://github.com/softwaremill/sttp-apispec/blob/a85fff8e6aeb8af7ab162c77a1d1a6db58493841/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala#L134

and converted to a validator here:

https://github.com/softwaremill/tapir/blob/f93ed86d5eae7f6f689514adedc7d4712f3cb02b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/TSchemaToASchema.scala#L150

But clearly something doesn't work. Would need debugging :)

seveneves commented 5 days ago

@adamw the problem is that with the default encode function, the produced Enumeration has wraped instances of sealed trait as SString schema. While after encoding and decoding, it represents such values as actual Scala String. Then of course equality breaks when it compares Scala String with an instance of the sealed trait. So the equality boils down to "Female" == Female which of course is false.

When using encode as _.toString then it forces the derived schema to have actual String values instead of instances of the sealed trait. And this fixes the equality check.

adamw commented 5 days ago

Ah right ... so we would need special comparison logic for example values, which mirrors what encoderExampleSingleValue is doing, that is for all non-primitive values, it just performs .toString