HTTP4S: Introducción

La primera solución para implementar servicios o microservicios en Scala es utilizar AKKA HTTP; pero, con las prestaciones de librerías como Cats, ha permitido la creación de librerías como HTTP4S. En la presente entrada, HTTP4s: Introducción, realizaré una breve descripción para la creación de servicios con la librería HTTP4S.

Definición de dependencias

La definición de dependencias y la gestión del ciclo de desarrollo del software se realizará con sbt. La versión de Scala a utilizar es la versión 2.13.4. Las librería utilizadas son las siguientes:

  • http4s_%.- librerías específicas de http4s.
  • munit_%.- librerías específicas para la realización de test.
  • circe_%.- librerías específicas para el tratamiento de componentes JSON.

El snippet del código con la definición de un módulo que utilice la librería HTTP4s es el siguiente:

val munit = "0.7.20"
val munit_cats_effect_version = "0.12.0"
val http4s = "0.21.14"
val circe = "0.13.0"
[…]
lazy val munit = "org.scalameta" %% "munit" % Versions.munit % Test
lazy val munit_cats_effect_2 = "org.typelevel" %% "munit-cats-effect-2" % Versions.munit_cats_effect_version % Test
lazy val http4s_blaze_server = "org.http4s" %% "http4s-blaze-server" % Versions.http4s
lazy val http4s_blaze_client = "org.http4s" %% "http4s-blaze-client" % Versions.http4s
lazy val http4s_dsl = "org.http4s" %% "http4s-dsl" % Versions.http4s
lazy val http4s_circe = "org.http4s" %% "http4s-circe" % Versions.http4s
lazy val circe_generic = "io.circe" %% "circe-generic" % Versions.circe
lazy val circe_literal = "io.circe" %% "circe-literal" % Versions.circe
[…]
lazy val http4s = (project in file("http4s"))
 .settings(
    name := "example-http4s",
    assemblySettings,
    scalacOptions ++= basicScalacOptions,
    testFrameworks += new TestFramework("munit.Framework"),
    libraryDependencies ++=
    http4sDependencies ++ Seq(
      scalaTest,
      munit,
      munit_cats_effect_2
    )
  )
  lazy val http4sDependencies = Seq(
     http4s_blaze_server,
     http4s_blaze_client,
     http4s_circe,
     http4s_dsl,
     circe_generic,
     circe_literal
   )

Definición de Servicios

No me centrará en definir qué es un micro-servicio o servicio ya que ha sido definido y descrito en otras entradas. Desde el punto de vista de la librería HTTP4s, un servicio es aquella definición de un método HTTP el cual, dado un path determinado, recibe una petición HTTP, realiza una funcionalidad determinada y, como resultado, retorna una respuesta HTTP.

HTTP4s está basada en la librería Cats y, al definir operaciones con efecto de lado, emplearemos como tipo parametrizado el componente IO.

El servicio lo definieremos con la función of del componente HttpRoutes, definiremos el tratamiento a realizar en función del método HTTP junto al path del recurso; para cada método, se realizará la creación de una respuesta con el componente Ok.

El servicio es definido y utilizado en un objeto de tipo aplicación de entrada salida IOApp el cual, con la definición de la función run, arranca la aplicación y levanta el servicio. Esta clase es propia de la librería Cats. La función run realiza la creación de un server con el objeto de tipo BlazeServerBuilder al cual se le configura con el servicio, el puerto y el host deseado.

En los siguiente apartados, se definen ejemplos de definición de servicios con HTTP4s.

Ejemplo de servicio básico

Ejemplo básico de tipo «Hello World» donde se define un servicio HTTP con método GET. El código de ejemplo es el siguiente:

import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import scala.concurrent.ExecutionContext.Implicits.global
import org.http4s.server.blaze._
import org.http4s.implicits._
object ServiceEjem1 extends IOApp {

  val helloWorldService = HttpRoutes
     .of[IO] { 
        case GET -> Root / "hello" / name => Ok(s"Hello, $name.")
     }.orNotFound

  def run(args: List[String]): IO[ExitCode] =
     BlazeServerBuilderIO
       .bindHttp(8080, "localhost")
       .withHttpApp(helloWorldService)
       .serve
       .compile
       .drain
       .as(ExitCode.Success)
}

Para probar el servicio anterior desde la línea de comando, se utiliza el siguiente comando curl:

curl http://localhost:8080/hello/Pete

Test

Para realizar el test del ejemplo anterior, usaremos el framework MUnit. La definición del test consiste en la declaración de una clase que herede de munit.FunSuite, en nuestro caso, la clase ServiceEjem1Test; ésta, define los test de prueba con la función test y, la verificación de resultado, se realiza con las funciones assert.

Dado que el servicio creado se implementa como una función Kleisli, no es necesario arrancar un server para realizar pruebas, con lo cual, el proceso de pruebas es un proceso de test de una función, simplificando dichas tareas.

La clase de test del servicio anterior es la siguiente:

import cats.effect._
import org.http4s._
import org.http4s.implicits._
class ServiceEjem1Test extends munit.FunSuite {

  test("Test ServiceEjem1 OK") {
     val service = ServiceEjem1.helloWorldService
     val requestTest = Request[IO](Method.GET, uri"/hello/test")
     val result = service.run(requestTest).unsafeRunSync()
     assertEquals(result.status, Status.Ok)
  }

  test("Test ServiceEjem1 KO") {
     val service = ServiceEjem1.helloWorldService
     val requestTest = Request[IO](Method.GET, uri"/hello")
     val result = service.run(requestTest).unsafeRunSync()
     assertEquals(result.status, Status.NotFound)
  }

}

La prueba consiste en la creación de una referencia al servicio, definido con el objeto service; definición de la petición HTTP, definido en el objeto requestTest; y, para su ejecución, emplearemos la función run() y unsafeRunSync(); el resultado, se recoge en el objeto result el cual es utilizado en las funciones assert.

Composición de servicios

Un servicio está implementado como una función Kleisli y, la composición de funciones Kleisli, se realiza combinado dichas funciones. Para ello, utilizamos la función <+> la cual está definida en la sintaxis de la librería cuyo paquete es cats.syntax.all._.

En el código ejemplo, se define un servicio básico tipo «Hola Mundo» en el objeto helloWorldService y se define un servicio tweeService. El servicio tweetService define dos métodos para dos endpoints diferentes. La composición se define en el objeto services con la función <+>.

Una vez definido la composición del servicio, se crea el enrutado de los servicios en el objeto httpApp; este objeto, será utilizada para levantar la aplicación en la función run.

El código de ejemplo es el siguiente:

object ServiceEjem2 extends IOApp {
   val helloWorldService = HttpRoutes.of[IO] { 
     case GET -> Root / "hello" / name => Ok(s"Hello, $name.")
   }
   case class Tweet(id: Int, message: String)
   […]

   def getTweet(tweetId: Int): IO[Tweet] = IO(Tweet(id = 1, message = "Tweet1"))

   def getPopularTweets(): IO[Seq[Tweet]] = IO(
      List(Tweet(id = 2, message = "Tweet2"), Tweet(id = 3, message = "Tweet3"))
   )

   val tweetService = HttpRoutes.of[IO] {
     case GET -> Root / "tweets" / "popular" =>
        getPopularTweets().flatMap(Ok()) 
     case GET -> Root / "tweets" / IntVar(tweetId) =>  
        getTweet(tweetId).flatMap(Ok())
   }
   val services = tweetService <+> helloWorldService

   val httpApp = Router("/" -> helloWorldService, "/api" -> services).orNotFound

   def run(args: List[String]): IO[ExitCode] =
      BlazeServerBuilder[IO](global)
        .bindHttp(7676, "localhost")
        .withHttpApp(httpApp)
        .serve
        .compile
        .drain
        .as(ExitCode.Success)
}

Test

La definición de los test se realiza con el mismo criterio del primer ejemplo. Para simplificar, me centraré los test del servicio compuesto y en la aplicación.

La estrategia de prueba sigue la misma secuencia, definición del servicio, definición de la petición de pruebas, ejecución y valoración de resultados.

El snippet de prueba es el siguiente:

[…]
test("Test ServiceEjem2.service (compose service) OK") {
   val service = ServiceEjem2.services
   val requestTestHello = Request[IO](Method.GET, uri"/hello/test")
   val resultHello = service.orNotFound(requestTestHello).unsafeRunSync()
   assertEquals(resultHello.status, Status.Ok)
   val requestTestTweet = Request[IO](Method.GET, uri"/tweets/popular")
   val resultTweet = service.orNotFound(requestTestTweet).unsafeRunSync()
   assertEquals(resultTweet.status, Status.Ok)
}
test("Test httpApp OK") {
   val httpApp = ServiceEjem2.httpApp
   val requestTestHello = Request[IO](Method.GET, uri"/hello/test")
   val resultHello = httpApp.run(requestTestHello).unsafeRunSync()
   assertEquals(resultHello.status, Status.Ok)
   val requestTestTweet = Request[IO](Method.GET, uri"/api/tweets/popular")
   val resultTweet = httpApp.run(requestTestTweet).unsafeRunSync()
   assertEquals(resultTweet.status, Status.Ok)
}
[…]

Definición de Middleware

En HTTP4S un middleware es un envoltorio de un servicio para poder manipular la petición enviada, o bien, la respuesta. La estrategia de definición de un middleware puede ser empleando una función, o bien, utilizando un objeto. En los siguientes apartados se muestran unos ejemplos prácticos.

Definición de un middleware mediante una función.

La forma básica para definir un middleware es empleando una función la cual recibe como parámetro un servicio y, como resultado, retorna una función kleisli representado en un HttpRoutes. El middleware ejecutará el servicio el cual, en función del resultado, modificará las cabeceras del objeto de respuesta. El resto de elementos sigue la misma estructura y función que los ejemplos de servicios.

El snippet del código es el siguiente:

object MiddlewareEjem1 extends IOApp {
   def myMiddle(service: HttpRoutes[IO], header: Header): HttpRoutes[IO] = Kleisli { (req: Request[IO]) =>
      service(req).map {
         case Status.Successful(resp) =>
             resp.putHeaders(header)
         case resp =>
             resp
      }
   }

   val service = HttpRoutes.of[IO] { 
     case GET -> Root / "hello" / name => Ok(s"Hello wrapper, $name.")
   }

   val wrappedService = myMiddle(service, Header("SomeKey", "SomeValue"))

   val apiService = HttpRoutes.of[IO] { 
     case GET -> Root / "rest1" => Ok("OK response API")
   }

   val httpRoute = Router("/" -> wrappedService, "/api" -> apiService).orNotFound

   def run(args: List[String]): IO[ExitCode] =
     BlazeServerBuilder[IO](global)
      .bindHttp(7676, "localhost")
      .withHttpApp(httpRoute)
      .serve
      .compile
      .drain
      .as(ExitCode.Success)
}

Test

Para definir las pruebas unitarias del middleware, igual que los servivios, se emplea el framework Munit; en nuestro caso, en el resultado se obtendrá las cabeceras asignadas por el middleware.

El snippet de las pruebas unitarias es el siguiente:

class MiddlewareEjem1Test extends munit.FunSuite {
   […]
   test("Test wrappedService MiddlewareEjem1 OK") {
     val service = MiddlewareEjem1.wrappedService
     val requestTest = Request[IO](Method.GET, uri"/hello/test")
     val result = service.orNotFound(requestTest).unsafeRunSync()
     assertEquals(result.status, Status.Ok)
     assertEquals(result.headers.get(CaseInsensitiveString("SomeKey")).get.value, "SomeValue")
   }
   […]
}

Definición de un middleware con un object.

Otra forma de definir el middleware es utilizando un objeto. El objeto implementará el método apply el cual tendrá la misma funcionalidad que la función del ejemplo anterior; en nuestro caso, se utiliza una función addHeader para modularizar más el código. El servicio que emplee este objeto, wrappedService, simplemente realiza la creación del objeto; posteriormente, se emplea en la creación del Router.

El snipper del código ejemplo es el siguiente:

object MiddlewareEjem2 extends IOApp {
   object MyMiddle {
     def addHeader(resp: Response[IO], header: Header): Response[IO] = resp match {
        case Status.Successful(resp) => resp.putHeaders(header)
        case resp => resp
     }

     def apply(service: HttpRoutes[IO], header: Header) =
        service.map(addHeader(_, header))
   }

   val service = HttpRoutes.of[IO] { 
      case GET -> Root / "middleware" / name =>
        Ok(s"Hello wrapper, $name.")
   }

   val wrappedService = MyMiddle(service, Header("SomeKey", "SomeValue"))

   val apiService = HttpRoutes.of[IO] { 
      case GET -> Root / "rest1" =>
        Ok("OK response API")
   }

   val httpRoute = Router("/" -> wrappedService, "/api" -> apiService).orNotFound
      def run(args: List[String]): IO[ExitCode] =
         BlazeServerBuilder[IO](global)
           .bindHttp(7676, "localhost")
           .withHttpApp(httpRoute)
           .serve
           .compile
           .drain
           .as(ExitCode.Success)
}

Test

La definicón del test del ejemplo anterior sigue la misma estructura que los ejemplos anteriores. El Snippet con el código de test es el siguiente:

[…]
test("Test wrappedService MiddlewareEjem1 OK") {
   val service = MiddlewareEjem2.wrappedService
   val requestTest = Request[IO](Method.GET, uri"/middleware/test")
   val result = service.orNotFound(requestTest).unsafeRunSync()
   assertEquals(result.status, Status.Ok)
   assertEquals(result.headers.get(CaseInsensitiveString("SomeKey")).get.value, "SomeValue")
}
[…]

Composición de un middleware y un servicio.

La composición de middleware sigue el mismo criterio que los servicios porque son funciones kleisli. El ejemplo de una composición de middleware es el siguiente:

object MiddlewareEjem3 extends IOApp {

  object MyMiddle {
    def addHeader(resp: Response[IO], header: Header): Response[IO] = resp match {
      case Status.Successful(resp) => resp.putHeaders(header)
      case resp => resp
    }

    def apply(service: HttpRoutes[IO], header: Header) =
      service.map(addHeader(_, header))
  }

  val service = HttpRoutes.of[IO] { case GET -> Root / "middleware" / name =>
      Ok(s"Hello wrapper, $name.")
  }

  val apiService = HttpRoutes.of[IO] { case GET -> Root / "rest1" =>
      Ok("OK response API")
  }

  val wrappedService = apiService <+> MyMiddle(service, Header("SomeKey", "SomeValue"))

  val httpRoute = Router("/api" -> wrappedService).orNotFound

  def run(args: List[String]): IO[ExitCode] =
     BlazeServerBuilder[IO](global)
      .bindHttp(7676, "localhost")
      .withHttpApp(httpRoute)
      .serve
      .compile
      .drain
      .as(ExitCode.Success)
}

La definición de los test de este ejemplo es análogo a los anteriores.

Para el lector interesado en el código ejemplo puede acceder a través del siguiente enlace.

En la siguiente entrada, HTTP4S: manejo de JSON, me centraré en cómo se manejan componentes JSON con HTTP4S.