softwaremill / sttp-apispec

OpenAPI, AsyncAPI and JSON Schema Scala models.
Apache License 2.0
23 stars 11 forks source link

Safer ExampleValue representation #154

Open ghik opened 4 months ago

ghik commented 4 months ago

Current Scala representation of OpenAPI example values is rather unprincipled:

sealed trait ExampleValue
case class ExampleSingleValue(value: Any) extends ExampleValue
case class ExampleMultipleValue(values: List[Any]) extends ExampleValue

The way these untyped Any values are constructed and interpreted is undocumented and unclear. The only way to actually know what they should be is to look into how they are serialized:

  implicit val encoderExampleSingleValue: Encoder[ExampleSingleValue] = {
    case ExampleSingleValue(value: String)     => parse(value).getOrElse(Json.fromString(value))
    case ExampleSingleValue(value: Int)        => Json.fromInt(value)
    case ExampleSingleValue(value: Long)       => Json.fromLong(value)
    case ExampleSingleValue(value: Float)      => Json.fromFloatOrString(value)
    case ExampleSingleValue(value: Double)     => Json.fromDoubleOrString(value)
    case ExampleSingleValue(value: Boolean)    => Json.fromBoolean(value)
    case ExampleSingleValue(value: BigDecimal) => Json.fromBigDecimal(value)
    case ExampleSingleValue(value: BigInt)     => Json.fromBigInt(value)
    case ExampleSingleValue(null)              => Json.Null
    case ExampleSingleValue(value)             => Json.fromString(value.toString)
  }

  implicit val encoderMultipleExampleValue: Encoder[ExampleMultipleValue] = { e =>
    Json.arr(e.values.map(v => encoderExampleSingleValue(ExampleSingleValue(v))): _*)
  }

  implicit val encoderExampleValue: Encoder[ExampleValue] = {
    case e: ExampleMultipleValue => encoderMultipleExampleValue.apply(e)
    case e: ExampleSingleValue   => encoderExampleSingleValue.apply(e)
  }

This implementation does at least two things which are presumably for "convenience" but are in fact completely undocumented and unexpected by the user:

The decoder is also not without problems:

  implicit val exampleSingleValueDecoder: Decoder[ExampleSingleValue] =
    Decoder[Json].map(json => json.asString.map(ExampleSingleValue(_)).getOrElse(ExampleSingleValue(json)))

  implicit val exampleMultipleValueDecoder: Decoder[ExampleMultipleValue] =
    Decoder[List[Json]].map { json =>
      val listString = json.flatMap(_.asString)
      if (listString.nonEmpty) {
        ExampleMultipleValue(listString)
      } else ExampleMultipleValue(json)
    }

  implicit val exampleValueDecoder: Decoder[ExampleValue] =
    exampleMultipleValueDecoder.widen[ExampleValue].or(exampleSingleValueDecoder.widen[ExampleValue])

All these problems are very typical of code that takes shortcuts by using untyped representations and APIs like Any and .toString.

A more principled way to represent examples would be to use a serialization-library-agnostic JSON representation. This could be something as simple as a raw JSON string wrapper or possibly a minimalistic, ad-hoc JSON AST representation.

Solving this issue would likely require changes beyond sttp-apispec as tapir would most likely also be affected since it is the primary user of this library.