Skip to content

Commit

Permalink
#46: authentication composition functions
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw committed Mar 15, 2019
1 parent fc571a4 commit 5525757
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package tapir.typelevel

/**
* Tuples with the first element replaced:
* IK = (I, A, B, C, ...)
* JK = (J, A, B, B, ...)
*/
trait ReplaceFirstInTuple[I, J, IK, JK]

object ReplaceFirstInTuple {
[1..21#implicit def replaceFirstInTuple1[I, J, [#A1#]]: ReplaceFirstInTuple[I, J, (I, [#A1#]), (J, [#A1#])] = null#
]
}
24 changes: 24 additions & 0 deletions core/src/main/scala/tapir/typelevel/ReplaceFirstInFn.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package tapir.typelevel

/**
* Replace the first parameter of a function from I to J.
* FN_IK[R] = (IK as args) => R
* FN_JK[R] = (JK as args) => R
* IK = (I, A, B, C, ...)
* JK = (J, A, B, B, ...)
*/
trait ReplaceFirstInFn[I, FN_IK[_], J, FN_JK[_]] {
def paramsAsArgsIk: ParamsAsArgs.Aux[_, FN_IK]
def paramsAsArgsJk: ParamsAsArgs.Aux[_, FN_JK]
}

object ReplaceFirstInFn {
implicit def replaceFirst[FN_IK[_], I, IK, J, JK, FN_JK[_]](implicit
p1: ParamsAsArgs.Aux[IK, FN_IK],
r: ReplaceFirstInTuple[I, J, IK, JK],
p2: ParamsAsArgs.Aux[JK, FN_JK]): ReplaceFirstInFn[I, FN_IK, J, FN_JK] =
new ReplaceFirstInFn[I, FN_IK, J, FN_JK] {
override def paramsAsArgsIk: ParamsAsArgs.Aux[_, FN_IK] = p1
override def paramsAsArgsJk: ParamsAsArgs.Aux[_, FN_JK] = p2
}
}
4 changes: 4 additions & 0 deletions doc/endpoint/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ cookie or a query parameter

Multiple authentication inputs indicate that all of the given authentication values should be provided. Specifying
alternative authentication methods (where only one value out of many needs to be provided) is currently not supported.

When interpreting a route as a server, it is useful to define authentication input first, to be able to share the
authentication logic among multiple endpoints easily. See [common server options](../server/common.html) for more
details.
2 changes: 1 addition & 1 deletion doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ which is a slight extension of the above.
* [Endpoints: authentication](endpoint/auth.md)
* [Servers: akka-http interpreter](server/akkahttp.md)
* [Servers: http4s interpreter](server/http4s.md)
* [Servers: common configuration](server/common.md)
* [Servers: common](server/common.md)
* [Clients: sttp interpreter](sttp.md)
* [Documentation: openapi interpreter](openapi.md)
* [Create your own tapir](mytapir.md)
Expand Down
33 changes: 32 additions & 1 deletion doc/server/common.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Common server configuration
# Common server options

## Status codes

Expand Down Expand Up @@ -35,3 +35,34 @@ value, or a specific response.

Only the first failure is passed to the `DecodeFailureHandler`. Inputs are decoded in the following order: method,
path, query, header, body.

## Extracting common route logic

Quite often, especially for [authentication](../endpoint/auth.md), some part of the route logic is shared among multiple
endpoints. However, these functions don't compose in a straightforward way, as the results can both contain errors
(represented as `Either`s), and are wrapped in a container. Suppose you have the following method:

```scala
type AuthToken = String

def authFn(token: AuthToken): Future[Either[ErrorInfo, User]]
def logicFn(user: User, data: String, limit: Int): Future[Either[ErrorInfo, Result]]
```

which you'd like to apply to an endpoint with type:

```scala
val myEndpoint: Endpoint[(AuthToken, String, Int), ErrorInfo, Result, Nothing] = ...
```

To avoid composing these functions by hand, tapir defines a helper extension method, `composeRight`, which allows
to compose these two functions. If the first function returns an error, that error is propagated to the final result;
otherwise, the result is passed as input to the second function.

This extension method is defined in the same traits as the route interpreters, both for `Future` (in the akka-http
interpreter) and for an arbitrary monad (in the http4s interpreter) so importing the package is sufficient to use it:

```scala
import tapir.server.akkahttp._
val r: Route = myEndpoint.toRoute((authFn _).composeRight(logicFn _))
```
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ package tapir.server.akkahttp

import akka.http.scaladsl.server.{Directive, Route}
import tapir.server.{ServerDefaults, StatusMapper}
import tapir.typelevel.{ParamsAsArgs, ParamsToTuple}
import tapir.typelevel.{ParamsAsArgs, ParamsToTuple, ReplaceFirstInFn}
import tapir.Endpoint
import tapir.internal.{ParamsToSeq, SeqToParams}

import scala.concurrent.Future
import scala.concurrent.{ExecutionContext, Future}

