softwaremill / tapir

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

Support for akka style `Map[String, V]` path matchers #625

Open tg44 opened 4 years ago

tg44 commented 4 years ago

I'm currently migrating lot of endpoints from akka-http to tapir. And sometimes we used some really handy akka features in our code. My current struggle is the Map[String, V] handling;

Here are the official akka path matchers list: https://doc.akka.io/docs/akka-http/current/routing-dsl/path-matchers.html#basic-pathmatchers

My not tested but compiling code for the Maps;

  def pathFromMapWithDefault[K: PlainCodec, V](inMap: Map[K, V], default: V): EndpointInput[V] =
    EndpointInput
      .PathCapture(implicitly[PlainCodec[K]], None, EndpointIO.Info.empty)
      .map(k => inMap.getOrElse(k, default))(_ => inMap.keys.head)
  def pathFromMapWithDefault[K: PlainCodec, V](
      inMap: Map[K, V],
      default: V,
      name: String
  ): EndpointInput[V] =
    EndpointInput
      .PathCapture(implicitly[PlainCodec[K]], Some(name), EndpointIO.Info.empty)
      .map(k => inMap.getOrElse(k, default))(_ => inMap.keys.head)

  def stringMapValidator[V](inMap: Map[String, V]) = {
    Validator.Enum[String](inMap.keys.toList, Option(s => Some(s)))
  }

  def pathFromStringMap[V](inMap: Map[String, V]): EndpointInput[V] =
    EndpointInput
      .PathCapture(Codec.stringPlainCodecUtf8.validate(stringMapValidator(inMap)), None, EndpointIO.Info.empty)
      .map(inMap)(_ => inMap.keys.head)
  def pathFromStringMap[V](inMap: Map[String, V], name: String): EndpointInput[V] =
    EndpointInput
      .PathCapture(Codec.stringPlainCodecUtf8.validate(stringMapValidator(inMap)), Some(name), EndpointIO.Info.empty)
      .map(inMap)(_ => inMap.keys.head)

  def pathFromMap[K: PlainCodec, V](
      inMap: Map[K, V],
      encode: Option[Validator.EncodeToRaw[K]]
  ): EndpointInput[V] =
    EndpointInput
      .PathCapture(
        implicitly[PlainCodec[K]].validate(Validator.Enum[K](inMap.keys.toList, encode)),
        None,
        EndpointIO.Info.empty
      )
      .map(inMap)(_ => inMap.keys.head)
  def pathFromMap[K: PlainCodec, V](
      inMap: Map[K, V],
      encode: Option[Validator.EncodeToRaw[K]],
      name: String
  ): EndpointInput[V] =
    EndpointInput
      .PathCapture(
        implicitly[PlainCodec[K]].validate(Validator.Enum[K](inMap.keys.toList, encode)),
        Some(name),
        EndpointIO.Info.empty
      )
      .map(inMap)(_ => inMap.keys.head)

My main question is the .map(inMap)(_ => inMap.keys.head) line and how bad this idea is? (If we not count the Map.empty.head case.)

Also can we add this next to the official path[T]?

adamw commented 4 years ago

Interesting, I didn't know about this akka-http feature :)

Let's maybe first look at the first case, leaving validation for later.

So we have:

def pathFromMapWithDefault[K: PlainCodec, V](inMap: Map[K, V], default: V): EndpointInput[V]

decoding is quite straightforward, encoding is more tricky, as we have a value, which we need to convert to a key (this would be used when calling this endpoint as a client).

So we'd need to create a Map[V, K] - also with a default. Question is then, what to do when there's no matching key? We could either have defaultK: K, or throw an exception indicating a programming error - there's currently no other way to signal an encoding error.

The impl would be sth like:

def pathFromMapWithDefault[K: PlainCodec, V](inMap: Map[K, V], default: V): EndpointInput[V] = {
    val vToK = inMap.map(_.swap)
    path[K].map(k => inMap.getOrElse(k, default))(v => vToK.getOrElse(v, throw new RuntimeException(s"No key for value: $v")))
}

What do you think?

tg44 commented 4 years ago

Hmm... If we should map both directions, I would use bimap with some implementations, and probably with an api with implicit converter from map. (So the def would be def pathFromMapWithDefault[K: PlainCodec, V](inMap: BiMap[K, V], default: V): EndpointInput[V], and you need to import bimap.syntax._ for a pathFromMapWithDefault(Map.empty[A,B].toBimap, B.empty) call option. Also probably would use a default K for the 'withDefault(s)' function.)

BTW: the def pathFromStringMap[V](inMap: Map[String, V]): EndpointInput[V] works as expected; I get

- name: p3
        in: path
        required: true
        schema:
          type: string
          enum:
          - add
          - remove

to my output yaml, the APIs runs as expected with it.

adamw commented 4 years ago

Sure, bimap would be an option as well. And yes, your implementation works for server & doc interpreters, but would fail for client interpreter (though you might not need it :) )

tg44 commented 4 years ago

Okay! I will draft a PR with this.