Defining server logic for an endpoint: three approaches

Translation of the article prepared in advance of the start of the course Scala Developer


Tea, Ralph and Jesse use tapir to describe their HTTP endpoints. They like its programmer-friendly API, a way to describe endpoints, the ability to use the same description to generate server, customer or documentationas well as its abstraction capabilities.

However, when it comes to defining server logic for endpoints (that is, what should happen when their endpoints are interpreted as a server and exposed to the outside world), they have different priorities. To our great luck, all three approaches are now covered with tapir!

Let’s look at them one by one.


Ralph, Jesse and Thea with his Tapir. Sofia Varska

Tea

Tea loves to keep things simple. She has one module in which all the endpoints of her application are defined. Each endpoint is a specialization. baseEndpoint, which includes standard parts: path prefix and type of error. The endpoint contains a description of its inputs and outputs, whether it requires request parameters, headers, what is the path to access it, and what should the request and response bodies look like.

import java.util.UUID

import sttp.tapir._
import sttp.tapir.json.circe._
import io.circe.generic.auto._
import sttp.model.StatusCode

case class AuthToken(token: String)
case class Error(msg: String, statusCode: StatusCode) extends Exception

val error: EndpointOutput[Error] = stringBody.and(statusCode).mapTo(Error)
val baseEndpoint: Endpoint[AuthToken, Error, Unit, Nothing] = endpoint
  .in(header[String]("X-Authorization")
      .description("Only authorized users can add pets")
      .example("1234")
      .mapTo(AuthToken))
  .in("api" / "1.0")
  .errorOut(error)

We also see some metadata, such as approximate parameter values ​​or readable descriptions of endpoints. However, definitions are completely separate from any business logic:

case class User(id: UUID, name: String)
case class Pet(id: UUID, kind: String, name: String)

val getPet: Endpoint[(AuthToken, UUID), Error, Pet, Nothing] =
  baseEndpoint
    .get
    .in(query[UUID]("id").description("The id of the pet to find"))
    .out(jsonBody[Pet])
    .description("Finds a pet by id")

val addPet: Endpoint[(AuthToken, Pet), Error, Unit, Nothing] =
  baseEndpoint
    .post
    .in(jsonBody[Pet])
    .description("Adds a pet")

Only later do endpoint descriptions connect to the code that should be run when the endpoint is called. This is also the moment when it is time to move on to a specific effraper. Tea uses Future to manage concurrency and combine I / O, so all of its business logic methods return Future.

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

// должен завершиться с Error, если пользователь не найден
def authorize(authToken: AuthToken): Future[User] = ???
def findPetForUser(user: User, id: UUID): Future[Option[Pet]] = ???
def addPetToUser(user: User, pet: Pet): Future[Unit] = ???

val getPetWithLogic = getPet.serverLogicRecoverErrors {
  case (authToken, id) =>
    authorize(authToken).flatMap { user =>
      findPetForUser(user, id).flatMap {
        case Some(pet) => Future.successful(pet)
        case None      => Future.failed(Error("Not found", StatusCode.NotFound))
      }
    }
}

val addPetWithLogic = addPet.serverLogicRecoverErrors {
  case (authToken, pet) =>
    authorize(authToken).flatMap { user =>
      addPetToUser(user, pet)
    }
}

Moreover, since the errors that Tea uses are a subclass of Exceptionand while business logic methods present errors as unsuccessful futures, she can use the methods to combine the endpoint with her server logic, which recovers errors from failed effects.

Finally, server endpoints can then be converted, for example, to akka-http Route, and reveal to the world using akka-http server.

// конечные точки теперь интерпретируются как 
akka.http.scaladsl.Route
import akka.http.scaladsl.server.Route
import sttp.tapir.server.akkahttp._
val routes: Route = List(getPetWithLogic, addPetWithLogic).toRoute

// раскрываем маршруты, используя
akka-http

Ralph