trait AkkaHttpServer {
implicit class RichAkkaHttpEndpoint[I, E, O](e: Endpoint[I, E, O, AkkaStream]) {
Expand All @@ -18,4 +19,22 @@ trait AkkaHttpServer {
errorStatusMapper: StatusMapper[E] = ServerDefaults.errorStatusMapper): Route =
new EndpointToAkkaServer(serverOptions).toRoute(e)(logic, statusMapper, errorStatusMapper)
}

implicit class RichToFutureFunction[T, E, U](f: T => Future[Either[E, U]])(implicit ec: ExecutionContext) {
def composeRight[O, FN_U[_], FN_T[_]](g: FN_U[Future[Either[E, O]]])(
implicit
r: ReplaceFirstInFn[U, FN_U, T, FN_T]): FN_T[Future[Either[E, O]]] = {

r.paramsAsArgsJk.toFn { paramsWithT =>
val paramsWithTSeq = ParamsToSeq(paramsWithT)
val t = paramsWithTSeq.head.asInstanceOf[T]
f(t).flatMap {
case Left(e) => Future.successful(Left(e))
case Right(u) =>
val paramsWithU = SeqToParams(u +: paramsWithTSeq.tail)
r.paramsAsArgsIk.asInstanceOf[ParamsAsArgs.Aux[Any, FN_U]].applyFn(g, paramsWithU)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package tapir.server.akkahttp

import org.scalatest.concurrent.ScalaFutures
import org.scalatest.{FunSuite, Matchers}

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

class RichToFutureFunctionTest extends FunSuite with Matchers with ScalaFutures {

case class Error(r: String)
case class User(u: String)
case class Result(r: String)

test("should compose functions when both succeed") {
// given
def f1(p: String): Future[Either[Error, User]] = Future {
Right(User(p))
}
def f2(u: User, i: Int, s: String): Future[Either[Error, Result]] = Future {
Right(Result(List(u.toString, i.toString, s).mkString(",")))
}

// when
val result = (f1 _).composeRight(f2 _).apply("john", 10, "x").futureValue

// then
result shouldBe Right(Result("User(john),10,x"))
}

test("should return error if first fails") {
// given
def f1(p: String): Future[Either[Error, User]] = Future {
Left(Error("e1"))
}
def f2(u: User, i: Int, s: String): Future[Either[Error, Result]] = Future {
Right(Result(List(u.toString, i.toString, s).mkString(",")))
}

// when
val result = (f1 _).composeRight(f2 _).apply("john", 10, "x").futureValue

// then
result shouldBe Left(Error("e1"))
}

test("should return error if second fails") {
// given
def f1(p: String): Future[Either[Error, User]] = Future {
Right(User(p))
}
def f2(u: User, i: Int, s: String): Future[Either[Error, Result]] = Future {
Left(Error("e2"))
}

// when
val result = (f1 _).composeRight(f2 _).apply("john", 10, "x").futureValue

// then
result shouldBe Left(Error("e2"))
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package tapir.server.http4s

import cats.Monad
import cats.implicits._
import cats.effect.{ContextShift, Sync}
import org.http4s.{EntityBody, HttpRoutes}
import tapir.Endpoint
import tapir.internal.{ParamsToSeq, SeqToParams}
import tapir.server.{ServerDefaults, StatusMapper}
import tapir.typelevel.ParamsAsArgs
import tapir.typelevel.{ParamsAsArgs, ReplaceFirstInFn}

trait Http4sServer {
implicit class RichHttp4sHttpEndpoint[I, E, O, F[_]](e: Endpoint[I, E, O, EntityBody[F]]) {
Expand All @@ -18,4 +21,21 @@ trait Http4sServer {
new EndpointToHttp4sServer(serverOptions).toRoutes(e)(logic, statusMapper, errorStatusMapper)
}
}

implicit class RichToMonadFunction[T, E, U, F[_]: Monad](f: T => F[Either[E, U]]) {
def composeRight[O, FN_U[_], FN_T[_]](g: FN_U[F[Either[E, O]]])(implicit
r: ReplaceFirstInFn[U, FN_U, T, FN_T]): FN_T[F[Either[E, O]]] = {

r.paramsAsArgsJk.toFn { paramsWithT =>
val paramsWithTSeq = ParamsToSeq(paramsWithT)
val t = paramsWithTSeq.head.asInstanceOf[T]
f(t).flatMap {
case Left(e) => implicitly[Monad[F]].point(Left(e))
case Right(u) =>
val paramsWithU = SeqToParams(u +: paramsWithTSeq.tail)
r.paramsAsArgsIk.asInstanceOf[ParamsAsArgs.Aux[Any, FN_U]].applyFn(g, paramsWithU)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package tapir.server.http4s

import cats.effect.IO
import org.scalatest.{FunSuite, Matchers}

class RichToMonadFunctionTest extends FunSuite with Matchers {

case class Error(r: String)
case class User(u: String)
case class Result(r: String)

test("should compose functions when both succeed") {
// given
def f1(p: String): IO[Either[Error, User]] = IO {
Right(User(p))
}
def f2(u: User, i: Int, s: String): IO[Either[Error, Result]] = IO {
Right(Result(List(u.toString, i.toString, s).mkString(",")))
}

// when
val result = (f1 _).composeRight(f2 _).apply("john", 10, "x").unsafeRunSync()

// then
result shouldBe Right(Result("User(john),10,x"))
}

test("should return error if first fails") {
// given
def f1(p: String): IO[Either[Error, User]] = IO {
Left(Error("e1"))
}
def f2(u: User, i: Int, s: String): IO[Either[Error, Result]] = IO {
Right(Result(List(u.toString, i.toString, s).mkString(",")))
}

// when
val result = (f1 _).composeRight(f2 _).apply("john", 10, "x").unsafeRunSync()

// then
result shouldBe Left(Error("e1"))
}

test("should return error if second fails") {
// given
def f1(p: String): IO[Either[Error, User]] = IO {
Right(User(p))
}
def f2(u: User, i: Int, s: String): IO[Either[Error, Result]] = IO {
Left(Error("e2"))
}

// when
val result = (f1 _).composeRight(f2 _).apply("john", 10, "x").unsafeRunSync()

// then
result shouldBe Left(Error("e2"))
}
}

0 comments on commit 5525757

Please sign in to comment.