softwaremill / tapir

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

Combining path extractors #1253

Closed tg44 closed 3 years ago

tg44 commented 3 years ago

My first use-case is simple; I want a path matcher which matches either to a Long or the exact string "actual".

In akkaHttp I could write a matcher like;

def fallbackMatcher(default: String) = PathMatcher(default :: Path.Empty, ()).tmap(_ => Tuple1(Option.empty[Long]))
def LongOr(default: String)          = (LongNumber.tmap(l => Tuple1(Option(l._1))) | fallbackMatcher(default))

pathPrefix( "foo" / LongOr("actual") / "bar")

So both foo/5/bar and foo/actual/bar could be matched with it. OpenApi can handle this too as a

- name: p1
   in: path
   required: true
   schema:
     oneOf:
      - type: integer
      - type: string
         enum:
         - actual

(probably the enum can be changed to an exact match, but I'm not pro at the specification).

What I've tried is;

  implicit class PathCaptureExtend[T](p: PathCapture[T]) {
    def or(o: PathCapture[T], name: String): PathCapture[T] = {
      val c1 = p.codec
      val c2 = o.codec
      val s  = Schema[T](SchemaType.SCoproduct(SchemaType.SObjectInfo(name), c1.schema :: c2.schema :: Nil, None))
      p.copy(codec = c1.schema(s))
    }

    def or(o: FixedPath[T], name: String): PathCapture[T] = {
      val c1 = p.codec
      val c2 = o.codec
      val s = Schema[T](SchemaType.SCoproduct(SchemaType.SObjectInfo(name), c1.schema :: c2.schema :: Nil, None))
      p.copy(codec = c1.schema(s))
    }
  }

  val longOrActualMatcher: EndpointInput.PathCapture[Option[Long]] =
    path[Long].map(i => Option(i))(_.get)
      .or(
        stringToPath("actual").map(_ => Option.empty[Long])(_ => {}),
        "epochMillisOrActual",
      )

This matches to Long but not matching the string, and generates a rather wrong API description.

My second use-case would be a bit bigger, bcs I also want to create a matcher which can replicate the same functionality as;

def scopeMatcher: PathMatcher[Tuple1[ReportScope]] = {
    val plantMatcher      = PathMatcher("plant" :: Path.Empty, Tuple1(Plant()))
    val departmentMatcher = ("department" / LongNumber).map(l => Department(l))
    val lineMatcher       = ("line" / LongNumber).map(l => Line(l))
    (plantMatcher | departmentMatcher | lineMatcher)
  }

And thirdly, it would be super interesting to add an option to "unpack" these matches in the docs. (So in the generated docs the above scope matcher would appear as 3 "different" routes). This unpack would be sometimes handy for;

def pathFromEnum[E <: Enumeration](ev: E) = {
    EndpointInput
      .PathCapture(
        None,
        Codec.string.validate(Validator.Enum[String](ev.values.toList.map(_.toString), Option(s => Some(s)))),
        EndpointIO.Info.empty,
      )
      .map(k => ev.values.find(_.toString == k).get)(e => e.toString)
      .example(ev.values.head)
  }

enums too, which could have been part of the original codebase or the enumerator integration page.

adamw commented 3 years ago

I'll have to think about this a bit more, but a first reflex and quickly writing down a possible solution (maybe it doesn't work ;) ) would be to use Codecs:

import scala.collection.immutable.ListMap
import sttp.tapir._

object TestMulti extends App {

  def codecEither[L, A, B, CF <: CodecFormat](c1: Codec[L, A, CF], c2: Codec[L, B, CF]): Codec[L, Either[A, B], CF] = {
    Codec
      .id[L, CF](
        c1.format,
        Schema[L](
          SchemaType.SCoproduct(
            SchemaType.SObjectInfo(s"Either[${c1.schema.schemaType.show},${c2.schema.schemaType.show}]"),
            ListMap(), // TODO
            None
          )(_ => None) // TODO
        )
      )
      .mapDecode[Either[A, B]] { (l: L) =>
        c1.decode(l) match {
          case _: DecodeResult.Failure => c2.decode(l).map(Right(_))
          case DecodeResult.Value(v)   => DecodeResult.Value(Left(v))
        }
      } {
        case Left(a)  => c1.encode(a)
        case Right(b) => c2.encode(b)
      }
  }

  implicit val longOrActualCodec: Codec[String, Either[Long, Unit], CodecFormat.TextPlain] =
    codecEither(Codec.long, Codec.string.validate(Validator.`enum`(List("actual"))).map(_ => ())(_ => "actual"))

  path[Either[Long, Unit]]
}
adamw commented 3 years ago

At least currently, you can only combine two inputs/outputs using AND, not with an OR. The composition is limited, but so far we've managed without adding this rather complex scenario :)

tg44 commented 3 years ago

After some thinkering and experimenting I think for these situations one of the best method is;

val p1: Seq[EndpointInput.Basic[Option[Long]]] = Seq(
    path[Long].map(i => Option(i))(_.get),
    stringToPath("actual").map(_ => Option.empty[Long])(_ => {}),
  )
  val p2: Seq[EndpointInput[_ <: ReportScope]] = Seq(
    stringToPath("plant").map(_ => Plant())(_ => {}),
    stringToPath("department").and(path[Long].map(i => Department(i))(i => i.id)),
    stringToPath("line").and(path[Long].map(i => Line(i))(i => i.id)),
  )
  val endpoints = for {
    lOrA <- p1
    scope <- p2
  } yield {
    endpoint.get
      .in(lOrA)
      .in(scope)
  }

This will unwrap the endpoints and will generate them one-by-one. The other side can choose if they want to model the routing with knowing that the param is either a Long or the string "actual", or not. Much easier than handle all the Codecs and Schemas, and probably the next programmer who will read the code will have a better time too :D

adamw commented 3 years ago

Haha true :) Though, maybe adding a codecEither to Codec won't be a bad idea. But I agree that having multiple endpoints is easiest, and in the spirit of the library, where Endpoints are the central primitive.

tg44 commented 3 years ago

I think we can close this, for-comp for the win :)