Ralph is closer to a slightly different approach. As you can see, all endpoints need authentication, and he would like to make the most of this fact. In the case of Ralph, authentication is based on bearer tokens sent in the Authorization header.
Like Thei, Ralph has one module in which all endpoints are defined. However, in order to simplify the process of determining a new authenticated endpoint as much as possible, Ralph has identified the base endpoint into which the authentication logic is built.

import java.util.UUID

import cats.effect.{ContextShift, IO, Timer}
import sttp.tapir._
import sttp.model.StatusCode

case class AuthToken(token: String)
case class Error(msg: String, statusCode: StatusCode)
case class User(id: UUID, name: String)
case class Pet(id: UUID, kind: String, name: String)

def authorize(authToken: AuthToken): IO[Either[Error, User]] = ???
def findPetForUser(user: User, id: UUID): IO[Either[Error, Option[Pet]]] = ???
def addPetToUser(user: User, pet: Pet): IO[Either[Error, Unit]] = ???

The model and server logic used in the Ralph application.

This base endpoint already contains part of the business logic. This partial logic uses all the input data defined so far (hence the name of the method for providing it: serverLogicForCurrent), and produces an intermediate result (authenticated user):

import sttp.tapir.json.circe._
import io.circe.generic.auto._
import sttp.tapir.server.PartialServerEndpoint

val error: EndpointOutput[Error] = stringBody.and(statusCode).mapTo(Error)
val secureEndpoint: PartialServerEndpoint[User, Unit, Error, Unit, Nothing, IO] = 
  endpoint
    .in(auth.bearer[String].mapTo(AuthToken))
    .in("api" / "1.0")
    .errorOut(error)
    .serverLogicForCurrent(authorize)

Once we provide partial logic, we get a value of type PartialServerEndpoint. Such an endpoint can be further expanded by adding more inputs / outputs or by providing more logical parts (each time consuming all the inputs defined so far).

However, errors are fixed! This is because the partial logic that we provided initially may also fail – and for this it should cause either an error or an intermediate result.

For each specialization of the base endpoint, after we have added all the inputs / outputs, we can complete the endpoint by providing server logic that consumes a tuple: intermediate results (here: User) and other input data.

val getPetWithLogic =
  secureEndpoint
    .get
    .in(query[UUID]("id").description("The id of the pet to find"))
    .out(jsonBody[Pet])
    .description("Finds a pet by id")
    .serverLogic {
      case (user, id) =>
        findPetForUser(user, id).map {
          case Right(Some(pet)) => Right(pet)
          case Right(None)      => Left(Error("Not found", StatusCode.NotFound))
          case Left(error)      => Left(error)
        }
    }

val addPetWithLogic =
  secureEndpoint
    .post
    .in(jsonBody[Pet])
    .description("Adds a pet")
    .serverLogic((addPetToUser _).tupled)

You probably noticed that Ralph presents mistakes as simple case classes. Ralph also uses the data type effect. IO to control effects in your application. Therefore, its logical functions return values ​​of type IO[Either[Error, _]].

After filling the partial server endpoint with the remaining logic, we get Serverendpoint (same as with Thea). Now we can open it using an interpreter that supports IO values ​​like http4s.

// the endpoints are interpreted as an http4s.HttpRoutes[IO]
import sttp.tapir.server.http4s._
import org.http4s.HttpRoutes
implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global
implicit val contextShift: ContextShift[IO] = IO.contextShift(ec)
implicit val timer: Timer[IO] = IO.timer(ec)
val routes: HttpRoutes[IO] = List(getPetWithLogic, addPetWithLogic).toRoutes

// expose routes using http4s

Jessie

Jesse uses tapir endpoints not only to provide the server and create documentation for endpoints, but also to describe and make client calls. That’s why her endpoint descriptions live in a completely separate module, which depends only on tapir-core and tapir-json-circe.

There is simply no way to define any part of the business logic along with the endpoints. However, its requirements are no different from those of Ralph. All of its endpoints also require authentication. It also has a base endpoint that describes how endpoints are equipped with security features (here, using cookies):

import java.util.UUID
import io.circe.generic.auto._
import sttp.model.StatusCode
import sttp.tapir._
import sttp.tapir.json.circe._

