14 July

Определение серверной логики для конечной точки: три подхода

OTUS. Онлайн-образование corporate blogScalaFunctional Programming
Translation
Original author: Adam Warski

Перевод статьи подготовлен в преддверии старта курса «Scala-разработчик»





Теа, Ральф и Джесси используют tapir для описания своих конечных точек HTTP. Им нравится его удобный для программиста API, способ описания конечных точек, возможность использовать одно и то же описание для генерации сервера, клиента или документации, а также его возможности абстракции.


Однако, когда дело доходит до определения серверной логики для конечных точек (то есть, что должно произойти, когда их конечные точки интерпретируются как сервер и подвергаются воздействию внешнего мира), приоритеты у них разнятся. К нашей великой удаче, все три подхода теперь покрыты tapir!


Давайте рассмотрим их один за другим.



Ральф, Джесси и Теа со своим Тапиром. София Варска


Теа


Теа любит, чтобы все было просто. У нее есть один модуль, в котором определены все конечные точки ее приложения. Каждая конечная точка является специализацией baseEndpoint, которая включает в себя стандартные части: префикс пути и тип ошибки. Конечная точка содержит описание своих входов и выходов, требуются ли ей параметры запроса, заголовки, каков путь для доступа к ней и как должны выглядеть тела запросов и ответов.


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)

Также мы видим некоторые метаданные, такие как примерные значения параметров или удобочитаемые описания конечных точек. Однако определения полностью отделены от любой бизнес-логики:


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")

Только позже описания конечной точки соединяются с кодом, который должен быть запущен, когда вызывается конечная точка. Это также тот момент, когда пора переходить к определенному эффектвраперу. Теа использует Future для управления параллелизмом и объединения операций ввода-вывода, поэтому все ее методы бизнес-логики возвращают 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)
    }
}

Более того, поскольку ошибки, которые использует Теа, являются подклассом Exception, а методы бизнес-логики представляют ошибки как неудачные фьючерсы, она может использовать метод для объединения конечной точки с ее серверной логикой, которая восстанавливает ошибки от неудачных эффектов.


Наконец, конечные точки сервера могут быть затем преобразованы, например, к akka-http Route, и представлены миру с помощью akka-http сервера.


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

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

Ральф


Ральфу ближе немного другой подход. Как вы могли заметить, все конечные точки нуждаются в аутентификации, и он хотел бы использовать по максимуму этот факт. В случае Ральфа аутентификация основана на токенах предъявителя, отправляемых в заголовке Authorization. Как и у Теи, у Ральфа есть один модуль, в котором определены все конечные точки. Однако, чтобы максимально упростить процесс определения новой аутентифицированной конечной точки, Ральф определил базовую конечную точку, в которую встроена логика аутентификации.


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]] = ???

Модель и серверная логика, используемые в приложении Ральфа


Эта базовая конечная точка уже содержит часть бизнес-логики. Эта частичная логика использует все входные данные, определенные до сих пор (отсюда и название метода для его предоставления: serverLogicForCurrent), и выдает промежуточный результат (аутентифицированный пользователь):


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)

Как только мы предоставим частичную логику, мы получим значение типа PartialServerEndpoint. Такая конечная точка может быть дополнительно расширена путем добавления большего количества входов/выходов или предоставления большего количества логических частей (каждый раз потребляя все входы, определенные до сих пор).


Однако ошибки зафиксированы! Это потому, что частичная логика, которую мы предоставили изначально, также может дать сбой — и для этого она должна вызвать либо ошибку, либо промежуточный результат.


Для каждой специализации базовой конечной точки после того, как мы добавили все входы/выходы, мы можем завершить конечную точку, предоставив серверную логику, которая потребляет кортеж: промежуточные результаты (здесь: User) и остальные входные данные.


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)

Вы, вероятно, заметили, что Ральф представляет ошибки как простые case классы. Кроме того, Ральф пользуется эффектом типа данных IO для управления эффектами в своем приложении. Следовательно, его логические функции возвращают значения типа IO[Either[Error, _]].


После заполнения частичной серверной конечной точки оставшейся логикой мы получаем ServerEndpoint (так же, как и в случае с Теа). Теперь мы можем раскрыть его, используя интерпретатор, который поддерживает IO значения, такие как 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

Джесси


Джесси использует конечные точки tapir не только для предоставления сервера и создания документации по конечным точкам, но также для описания и выполнения клиентских вызовов. Вот почему ее описания конечных точек живут в совершенно отдельном модуле, который зависит только от tapir-core и tapir-json-circe.


Просто нет никакого способа определить какую-либо часть бизнес-логики вместе с конечными точками. Однако ее требования ничем не отличаются от требований Ральфа. Все ее конечные точки также требуют аутентификации. Она также имеет базовую конечную точку, которая описывает как конечные точки снабжены средствами защиты (здесь, используя 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")
}

Однако она не может использовать тот же метод, который описан выше, чтобы определить базовую конечную точку в сочетании с логикой аутентификации. Вот почему она использует другой подход.


Предоставляя серверную логику для конечных точек (которые полностью описаны в отдельном модуле), Джесси использует serverLogicPart, чтобы сначала предоставить базовые части серверной логики (логика аутентификации), а затем остальные.


serverLogicPart потребляет только часть ввода, определенную на данный момент (в отличии от предыдущих, где потребляется весь определенный ввод). Однако возвращаемое значение типа ServerEndpointInParts нельзя использовать для расширения конечной точки большим количеством входов или выходов.


Мы можем предоставить только больше логических частей, производящих либо ошибку, либо промежуточное значение, либо дополнить логику функцией, которая (как в случае Ральфа) принимает кортеж, содержащий частичные, промежуточные результаты и неиспользованный ввод.


Таким образом, логика аутентификации по-прежнему централизована и может использоваться повторно, и ее можно легко составить с помощью функций, которые требуют для работы аутентифицированного 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
}

Как в случаях Теи и Ральфа, окончательное значение имеет тип ServerEndpoint. Как видите, Джесси использует Future для представления сайдэффектов и серверного интерпретатора akka-http.


Резюме


Для предоставлении серверной логики конечной точке у нас есть три подхода:


  • При описании законченной конечной точки предоставить всю логику сервера сразу.
  • При описании частичной конечной точки обеспечить частичную логику сервера для всех входов, определенных до сих пор. Результирующая частичная конечная точка затем может быть дополнительно расширена с помощью входов/выходов (но не выходов с ошибками), с возможностью последующего предоставления функций частей логики.
  • При описании законченной конечной точки обеспечивать частичную серверную логику, каждый раз занимая часть ввода. Результирующая конечная точка не может быть дополнительно расширена, могут быть предоставлены только последующие частичные логические функции.

Можно сказать, что это на два способа предоставления серверной логики больше, чем нужно. Однако у библиотек есть свои законы. Это всегда вопрос предоставления пользователям гибкости там, где это действительно полезно, при одновременном ограничении других частей во избежание ненужного выбора.


В данном случае хорошо иметь выбор. Варианты использования, которые мы хотели бы охватить, просто все разные, и не существует универсального решения.


Будьте как Теа, Ральф или Джесси, и используйте tapir, чтобы раскрыть свои конечные точки HTTP!




Узнать подробнее о курсе «Scala-разработчик»



Tags:scalaTapirAkkaHttpFunctional Programmingotus
Hubs: OTUS. Онлайн-образование corporate blog Scala Functional Programming
+4
558 8
Comments 1