softwaremill / tapir

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

Support Deriving Schemas for non String Map Keys #918

Closed sbrunk closed 2 years ago

sbrunk commented 3 years ago

As discussed on Gitter (a while ago), extending SchemaMapMacro.schemaForMap to support non-string Map keys would help for cases where string keys are wrapped in values classes (might also apply to newtypes/opaque types).

Consider the following example:

case class User(name: String) extends AnyVal {
  override def toString: String = name
}

object User {
  implicit val encoder: Encoder[User] = deriveUnwrappedEncoder
  implicit val decoder: Decoder[User] = deriveUnwrappedDecoder

  implicit val keyEncoder: KeyEncoder[User] = _.name
  implicit val keyDecoder: KeyDecoder[User] = key => Some(User(key))
}

val ep = endpoint.get.out(jsonBody[Map[User, String]])

Currently, this doesn't work because the map key in schemaForMap is fixed to String: https://github.com/softwaremill/tapir/blob/12c641fe73e5658fb5905477dd6271123662ee1c/core/src/main/scala/sttp/tapir/Schema.scala#L142

perok commented 3 years ago

I have been experimenting with the following setup. Not quite sure about the format and description handling yet.

  implicit def schemaForMap[K: KeyEncoder: KeyDecoder: Schema, V: Schema]: Schema[Map[K, V]] = {
    val keySchema = implicitly[Schema[K]]

    val mapSchema = Schema
      .schemaForMap[V]
      .contramap[Map[K, V]](_.map { case (k, v) => (KeyEncoder[K].apply(k), v) })

    val mapSchemaWithFormat = keySchema.format
      .orElse(mapSchema.format)
      .fold(mapSchema)(mapSchema.format(_))

    keySchema.description
      .orElse(mapSchema.description)
      .fold(mapSchemaWithFormat)(mapSchemaWithFormat.description(_))
  }
DenisNovac commented 3 years ago

I have been experimenting with the following setup. Not quite sure about the format and description handling yet.

  implicit def schemaForMap[K: KeyEncoder: KeyDecoder: Schema, V: Schema]: Schema[Map[K, V]] = {
    val keySchema = implicitly[Schema[K]]

    val mapSchema = Schema
      .schemaForMap[V]
      .contramap[Map[K, V]](_.map { case (k, v) => (KeyEncoder[K].apply(k), v) })

    val mapSchemaWithFormat = keySchema.format
      .orElse(mapSchema.format)
      .fold(mapSchema)(mapSchema.format(_))

    keySchema.description
      .orElse(mapSchema.description)
      .fold(mapSchemaWithFormat)(mapSchemaWithFormat.description(_))
  }

If this workaround used in multiple places with differently typed Maps - in Swagger it will show only one variant (Map_V) for every appearances. Maybe it is because of #763 .

perok commented 3 years ago

Yeah, it seems so. A workaround is to add a TypeTag constraint to get the full type name:

  def fixSchemaName[Original, Additional: TypeTag](schema: Schema[Original]): Schema[Original] = {
    import sttp.tapir.SchemaType.{SOpenProduct, SProduct}
    val typesNames: String = {
      val typeArgs = typeOf[Additional].typeArgs
        .map(_.typeSymbol.name.toString)

      s"${typeOf[Additional].typeSymbol.name.toString}${if (typeArgs.nonEmpty) typeArgs.mkString("[", ",", "]")
      else ""}"
    }

    schema.copy(schemaType = schema.schemaType match {
      case a: SOpenProduct =>
        a.copy(info = a.info.copy(typeParameterShortNames = List(typesNames)))
      case a: SProduct =>
        a.copy(info = a.info.copy(typeParameterShortNames = List(typesNames)))
      case a => a
    })
  }

  /**
    * If we know [[K]] is a KeyEncoder, KeyDecoder then the representation is a String that
    * is usable in a Json Map.
    *
    * @tparam K Key
    * @tparam V Value
    * @return Schema for Map[K, V]
    */
  @SuppressWarnings(
    Array("org.wartremover.warts.AsInstanceOf", "org.wartremover.warts.OptionPartial")
  )
  implicit def schemaForMap[K: KeyEncoder: KeyDecoder: Schema, V: Schema: TypeTag]
      : Schema[Map[K, V]] = {
    val keySchema = implicitly[Schema[K]]

    val stringKeySchema = Schema.schemaForMap[V]
    val schema_ = stringKeySchema.asInstanceOf[Schema[Map[K, V]]]

    val schema = fixSchemaName[Map[K, V], V](schema_)

    val schemaWithFormat = keySchema.format
      .fold(schema)(schema.format(_))

    keySchema.description
      .fold(schemaWithFormat)(schemaWithFormat.format(_))
  }
gagbo commented 3 years ago

So to use this I should create my own package in my project to redefine the new implicit ? Or should I somehow patch the tapir dependency that I import ?

adamw commented 3 years ago

Any implicits that you import or have defined next to where you use them, have precedence over those in the companion objects, so you shouldn't have to patch tapir :)

esgott commented 3 years ago

might also apply to newtypes/opaque types

I can confirm this for opaque types

adamw commented 3 years ago

See: https://github.com/softwaremill/tapir/pull/1404

The proposed solution does not define an implicit, but a method to create a map schema given a function to convert the keys to strings.

We could probably do a better job with value classes and opaque types, though.

adamw commented 3 years ago

Keeping this open to implement automatic derivation when keys are value classes / opaque types

adamw commented 2 years ago

On second (or third :) ) thought, I don't think we can do much more for value classes etc., as we need the key-to-string conversion function anyway. Hence closing, for related feature requests please open new issues.

SarpongAbasimi commented 2 years ago

@adamw please what is the outcome for this because I am facing the same issue I have something is this

final case class Info(value: String) extends AnyVal
case class User(name: String, data: Map[Info, Set[String]])

but the map key in schemaForMap is fixed to String:

def schemaForMap[V: Schema]: Schema[Map[String, V]] = macro SchemaMapMacro.schemaForMap[Map[String, V], V]

adamw commented 2 years ago

@SarpongAbasimi you should use this variant: def schemaForMap[K, V: Schema](keyToString: K => String): Schema[Map[K, V]]

SarpongAbasimi commented 2 years ago

@adamw thank you. so this is it.

   implicit val rolesSchema: Schema[Map[Info, Set[String]]] = Schema.schemaForMap[Info, Set[String]](_.value)
   implicit val userSchema: Schema[User] = Schema.derived[User]