object Endpoints {
  case class AuthToken(token: String)
  case class Error(msg: String, statusCode: StatusCode) extends Exception
  case class User(id: UUID, name: String)
  case class Pet(id: UUID, kind: String, name: String)

  val error: EndpointOutput[Error] = stringBody.and(statusCode).mapTo(Error)
  val baseEndpoint: Endpoint[AuthToken, Error, Unit, Nothing] = 
    endpoint
      .in(auth.apiKey(cookie[String]("Token")).mapTo(AuthToken))
      .in("api" / "1.0")
      .errorOut(error)

  val getPet: Endpoint[(AuthToken, UUID), Error, Pet, Nothing] =
    baseEndpoint
      .get
      .in(query[UUID]("id").description("The id of the pet to find"))
      .out(jsonBody[Pet])
      .description("Finds a pet by id")

  val addPet: Endpoint[(AuthToken, Pet), Error, Unit, Nothing] =
    baseEndpoint
      .post
      .in(jsonBody[Pet])
      .description("Adds a pet")
}

However, she cannot use the same method described above to determine the base endpoint in combination with the authentication logic. That’s why she takes a different approach.

By providing server-side logic for endpoints (which are fully described in a separate module), Jesse uses serverLogicPartto first provide the basic parts of the server logic (authentication logic), and then the rest.

serverLogicPart consumes only part of the input that is currently defined (unlike the previous ones, where all the specific input is consumed). However, the return value of type ServerEndpointInParts cannot be used to expand an endpoint with more inputs or outputs.

We can provide only more logical parts – producing either an error or an intermediate value – or supplement the logic with a function that (as in the case of Ralph) accepts a tuple containing partial, intermediate results and unused input.

Thus, the authentication logic is still centralized and reusable, and can be easily compiled using functions that require authenticated to work User:

object Server {
  import Endpoints._
  import scala.concurrent.Future
  import scala.concurrent.ExecutionContext.Implicits.global

  // should fail with Error if user not found
  def authorize(authToken: AuthToken): Future[Either[Error, User]] = ???
  def findPetForUser(user: User, id: UUID): Future[Either[Error, Option[Pet]]] = ???
  def addPetToUser(user: User, pet: Pet): Future[Either[Error, Unit]] = ???

  val getPetWithLogic = getPet.serverLogicPart(authorize).andThen {
    case (user, id) =>
      findPetForUser(user, id).map {
        case Right(Some(pet)) => Right(pet)
        case Right(None)      => Left(Error("Not found", StatusCode.NotFound))
        case Left(error)      => Left(error)
      }
  }

  val addPetWithLogic = addPet.serverLogicPart(authorize)
    .andThen((addPetToUser _).tupled)

  // the endpoints are now interpreted as an akka.http.scaladsl.Route
  import akka.http.scaladsl.server.Route
  import sttp.tapir.server.akkahttp._
  val routes: Route = List(getPetWithLogic, addPetWithLogic).toRoute

  // раскрываем маршруты, используя akka-http
}

As with Thei and Ralph, the final value is of type Serverendpoint. As you can see, Jesse uses Future for presentation of side effects and server interpreter akka-http.

Summary

To provide the server logic to the endpoint, we have three approaches:

  • When describing the finished end point, provide all server logic right away.
  • In the description partial endpointprovide partial server logic for all inputsidentified so far. The resulting partial endpoint can then be further expanded with the help of inputs / outputs (but not outputs with errors), with the possibility of further providing functions of the parts of the logic.
  • In the description finished endpointto provide partial server logiceach time taking input part. The resulting endpoint cannot be extended further; only subsequent partial logical functions can be provided.

We can say that these are two ways to provide server logic more than necessary. However, libraries have their own laws. It is always a matter of giving users flexibility where it is truly useful, while limiting other parts to avoid unnecessary choices.

In this case, it’s good to have a choice. The use cases that we would like to cover are simply all different – and there is no universal solution.

Be like Tea, Ralph or Jesse, and use tapir to reveal your HTTP endpoints!


Learn more about the course Scala Developer


Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *