Hi,
Until now I was used to build HttpRoutes from my tapir endpoint definitions. These endpoints when requiring authentication were expecting an auth token (bearer) input.
Nonetheless, I currently have another need that is to use a Http4s library (http4s-pac4j) that performs some authentication mechanism but expects AuthedRoutes instead of HttpRoutes.
I didn’t find support for AuthedRoutes in tapir and I didn’t know where to handle this need, so I admit I had a little trouble figuring out how to do it. But, I finally have a working sample.
// For information :
type AuthedRoutes[T, F[_]] = Kleisli[OptionT[F, *], AuthedRequest[F, T], Response[F]]
type HttpRoutes[F[_]] = Http[OptionT[F, *], F]
type Http[F[_], G[_]] = Kleisli[OptionT[F, *], Request[G], Response[G]]
import org.http4s.AuthedRoutes
case class AuthedIn[In, AuthContext](input: In, context: AuthContext)
val sampleEndpoint =
endpoint.get
.in("sample1")
.out(stringBody)
.description("Sample 1 endpoint")
.name("Sample1")
def sample1Route[F[_]: Async, T]: AuthedRoutes[T, F] =
CustomHttp4sServerInterpreter[F]()
.toAuthedRoutes[T](
withAuthedContext(sampleEndpoint)
.serverLogic(p => Async[F].pure(Right(s"auth data 1: ${p}")))
)
private def withAuthedContext[S, I, E, O, R, AuthContext](
endpoint: Endpoint[S, I, E, O, R]
): Endpoint[S, AuthedIn[I, AuthContext], E, O, R] =
endpoint
.in(extractFromRequest { (req: ServerRequest) =>
req
.attribute(new AttributeKey[AuthContext]("authContext"))
.getOrElse(throw new RuntimeException("auth context not found, should never happen except if attribute key is wrong"))
})
.mapInTo[AuthedIn[I, AuthContext]]
package sttp.tapir.server.http4s
import cats.data.Kleisli
import cats.data.OptionT
import cats.effect.Async
import cats.effect.std.Queue
import cats.implicits._
import fs2.Pipe
import fs2.Stream
import org.http4s._
import org.http4s.headers.`Content-Length`
import org.http4s.server.websocket.WebSocketBuilder2
import org.http4s.websocket.WebSocketFrame
import org.typelevel.ci.CIString
import sttp.capabilities.WebSockets
import sttp.capabilities.fs2.Fs2Streams
import sttp.tapir._
import sttp.tapir.integ.cats.effect.CatsMonadError
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.interceptor.RequestResult
import sttp.tapir.server.interceptor.reject.RejectInterceptor
import sttp.tapir.server.interpreter.BodyListener
import sttp.tapir.server.interpreter.FilterServerEndpoints
import sttp.tapir.server.interpreter.ServerInterpreter
import sttp.tapir.server.model.ServerResponse
trait CustomHttp4sServerInterpreter[F[_]] extends Http4sServerInterpreter[F] {
def toAuthedRoutes[T](se: ServerEndpoint[Fs2Streams[F], F]): AuthedRoutes[T, F] =
toAuthedRoutes[T](List(se))
def toAuthedRoutes[T](serverEndpoints: List[ServerEndpoint[Fs2Streams[F], F]]): AuthedRoutes[T, F] =
toAuthedRoutes(serverEndpoints, None)
private def toAuthedRoutes[T](
serverEndpoints: List[ServerEndpoint[Fs2Streams[F] with WebSockets, F]],
webSocketBuilder: Option[WebSocketBuilder2[F]]
): AuthedRoutes[T, F] = {
implicit val monad: CatsMonadError[F] = new CatsMonadError[F]
implicit val bodyListener: BodyListener[F, Http4sResponseBody[F]] = new Http4sBodyListener[F]
val interpreter = new ServerInterpreter[Fs2Streams[F] with WebSockets, F, Http4sResponseBody[F], Fs2Streams[F]](
FilterServerEndpoints(serverEndpoints),
new Http4sRequestBody[F](http4sServerOptions),
new Http4sToResponseBody[F](http4sServerOptions),
RejectInterceptor.disableWhenSingleEndpoint(http4sServerOptions.interceptors, serverEndpoints),
http4sServerOptions.deleteFile
)
Kleisli { (authedRequest: AuthedRequest[F, T]) =>
val serverRequest =
Http4sServerRequest(
authedRequest.req,
AttributeMap.Empty
.put(new AttributeKey[T]("authContext"), authedRequest.context)
)
OptionT(interpreter(serverRequest).flatMap {
case _: RequestResult.Failure => none.pure[F]
case RequestResult.Response(response) => serverResponseToHttp4s(response, webSocketBuilder).map(_.some)
})
}
}
// below is unchanged but methods were private in Http4sServerInterpreter
private def serverResponseToHttp4s(
response: ServerResponse[Http4sResponseBody[F]],
webSocketBuilder: Option[WebSocketBuilder2[F]]
): F[Response[F]] = {
implicit val monad: CatsMonadError[F] = new CatsMonadError[F]
val statusCode = statusCodeToHttp4sStatus(response.code)
val headers = Headers(response.headers.map(header => Header.Raw(CIString(header.name), header.value)).toList)
response.body match {
case Some(Left(pipeF)) =>
pipeF.flatMap { pipe =>
webSocketBuilder match {
case Some(wsb) => wsb.withHeaders(headers).build(pipe)
case None =>
monad.error(
new Http4sInvalidWebSocketUse(
"Invalid usage of web socket endpoint without WebSocketBuilder2. " +
"Use the toWebSocketRoutes/toWebSocketHttp interpreter methods, " +
"and add the result using BlazeServerBuilder.withHttpWebSocketApp(..)."
)
)
}
}
case Some(Right((entity, contentLength))) =>
val headers2 = contentLength match {
case Some(value) if response.contentLength.isEmpty => headers.put(`Content-Length`(value))
case _ => headers
}
Response(status = statusCode, headers = headers2, body = entity).pure[F]
case None => Response[F](status = statusCode, headers = headers).pure[F]
}
}
private def statusCodeToHttp4sStatus(code: sttp.model.StatusCode): Status =
Status.fromInt(code.code).getOrElse(throw new IllegalArgumentException(s"Invalid status code: $code"))
}
object CustomHttp4sServerInterpreter {
def apply[F[_]]()(implicit _fa: Async[F]): CustomHttp4sServerInterpreter[F] =
new CustomHttp4sServerInterpreter[F] {
implicit override def fa: Async[F] = _fa
}
def apply[F[_]](serverOptions: Http4sServerOptions[F])(implicit _fa: Async[F]): CustomHttp4sServerInterpreter[F] =
new CustomHttp4sServerInterpreter[F] {
implicit override def fa: Async[F] = _fa
override def http4sServerOptions: Http4sServerOptions[F] = serverOptions
}
}
There are probably some improvements to perform to ensure typesafety and correctness (especially on the attribute key name match) but here is my main idea. May be it is possible to have a better integration so the endpoint itself is aware of this authentication context (here it is done just before giving the endpoint to the server interpreter instance).
I don’t know if AuthedRoutes support is welcome in the ServerInterpreter. I don’t know either if it is the best way to meet this need (I’d be glad to hear about a better idea) but if it is feel free to take inspiration of it. At least I hope it may help someone with the same need.
Regards,