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.

MUnit IV: Filtros

Finalizamos la serie de MUnit con esta última entrada, MUnit IV: Filtros, en donde describiremos cómo filtrar aquellos test que queramos ejecutar y cuál no. Para el lector interesado en la serie de entradas puede acceder a través de los siguiente enlaces:

  1. MUnit I: declaración de test.
  2. MUnit II: aserciones.
  3. MUnit III: accesorios.

Los filtrados a los test los podemos definir de la siguiente forma:

  • Ejecutar solo un test de una clase.- Para ejecutar solo un test definido en una clase se debe de utilizar la función only en el texto de la definición de un test. Un ejemplo de uso es el siguiente:
class FilteringEjem1 extends munit.FunSuite {
  test("test-issue-455") {
    assert(1 == 1)
  }
  test("consoletest-issue-456".only) {
    assert(1 == 1)
  }
  test("test issue 457") {
    assert(1 == 1)
  }
}
  • Ignorar un test.- Si se desea ignorar la ejecución de un test en una clase se debe de utilizar la función ignore en el texto de la definición de un test. Un ejemplo de uso es el siguiente:
class FilteringEjem2 extends munit.FunSuite {
  test("test-issue-455") {
    assert(1 == 1)
  }
  test("consoletest-issue-456".ignore) {
    assert(1 == 1)
  }
  test("test issue 457") {
    assert(1 == 1)
  }
}
  • Ejecución de test en función de condiciones dinámicas.- Si se desea la ejecución de test en función de valores externos, como por ejemplo: la ejecución de un test si es en una máquina Linux o Mac, se emplea la función assume la cual, si no cumple una función, lanza una excepción. Un ejemplo de uso de esta función es la siguiente clase:
class FilteringEjem3 extends munit.FunSuite {
  import scala.util.Properties
  test("paths linux") {
    try {
      print(f"Properties.isLinux=${Properties.isLinux}")
      assume(Properties.isLinux, "this test runs only on Linux")
      assume(Properties.versionNumberString.startsWith("2.13"), "this test runs only on Scala 2.13")
      assert(1 == 1)
    } catch {
      case exception: Exception => fail("error: " + exception.getMessage)
    }
  }
  test("paths mac") {
    try {
      println(f"Properties.isMac=${Properties.isMac}")
      assume(Properties.isMac, "this test runs only on Mac")
      assume(Properties.versionNumberString.startsWith("2.13"), "this test runs only on Scala 2.13")
      assert(1 == 1)
    } catch {
      case exception: Exception => fail("error: " + exception.getMessage)
    }
  }
}

Los test del ejemplo anterior fallarán o no en función de si la ejecución se realiza en una máquina Linux o Mac.

  • Ignorar una clase de test por anotación.- Un camino rápido para ignorar la ejecución de una clase de test es la utilización de la anotación munit.IgnoreSuite. Un ejemplo de uso es el siguiente:
@munit.IgnoreSuite
class FilteringIgnoreClassEjem4 extends munit.FunSuite {
  test("Ignore test 1") {
    assert(1 == 1)
  }
  test("Ignore test 2") {
    assert(1 == 1)
  }
}

La utilización de MUnit es muy intuitiva para todos aquellos que hayan trabajado con JUnit y, para quien no, no considero un aprendizaje duro y costoso por la sencillez de la definición y uso. La posibilidad de definir pruebas de tareas asíncronas es un aspecto importante para considerar su uso en ciertos casos. Por el contrario, es posible que el uso de los accesorios (fixture) sea algo más laborioso al tener que definir de forma explícita el objeto en el test.

MUnit III: accesorios

En la presente entrada, MUnit III: accesorios, definiré qué es un accesorio (fixtures) en un test con MUnit y mostraré ejemplo de uso. Para el lector interesado, el resto de entradas de la serie son las siguientes:

+ MUnit I: declaración de test.
+ MUnit II: aserciones.

Los accesorios en las pruebas son los entornos de ejecución en donde se ejecutan los test; en estos entornos, permiten la adquisición de recursos para ejecutar la prueba y, una vez finalizada, liberar todos aquellos recursos utilizados. Podemos tener dos tipos de accesorios: funcionales, son aquellos en donde se adquiere un recurso determinado ; y, los reusables, son aquellos en donde se puede definir una funcionalidad antes y después del test.

Las clases de test en donde se definen accesorios son clases que heredan de la clase munit.FunSuite y, en función de si es un accesorio funcional o reusable, se define la referencia a  munitFixture.

Los ejemplos de accesorios son aquellos en los que se definen test en donde es necesario utilizar un recurso, ya sea un fichero o bien una base de datos con los cuáles operar.

Ejemplo de accesorio funcional

Supongamos que necesitamos crear un fichero temporal para un test, necesitamos crear un fichero antes del test, ejecutar el test y, para finalizar, liberar dicho fichero. Un ejemplo de test es el siguiente:

import java.nio.file.{Files, Path}
class FunctionalTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  val files = FunFixture[Path](
    setup = { test =>
      Files.createTempFile("tmp", test.name)
    },
    teardown = { file =>
      Files.deleteIfExists(file)
      ()
    }
  )
  files.test("Functional Test Local Fixtures") { file =>
     assert(Files.isRegularFile(file), s"Files.isRegularFile($file)")
  }
  [...]
}

En el ejemplo anterior, se define un objeto FunFixture cuyo tipo parametrizado es un objeto Path del paquete java.nio.file; la función setup, es aquella función que se ejecuta antes de la ejecución del test; y, la función teardown, es aquella función que se ejecuta después del test; en nuestro caso, se realiza la creación de un fichero temporal y su liberación respectivamente. Una vez definido el accesorio, se invoca la función test del objeto FunFixture en el cual se define las aserciones oportunas.

Si deseamos realizar una composición de accesorios, podemos emplear la función map2 o map3 del objeto FunFixture. Un ejemplo de composición de dos accesorios como el definido anteriormente es el siguiente:

import java.nio.file.{Files, Path}
class FunctionalTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  [...]
  val files2 = FunFixture.map2(files, files)
  files2.test("Multiple Functional Test Local Fixtures") {
    case (file1, file2) => {
      assertNotEquals(file1, file2)
      assert(Files.isRegularFile(file1), s"Files.isRegularFile($file1)")
      assert(Files.isRegularFile(file2), s"Files.isRegularFile($file2)")
    }
  }
}

Ejemplo de accesorio reusable

En JUnit puedes definir funciones con funcionalidad antes del test y después, representadas en las funciones before y after. Para conseguir estas funciones, necesitamos definir accesorios reusables las cuáles son más poderosas que los accesorios funcionales. En el siguiente ejemplo se muestra una accesorio reusable que trata con ficheros:

import java.nio.file.{Files, Path}
class ReusableTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  val file = new Fixture[Path]("files") {
    var file: Path = null
    override def apply(): Path = file
    override def beforeEach(context: BeforeEach): Unit = {
      file = Files.createTempFile("files", context.test.name)
    }
    override def afterEach(context: AfterEach): Unit = {
      Files.deleteIfExists(file)
      ()
    }
  }
  override def munitFixtures: Seq[Fixture[_]] = List(file)
  test("exists") {
    assert(Files.exists(file()))
  }
}

En el ejemplo anterior, se definie un accesorio de la clase Fixture con un tipo parametrizado de tipos Path; esta clase, define las funciones beforeEach y afterEach con la referencia al recurso de tipo file con el que se trabaja. Además, se define la lista de Fixtures que pueden ser utilizados en cada test.

Si por el contrario, deseamos trabajar con una base de datos, el ejemplo de definición de la clase de test con un Fixture con el tratamiento de una base de datos es el siguiente:

import java.sql.{Connection, DriverManager}
class ReusableTestLocalFixturesDBEjem1 extends munit.FunSuite {
  val db = new Fixture[Connection]("database") {
    private var connection: Connection = null
    override def apply(): Connection = connection
    override def beforeEach(context: BeforeEach): Unit = {
      connection = DriverManager.getConnection("jdbc:h2:mem:test", "sa", "password")
    }
    override def afterEach(context: AfterEach): Unit = {
      connection.close()
      ()
    }
  }
  override def munitFixtures: Seq[Fixture[_]] = List(db)
  test("test1") {
    db()
    assert(1 == 1)
  }
  test("test2") {
    db()
    assert(1 == 1)
  }
}

En el ejemplo anterior, para cada test definido, se inicializa una base de datos de tipo m2 en memoria.

En la siguiente entrada, MUnit IV: filtrado, describiré como realizar filtros de test en ejecución.

MUnit II: aserciones

En la entrada anterior, Munit I: declaración de test, realizaré una descripción de cómo se definen test; en la presenta entrada, Munit II: aserciones, realizaré una descripción de cómo utilizar las aserciones en los test.

Las aserciones son aquellas comprobaciones lógicas que determinan si el resultado del test es correcto. Las aserciones se definen, entre otras, con las siguientes funciones : assert, assertNotEquals, assertEquals,… Las aserciones están compuestas por una comprobación lógica, o bien, por una comprobación lógica y un mensaje informativo. Unos ejemplos básicos de test son los siguientes:

class AssertEjem1 extends munit.FunSuite {
  test("Basic assert() 1") {
    val obtained = 42
    val expected = 43
    assert(obtained < expected)
  }
  test("Basic assert() 2") {
    val obtained = 42
    val expected = 43
    assert(obtained < expected, "obtained was smaller than expected")
  }
  [...]
}

MUnit ofrece la posibilidad de mostrar información sobre aquellos test cuyo resultado no es válido. Para mostrar información adicional, es necesario utilizar la función clue; para su mayor comprehensión, mostramos los siguientes ejemplos de test con sus respectivas salidas por consola:

  • Test sin función clue.
class AssertEjem1 extends munit.FunSuite {
  test("Basic assert() 1") {
    val obtained = 42
    val expected = 43
    assert(obtained == expected)
  }
}

La salida por consola es la que se muestra en la siguiente imagen:

  • Test con función clue.
class AssertEjem1 extends munit.FunSuite {
  test("Basic assert() clue - error") {
    val obtained = 42
    val expected = 43
    assert(clue(obtained) == clue(expected))
  }
}

La salida por consola es la que se muestra en la siguiente imagen:

Realizando la comparativa de la información que se muestra en los resultados de los test, al utilizar la función clue, MUnit muestra los datos que se emplean para poder realizar un análisis de los datos que se han tratado en el test y, así, poder realizar las correcciones oportunas en el momento de la definición del test.

La función clue no se usa exclusivamente con valores básicos, podemos utilizar con datos más estructurados como se muestra a continuación:

  • Clue con case class.

Supongamos que definimos un test que realiza la comparación entre dos objetos definidos en una case class, el test sería el siguiente:

test("Basic assertEquals() case class".flaky) {
  case class Library(name: String, awesome: Boolean, versions: Range = 0.to(1))
  val munitLibrary = Library("MUnit", true)
  val mdocLibrary = Library("MDoc", true)
  assertEquals(clue(munitLibrary), clue(mdocLibrary))
}

La salida por consola es la que se muestra en la siguiente imagen:

Como se observa en la imagen anterior, MUnit muestra la diferencia en los valores de los objetos que se utilizan en la aserción.

  • Clue con Map.

Supongamos que queremos definir una aserción de igualdad con dos elementos de tipo Map cuyos valores son diferentes. La definición del test es el siguiente:

test("Basic assertEquals Map".flaky) {
  assertEquals(
   clue(Map(1 -> List(1.to(3)))),
   clue(Map(1 -> List(1.to(4))))
  )
}

El resultado no es satisfactorio al no ser iguales los objetos Map y, el resultado del test, muestra las diferencias entre los dos objetos. La salida se muestra en la siguiente imagen:

  • Clue con String.

Supongamos que tenemos dos string y queremos verificar que no existen diferencias entre ellos; pero, los strings tienen una longitud grande. La definición del test es el siguiente:

test("Basic assertNoDiff") {
  val obtainedString = "val x = 41\nval y = 43\nval z = 43"
  val expectedString = "val x = 41\nval y = 42\nval z = 43"
  assertNoDiff(obtainedString, expectedString)
}

El resultado no es satisfactorio al existir diferencias. La salida se muestra en la siguiente imagen:

  • Interceptación de una excepción.

Para capturar una excepción en un test, se utiliza la función intercept. Un ejemplo de uso en un test se muestra en el siguiente código de ejemplo:

test("Basic intercept") {
  intercept[java.lang.IllegalArgumentException] {
    def throwException(): Unit = throw new IllegalArgumentException()
    throwException()
  }
}
  • Interceptación de un mensaje de una excepción.

Para capturar un mensaje de una excepción en un test, se utiliza la función interceptMessage. Un ejemplo de uso en un test se muestra en el siguiente código de ejemplo:

test("Basic interceptMessage") {
  interceptMessage[java.lang.IllegalArgumentException]("My Message Exception") {
    def throwException(): Unit = throw new IllegalArgumentException("My Message Exception")
    throwException()
  }
}

MUnit permite definir test para verificar que un snippet de código no tenga errores de compilación. Un ejemplo de uso de este tipo de test cuyo resultado es correcto es el siguiente código:

test("Basic compileError") {
  compileErrors("val x: String = 2")
}

La verificación del resultado de los test es muy intuitiva ya que el concepto de aserciones es utilizado por muchas soluciones; de la misma manera, la captura de excepciones o mensajes. Esta situación supone que la curva de aprendizaje de MUnit no sea grande.

En la siguiente entrada, MUnit III: accesorios, describiré cómo trabajar con los entornos de ejecución.

MUnit I: declaración de test

Inicio una serie de entradas relacionadas MUnit. MUnit es una librería de testing para Scala. En la presente entrada, MUnit 1: declaración de test, realizaré una introducción y una descripción de cómo se declaran test con esta librería.

MUnit tiene una parecido a otra librería de testing como es JUnit. JUnit es una librería del contexto del lenguaje de programación Java. La filosofía de MUnit es seguir la misma línea de JUnit pero en el contenxto del lengua Scala. Las características de MUnit son las siguientes:

  1. MUnit se implementa como un ejecutor de JUnit e intenta construir sobre JUnit siempre que sea posible.
  2. No tiene dependencias externas con otras librerías del mundo Scala.
  3. Es una librería multiplataforma que complica MUnit a JVM a través de JavaScript Scala.js.
  4. Informes de pruebas entendibles para analizar los problemas.

Definición de dependencias

La definición de dependencias de MUnit en un proyecto Scala en donde se utiliza sbt se realiza de la siguiente manera:

val MunitVersion = "0.7.20"
val MunitCatsEffectVersion = "0.12.0"
[...]
lazy val root = (project in file("."))
  .settings(
     scalacOptions += "-Yrangepos",
     libraryDependencies ++= Seq(
        "org.scalameta" %% "munit" % MunitVersion % Test,
        "org.typelevel" %% "munit-cats-effect-2" % MunitCatsEffectVersion % Test
     ),
  testFrameworks += new TestFramework("munit.Framework")
)

Ejemplo básico de test con MUnit

La definición de un test en MUnit y la forma de trabajar es parecida a JUnit. Los test se definen heredando de la clase munit.FunSuite, se declara un test con la sentencia test y, dentro de esta, se define la prueba funcional verificando el resultado con assertEquals. Un ejemplo básico de test es el descrito en el siguiente snippet:

class BasicEjem1 extends munit.FunSuite {
  test("Basic") {
    val obtained = 43
    val expected = 43
    assertEquals(obtained, expected)
  }
}

Declaración de test en MUnit

Declaración de test asíncronos.

MUnit tiene la capacidad de realizar test de tareas asíncronas. Esta tarea es posible gracias a los transformadores de MUnit los cuáles permiten controlar la ejecución de este tipo de tarea con un tiempo límite de espera de 30 segundos.

En el siguiente ejemplo, se muestra la definición de un test asíncrono cuyo tiempo de espera es el valor por defecto.

import scala.concurrent.Future
class BasicAsyncEjem1 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  test("async") {
    Future {
      println("Hello World")
    }
  }
}

Si deseamos modificar el timeout, debemos de modificar el valor del atributo munitTimeout. Un ejemplo de modificación del campo munitTimeout es el siguiente:

import scala.concurrent.Future
import scala.concurrent.duration.Duration
class BasicAsyncEjem2 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  override val munitTimeout = Duration(1, "s")
  test("slow-async") {
    Future {
      Thread.sleep(5000)
      println("Hello world, slow-async")
    }
  }
}

La salida por consola es la excepción de Timeout cuya traza es la siguiente:

java.util.concurrent.TimeoutException: Future timed out after [1 second]

MUnit tiene una tratamiento especial para manejar Future, pudiendo definir tareas asíncronas sin necesidad de ser ejecutas con un resultado de éxito del test. En el siguiente ejemplo, se muestra una tarea asíncrona definida en un case class que nunca se ejecuta y el resultado de ejecución es correcto.

class BasicAsyncEjem3 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  case class LazyFuture[+T](run: () => Future[T])
  object LazyFuture {
    def apply[T](thunk: => T)(implicit ec: ExecutionContext): LazyFuture[T] =
      LazyFuture(() => Future(thunk))
  }
  test("buggy-task") {
    LazyFuture {
      Thread.sleep(10)
      println("Hello world BasicAsyncEjem3")
    }
  }
}

Para poder ejecutar la anterior tarea asíncrona, necesitamos definir un ejecutor que sea capaz de ejecutar la referencia a la función run. En el siguiente ejemplo, definimos dicho ejecutor identificado como munitValueTransform. El código del ejemplo es el siguiente:

import scala.concurrent.{ExecutionContext, Future}
class BasicAsyncEjem4 extends munit.FunSuite {
  case class LazyFuture[+T](run: () => Future[T])
  object LazyFuture {
    def apply[T](thunk: => T)(implicit ec: ExecutionContext): LazyFuture[T] =
      LazyFuture(() => Future(thunk))
  }
  override def munitValueTransforms = super.munitValueTransforms ++ List(
    new ValueTransform(
      "LazyFuture",
      { 
        case LazyFuture(run) => run()
      }
    )
  )
  implicit val ec = ExecutionContext.global
    test("tarea OK") {
      LazyFuture {
         Thread.sleep(5000)
         println("Hello world BasicAsyncEjem4")
      }
    }
}

La salida por consola del código anterior es la siguiente:

Hello world BasicAsyncEjem4

Definir test con una función auxiliar.

Podemos definir una función que defina un test cuyos parámetros pueden ser por valor o por nombre. Un código ejemplo de estos test es el siguiente:

class AuxiliarFunctionEjem1 extends munit.FunSuite {
   def check[T](name: String, original: List[T], expected: Option[T])(implicit loc: munit.Location): Unit = {
      test(name) {
         val obtained = original.headOption
         assertEquals(obtained, expected)
      }
   }
   check("basic", List(1, 2), Some(1))
   def checkByName(name: String, bytes: => Array[Byte]): Unit =
       test(name) {
          assertEquals(bytes.length > 0, true)
       }
   import java.nio.file.{Files, Paths}
   checkByName("file", Files.readAllBytes(Paths.get("build.sbt")))
}

Definir test que siempre falle.

En ocasiones necesitamos que un test falle. Para definir este comportamiento, empleamos la función fail. Un código ejemplo es el siguiente:

class AlwaysFailEjem1 extends munit.FunSuite {
  test("issue-456".fail) {
     assertEquals(1, 1)
  }
}

Modificar el comportamiento de ejecutor munitTest

Si necesitamos modificar el comportamiento de un test empleamos un tag. Un tag es una entidad de Munit la cual se representa con un case class. El comportamiento del test lo definiremos en una clase TestTransform del ejecutor munitTestTransforms, referenciando al tag identificador del comportamiento. En la identificación del test, se define la referencia al tag a ejecutar. Un código ejemplo para ejecutar un test cuatro veces es el siguiente:

case class Rerun(count: Int) extends munit.Tag("Rerun")
class CustomizeEvaluationTestEjem1 extends munit.FunSuite {
   override def munitTestTransforms = super.munitTestTransforms ++ List(
     new TestTransform(
       "Rerun",
       { test =>
           val rerunCount = test.tags.collectFirst { case Rerun(n) => n }.getOrElse(1)
           if (rerunCount == 1) test
           else {
             test.withBody(() => {
                Future.sequence(1.to(rerunCount).map(_ => test.body()).toList)
             })
           }
       }
     )
   )
   test("files".tag(Rerun(4))) {
       println(s"Hello world...")
   }
}

El resultado del anterior código es la escritura en consola del texto “Hello world…” cuatro veces.

Modificar el nombre de un test de forma dinámica.

Si necesitamos ejecutar test cuyo nombre dependa de circunstancias externas, es necesario emplear un TestTransform. Un código ejemplo que modifica el nombre de los test de forma dinámica con la versión de scala es el siguiente:

class CustomizeTestNameEjem1 extends munit.FunSuite {
  val scalaVersion = scala.util.Properties.versionNumberString
  override def munitTestTransforms: List[TestTransform] = super.munitTestTransforms ++ List(
     new TestTransform(
        "append Scala version",
        { test =>
            test.withName(test.name + "-" + scalaVersion)
        }
      )
  )
  test("test-Foo") {
     assert(scalaVersion.startsWith("2.13.4"))
     assertEquals(scalaVersion, "2.13.4")
  }
}

El resultado de la ejecución es el que se muestra en la siguiente imagen:

Saltar un test.

Si necesitamos saltar un test, emplearemos la función flaky para saltar la ejecución. Un código ejemplo es el siguiente:

class FlakyTestEjem1 extends munit.FunSuite {
  test("requests".flaky) {
    val obtained = 42
    val expected = 43
    assertEquals(obtained, expected)
  }
}

Para saltar la ejecución en los test, es necesario ejecutar sbt definiendo la variable MUNIT_FLASK_OK con valor a true. Si no se emplea la variable, los test se ejecutan normalmente. Un ejemplo de ejecución es el siguiente:

MUNIT_FLAKY_OK=true sbt test

Compartir configuración de test.

Si necesitamos definir clases de test con componentes comunes, se define una herencia de clases cuya clase base defina todo aquello que sea común. Si necesitamos modificar el nombre de los test de dos clases diferentes, definimos una clase base con el transformador del nombre de test y, sus clases hijas, heredan de dicha clase base. Un código ejemplo es el siguiente:

class BaseSuite extends munit.FunSuite {
   val scalaVersion = scala.util.Properties.versionNumberString
   override def munitTestTransforms: List[TestTransform] = super.munitTestTransforms ++ List(
      new TestTransform(
        "append Scala version",
        { test =>
          test.withName(test.name + "-" + scalaVersion)
        }
      )
   )
}
class MyFirstTestSuite extends BaseSuite {
   test("FirstTestSuite.test1") {
      println("Hello world ...FirstTestSuite.test1")
   }
   test("FirstTestSuite.test2") {
      println("Hello world FirstTestSuite.test2")
   }
}
class MySecondTestSuite extends BaseSuite {
   test("SecondTestSuite.test1") {
      println("Hello world ...SecondTestSuite.test1")
   }
   test("SecondTestSuite.test2") {
      println("Hello world SecondTestSuite.test2")
   }
}

La salida por consola es la que se muestra en la siguiente imagen:

Definición de una batería de pruebas.

Si necesitamos definir una batería de pruebas, definimos una clase de test que herede de munit.Suite, definimos el tipo de de valor de las pruebas y, para finalizar, definimos la lista de test con la creación de tantas clases Test como queremos. Un código de ejemplo es el siguiente:

import munit.Assertions.assertEquals
import munit.{Location, Tag}
class TestLibrarySuite2 extends munit.Suite {
   override type TestValue = Unit
   override def munitTests(): Seq[Test] = List(
     new Test(
       "example1",
       body = () => {
          val obtained = 43
          val expected = 43
          assertEquals(obtained, expected)
       },
       tags = Set.empty[Tag],
       location = implicitly[Location]
     ),
     new Test(
        "example2",
        body = () => {
           val obtained = "43"
           val expected = "43"
           assertEquals(obtained, expected)
        },
        tags = Set.empty[Tag],
        location = implicitly[Location]
     )
   )
}

En la siguiente entrada, MUnit II: aserciones, describiré en cómo definir aserciones en los test.

Microservicios en Python: plantilla básica.

En la presente entrada, Microservicios en Python: plantilla básica, realizaré una descripción de una plantilla base de un microservicio en Python utilizando la librería Flask.

La arquitectura de microservicios es aquel enfoque que permite definir aplicaciones software mediante un conjunto de servicios desplegables de forma independiente, es decir, una aplicación es un conjunto de pequeñas aplicaciones poco acopladas. La definición de microservicio que realiza Martin Fowler es la siguiente:

“El término ‘Arquitectura de microservicio’ ha surgido en los últimos años para describir una forma particular de diseñar aplicaciones de software como conjuntos de servicios desplegables de forma independiente. Si bien no existe una definición precisa de este estilo arquitectónico, existen ciertas características comunes en torno a la organización en torno a la capacidad empresarial, la implementación automatizada, la inteligencia en los puntos finales y el control descentralizado de idiomas y datos.”

El acoplamiento entre los microservicios, se puede realizar utilizando colas, brokers de mensajería o mediante peticiones HTTP. Un ejemplo de un productor y consumidor de mensajes para el broker de mensjaes que contiene Redis pueden ser los que describo en los siguientes enlaces:

La funcionalidad de la plantilla del microservicio es muy simple, se definirá un punto de entrada de tipo POST al cual se le pasarán los campos nombre, operación y operador y, como resultado, retornará un JSON con el resultado. Se empleará la técnica DDD Domain Driven Design para definir una entidad de dominio la cual será almacenda en un supuesto contenedor de datos, en nuestro caso, en memoria.

Las dependencias de las librerías del proyecto se definen en el fichero requirements.txt y contiene las siguientes referencias: flask, dataclasses y pytest.

La arquitectura está compuesta por tres capas horizontales: capa de presentación, representada por el paquete entrypoints; cada de servicios, representada por el paquete services; y, capa de datos, representada por el paquete repository. Desde un punto de vista vertical, tenemos las siguientes capas: capa de dominio, representada por el paquete domain en donde se define las entidades de dominio y DTO; y, por último,capa de excepciones, representado con el paquete exception en cual contiene las excepciones del
aplicativo.

Descripción arquitectónica por capas

Capa de dominio

La capa de dominio está compuesto por el módulo entity_model.py el cual contiene la entidad de dominio UseCaseEntity y los DTO UseCaseRequest y UseCaseResponse. El snippet de la entidad de dominio es el siguiente:

class UseCaseEntity:
    def __init__(self,
                 uuid: str,
                 name: str,
                 operation: str,
                 operator: int,
                 date_data: Optional[date] = None):
        self.uuid = uuid
        self.name = name
        self.operation = operation
        self.operator = operator
        self.date = date_data
    @property
    def calculate(self) -> int:
        result = 0
        if self.operation == "+":
             result = self.operator + self.operator
        elif self.operation == "*":
             result = self.operator * self.operator
        else:
             result = -1
        return result

Capa de presentación

La capa de presentación está compuesta por el módulo app.py. Los puntos de entrada son: métodos liveness y rediness para conocer el estado del microservicio (en el ejemplo no realizan ninguna operación) y el método para la operación de negocio use_case_example; este método, realiza la obtención de los parámetros de la petición HTTP, creación del DTO de la petición e invocación al método de servicio; para finalizar, retorna el resultado. El snippet de la función es la siguiente:

@app.route("/use_case_example", methods=['POST'])
def do_use_case_example():
    """
    use case example
    curl --header "Content-Type: application/json" --request POST \
         --data '{"name":"xyz1", "operation":"+", "operator":"20"}' \
         http://localhost:5000/use_case_example
    :return: str
    """
    p_name = request.json['name']
    p_operation = request.json['operation']
    p_operator = int(request.json['operator'])
    current_app.logger.info(f"[*] /use_case_example")
    current_app.logger.info(f"[*] Request: Name={p_name} operation={p_operation} operator={p_operator}")
    current_app.logger.info(f"Name={p_name} operation={p_operation} operator={p_operator}")
    data_request = entity_model.UseCaseRequest(uuid=uuid.UUID,
                                               name=p_name,
                                               operation=p_operation,
                                               operator=p_operator)
    repository = use_case_repository.UseCaseRepository()
    response_use_case = use_case_service.do_something(data_request, repository)
    data = jsonify({'result': response_use_case.resul})
    return data, 200

Capa de servicio

La capa de servicio está compuesta por el módulo use_case_service.py el cual contiene la función que realiza la operación de negocio: creación de la entidad de dominio, inserción en el repositorio de datos y retorno del resultado. El snippet de la función es el siguiente:

def do_something(request: entity_model.UseCaseRequest,
                 repository: use_case_repository.AbstractUseCaseRepository) -> entity_model.UseCaseResponse:
    """
    Business operation.
    :param request: entity_model.UseCaseRequest
    :param repository: use_case_repository.AbstractUseCaseRepository

    :return: entity_model.UseCaseResponse
    """
    if request is None:
        raise use_case_exception.UseCaseRequestException()
    logging.info(f"[**] /use_case_service.do_something")
    entity = entity_model.UseCaseEntity(uuid=request.uuid,
                                        name=request.name,
                                        operation=request.operation,
                                        operator=request.operator,
                                        date_data=date.today())
    repository.add(entity)
    return entity_model.UseCaseResponse(str(entity.calculate))

Capa de repositorios

La capa de repositorio define el respositorio en donde se almacenan los datos la cual está compuesta por el módulo use_case_repository.py. El módulo contiene la definición del repositorio UseCaseRepository para la entidad UseCaseEntity y la clase de abstracta con las operaciones de los repositorios. El snippet del repositorio es el siguiente:

class UseCaseRepository(AbstractUseCaseRepository):
    """
    Definition of the operations that connect to database.
    """
    def __init__(self) -> None:
        self.database: [entity_model.UseCaseEntity] = []
    def add(self, entity: entity_model.UseCaseEntity) -> bool:
        result: bool = False
        logging.info(f"[***] /use_case_repository.add")
        if entity is not None:
            result = True
            self.database.append(entity)
        return result
    def get(self, p_uuid: str) -> entity_model.UseCaseEntity:
        index = 0
        enc = False
        result: entity_model.UseCaseEntity = None
        logging.info(f"[***] /use_case_repository.get")
        while (index < len(self.database)) and not enc:
            aux: entity_model.UseCaseEntity = self.database[index]
            if aux.uuid == uuid.UUID(p_uuid):
                result = aux
                enc = True
            index += 1
       return result

Pruebas

La plantilla contiene el directorio tests el cual contiene los test de la plantilla del microservicio. Para ejecutar los test se ejecuta el siguiente comando desde la carpeta raíz del proyecto:

>pytest --setup-show

Docker

Todo microservicio debe de tener la definición de la imagen para que sea ejecutado en un contenedor. Así, existe el fichero Dockerfile para definir dicha imagen. El contenido de la imagen contiene las operaciones de instalación de las herramientas para Python, instalación de las librerías, copiado de código fuente y variables de entorno y ejecutación. El snippet con el contenido del DOckerfile es el siguiente:

FROM python:3.8-alpine
RUN apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev
RUN apk add libpq
COPY requirements.txt /tmp
RUN pip install -r /tmp/requirements.txt
RUN apk del --no-cache .build-deps
RUN mkdir -p /app
COPY . /app/
WORKDIR /app
ENV FLASK_APP=entrypoints/app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1
CMD flask run --host=0.0.0.0 --port=80

Makefile

Para facilitar las operaciones de Docker, se ha definido un fichero de tipo Makefile en el cual se definen las operaciones necesarias para operar con Docker. Las operaciones son las siguientes:

  • Operación build.- Creación de la imagen. Para ejecutar la operación se ejecuta el comando make build desde la raíz del proyecto. El snippet de la definición de la operación build es la siguiente:
build:
     docker image build -t alvaroms/template-microservice:v1.0 .
  • Operación run.- Arranque de un contenedor con la imagen del proyecto. Para ejecutar la operación se ejecuta el comando make run desde la raíz del proyecto. El snippet de la definición de la operación run es la siguiente:
run:
   docker container run -d --name template-microservice -p 6060:80 alvaroms/template-microservice:v1.0
  • Operación exec.- Acceso a la consola del contenedor. Para ejecutar la operación se ejecuta el comando make exec desde la raíz del proyecto. El snippet de la definición de la operación exec es la siguiente:
exec:
    docker container exec -it template-microservice /bin/sh
  • Operación logs.- Visualización de los logs del contenedor. Para ejecutar la operación se ejecuta el comando make logs desde la raíz del proyecto. El snippet de la definición de la operación logs es la siguiente:
logs:
    docker container logs template-microservice
  • Operación test.- Ejecución de los test. Para ejecutar la operación se ejecuta el comando make test desde la raíz del proyecto. El snippet de la definición de la operación test es la siguiente:
test:
    pytest --setup-show
  • Operación all.- Ejecución de los test, construcción de la imagen y arranque del contenedor. Para ejecutar la operación se ejecuta el comando make all desde la raíz del proyecto. El snippet de la definición de la operación test es la siguiente:
all: test build run

Pruebas del API

Los comando curl para realizar las pruebas sobre el microservicio desplegado en el contenedor son los siguientes:

  • Root del microsercicio.
    curl http://localhost:6060/
  • Función rediness
    curl http://localhost:6060/readiness
  • Función liveness
    curl http://localhost:6060/liveness
  • Función de negocio.
    curl --header "Content-Type: application/json" --request POST \
    
    --data '{"name": "xyz1", "operation": "+", "operator": "20"}' \
    
    http://localhost:6060/use_case_example

Integración Contínua

Para finalizar, se define un pipeline de integración contínua definida en el fichero .travis.yml. El snippet con el contenido es el siguiente:

dist: xenial
language: python
python: 3.6
install:
- pip3 install -r requirements.txt

script:
- make test

branches:

 

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

Redis: consumidor de mensajes

En la entrada anterior, Redis: productor de mensajes, describo cómo definir un productor para la publicación de mensajes en el broker Redis; en la presente entrada, Redis: consumidor de mensajes, describiré cómo consumir mensajes del broker.

El primer paso es crear el broker al cual publicar mensajes; para ello, trabajaré con una imagen Docker con Redis. Para descarga la imagen y arrancar el contenedor es necesario ejecutar los siguientes comandos:

docker pull redis
docker run --name some-redis -d redis

Tras su ejecución, tendremos Redis en una contenedor cuyo puerto de acceso es el 6379.

El segundo paso, es escribir el código del consumidor. Seguiremos los mismos criterios que en la entrada Redis: productor de mensajes.

Para crear la conexión con Redis, creamos un objeto de tipo Redis con los datos de la conexión a Redis. El snippet del código es el siguiente:

import redis
publish_redis = redis.Redis(host=config.HOST_REDIS, port=config.PORT_REDIS, db=0)

Una vez que tenemos la referencia a Redis, necesitamos suscribirnos al topic donde leer los mensajes; una vez suscritos, nos mantenemos a la espera de la recepción del mesaje; al recepcionar el mensaje, obtenemos un mensaje con una estructura de diccionario del cual deberemos de obtener el campo data. El snippet con el código es el siguiente:

consume_client_topic = publish_redis.pubsub()
consume_client_topic.subscribe(config.TOPIC_REDIS)
for message in consume_client_topic.listen():
  if message['data'] != 1:
    data = json.loads(message['data'].decode())
    logging.info(f"mesagge={data['message']} result={data['result']}")

Para el lector interesado, el código del enlace está en el siguiente enlace.

Redis: productor de mensajes

Redis es una herramienta Open source la cual puede ser utilizada como un almacén de estructura de datos en memoria, como una cache, como base de datos y como un broker
de mensajes. En la presente entrada, Redis: productor de mensajes en el broker, me centraré en describir cómo crear un productor de mensajes en el broker de mensajes de Redis. El ejemplo estará definido en lenguaje Python.

El primer paso es crear el broker al cual publicar mensajes; para ello, trabajaré con una imagen Docker con Redis. Para descargar la imagen y arrancar el contenedor es necesario ejecutar los siguientes comandos:

docker pull redis
docker run --name some-redis -d redis

Tras su ejecución, tendremos Redis en una contenedor cuyo puerto de acceso es el 6379.

El segundo paso es crear un proyecto Python en donde definiremos la dependencia del paquete redis y un fichero de tipo Python con el código del productor.

Para crear la conexión con Redis, creamos un objeto de tipo Redis con los datos de la conexión a Redis. El snippet del código es el siguiente:

import redis
publish_redis = redis.Redis(host=config.HOST_REDIS, port=config.PORT_REDIS, db=0)

Una vez creado la referecia a Redis, utilizaremos la función publish para publicar un mensaje en un topic de Redis. El snippet ejemplo es el siguiente:

msg = '{"message": "Test message-%d", "result": "OK"}' % index
publish_redis.publish(config.TOPIC_REDIS, msg)

El valor config.TOPIC_REDIS corresponde con un valor alfanumérico.

Para el lector interesado, el código del enlace está en el siguiente enlace.

En la siguiente entrada, Redis: consumidor de mensajes, realizaré la descripción de un consumidor de mensajes.

Composición de FreeMonad

En la entrada anterior, FreeMonad en Cats, realicé una descripción de cómo definir un DSL utilizando una FreeMonad pero, en ciertas situaciones, necesitamos utilizar un segundo DSL conjuntamente con el primero. En la entrada de hoy, Composición de FreeMonad, realizaré un ejemplo de una aplicación que utiliza conjuntamente dos DSL para la definición de un programa.

Presentación del problema

Continuando con las entidades de dominio de la entrada anterior, supongamos que necesitamos realizar la creación de un esquema de base de datos, realizar unas inserciones, eliminar y consultar; es decir, necesitamos un programa que realice unas operaciones básicas de tipo CRUD; pero además, necesitamos que las operaciones sean trazadas en un log en la salida estándar; pudiendo ser de dos tipos: mensajes informativos, tipo info; y, mensajes de depuración, tipo debug.
Para definir un programa que cumpla con estos requisitos necesitamos lo siguiente: definir un DSL para las operaciones en base de datos, definir un DSL para las operaciones de log y un intérprete que entienda el lenguaje de los DSL`s.

Definición de los ADT`s

Definiré dos ADT’s para los dos tipos de operaciones: DBOperations, para el ADT’s de las operaciones en base de datos; y, LogOperation. Antes de definir los ADT’s, definiremos el alias a utilizar. Definimos un alias con nombre Response con un tipo genérico, define un contenedor binario de tipo Either de un tipo Exception y un tipo genérico. La definición del alias es el siguiente:

type Response[A] = Either[Exception, A]

El ADT que define las operaciones de base de datos es el mismo que se utiliza en la entrada anterior, mediante la siguiente estructura de clases:

sealed trait DBOperation[A]
case class Configure(xa: Aux[IO, Unit]) extends DBOperation[Response[Unit]]
case class CreateSchema() extends DBOperation[Response[Boolean]]
case class Insert(author: Author) extends DBOperation[Response[Int]]
case class Select(key: Int) extends DBOperation[Response[Option[String]]]
case class Delete(key: Int) extends DBOperation[Response[Int]]

Las clases definidas definen las siguientes operaciones: clase Configure, define la operación de configuración de Transactor de la base de datos; CreateSchema, creación del esquema de base de datos: Insert, inserción de una entidad Author; Select, seleccionar un autor por clave; Delete, eliminación de un autor por clave.

El ADT que define las operaciones de escritura de log se define mediante la siguiente estructura de clases:

sealed trait LogOperation[A]
case class Info(msg: String) extends LogOperation[Response[Unit]]
case class Debug(msg: String) extends LogOperation[Response[Unit]]

Las clases definidas definen las siguientes operaciones: Info, define la escritura de un mensaje de tipo informativo; Debug, define la escritura de un mensaje de tipo depuración.

En los dos ADT’s, se define como respuesta de la operación el tipo Response.

Definición de los DSL’s

Una FreeMonad permite a un Functor tener la funcionalidad de una mónada; añadir esta funcionalidad, la proporciona el tipo Free[_]. Además, para poder trabajar con otros DSL, es necesario añadir elementos que permitan la unión de los DSL; para ello se utilizan las siguientes herramientas:

  • InjectK[DBOperation, F].- Type class que permite inyectar el tipo DBOperation en el tipo F del contexto de ejecución.
  • Free.inject[DBOperation, F].- Función que permite enlazar el tipo DBOperation al tipo F del contexto de ejecución.

La definición de los DSL`s DBOperations y LogOperations se realiza definiendo una clase y su companion object el cual define una referencia implícita a la instancia de la clase. El código de los DSL`s se definen en el siguiente snippet:

class DBOperations[F[_]](implicit I: InjectK[DBOperation, F]){
  def configure(xa: Aux[IO, Unit]): Free[F, Response[Unit]] = Free.inject[DBOperation, F](Configure(xa))
  def createSchema(): Free[F, Response[Boolean]] = Free.inject[DBOperation, F](CreateSchema())
  def insert(elem: Author): Free[F, Response[Int]] = Free.inject[DBOperation, F](Insert(elem))
  def delete(key: Int): Free[F, Response[Int]] = Free.inject[DBOperation, F](Delete(key))
  def select(key: Int): Free[F, Response[Option[String]]] = Free.inject[DBOperation, F](Select(key))
}
object DBOperations{
  implicit def dboperations[F[_]](implicit I: InjectK[DBOperation, F]) = new DBOperations[F]()
}
class LogOperations[F[_]](implicit I: InjectK[LogOperation, F]){
  def infoLog(msg: String): Free[F, Response[Unit]] = Free.inject[LogOperation, F](Info(msg))
  def debugLog(msg: String): Free[F, Response[Unit]] = Free.inject[LogOperation, F](Debug(msg))
}
object LogOperations{
  implicit def logopearations[F[_]](implicit I: InjectK[LogOperation, F]) = new LogOperations[F]()
}

Definición de los intérpretes

Los intérpretes son los functores de transformación. Realizan la interpretación del DSL, interpretando el lenguaje para el que está definido, ejecuta la funcionalidad requerida y retorna el valor transformado.

El intérprete del DSL DBOperations se define en el objeto DBOperationInterprete el cual es un functor de transformación de un tipo DBOperation a OperationResponse. El tipo OperationResponse es un alias del tipo identidad definido en Cats. El alias se define de la siguiente forma:

type OperationResponse[A] = Id[A]

DBOperationsInterprete define una referencia a un transactor de la librería Doobie con una configuración por defecto, esta configuración es un valor mutable ya que puede ser configurada de forma dinámica. Para todo elemento del ADT, se utiliza el repositorio de Author y se trata el resultado como un tipo OperationResponse[A]. El snippet del código con el intérprete es el siguiente:

object DBOperationsInterpreter extends (DBOperation ~> OperationResponse){
  implicit val cs = IO.contextShift(ExecutionContexts.synchronous)
  // TODO var -> val?
  private var xa = Transactor.fromDriverManager[IO](
    s"com.mysql.jdbc.Driver", s"jdbc:mysql://host:port/doobie", s"user", s"pwd",
    Blocker.liftExecutionContext(ExecutionContexts.synchronous) // just for testing
  )
  override def apply[A](fa: DBOperation[A]) = fa match {
    case Configure(xaTransactor) => {
      this.xa = xaTransactor
      val result: OperationResponse[Response[Unit]] = Right(Unit)
      result
    }
    case CreateSchema() => {
      val resultCreateSchema: Response[Boolean] = AuthorRepository.createSchemaIntoMySqlB(xa)
      val result: OperationResponse[ Response[Boolean]] = resultCreateSchema
      result
    }
    case Insert(author) =>{
      val resultTask: Response[Int] = AuthorRepository.insertAuthorIntoMySql(xa, author)
      val result: OperationResponse[Response[Int]] = resultTask
      result
    }
    case Delete(key) => {
      val resultTask: Response[Int] = AuthorRepository.deleteAuthorById(xa, key)
      val result: OperationResponse[Response[Int]] = resultTask
      result
    }
    case Select(key) => {
      val resultTask: Response[Option[String]] = AuthorRepository.selectAuthorById(xa, key)
      val result: OperationResponse[Response[Option[String]]] = resultTask
      result
    }
  }
}

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

El intérprete del DSL`s de operaciones de Log se define en el objeto LogOperationsInterprete el cual define un functor transformador de un tipo LogOperation a un tipo OperationResponse. El intérprete define las operaciones para los mensajes de tipo info y debug escribiendo mensajes en la salida estándar. El snippet de código con el intérprete es el siguiente:

object LogOperationsInterpreter extends (LogOperation ~> OperationResponse){
  override def apply[A](fa: LogOperation[A]) = fa match {
    case Info(msg) =>
      println(s"[*** INFO] ${msg}")
      val result: OperationResponse[Response[Unit]] = Right(Unit)
      result
    case Debug(msg) =>
      println(s"[*** DEBUG] ${msg}")
      val result: OperationResponse[Response[Unit]] = Right(Unit)
      result
  }
}

Un aspecto importante a destacar es que en los dos funtores de transformación, es decir, los intérpretes, tienen el mismo tipo de salida definido en el alias OperationResponse[A].

Llegado a este punto, tenemos definidos los dos intérpretes de forma individual pero, nos falta la definición del intérprete que comprenda los dos lenguajes; para ello, tenemos que definir un tipo que englobe a los dos ADT de los DSL definidos y, posteriormente, define un functor que transforma del tipo anterior al resultado final.

El tipo DoobiePureComposingApp es un alias que define un tipo EitherK con los tipos DBOperation, LogOperation (ADT de los DSL definidos) y el tipo A. Este tipo comprende los tipos de las gramáticas de los DSL.

type DoobiePureComposingApp[A] = EitherK[DBOperation, LogOperation, A]

El tipo que define el resultado es el tipo identidad de los DSL definidos.

type OperationResponse[A] = Id[A]

Así, la definición del intérprete que engloba a los dos DSL se define de la siguiente forma:

val interpreter: DoobiePureComposingApp ~> OperationResponse = DBOperationsInterpreter or LogOperationsInterpreter

Definición del programa de uso de los DSL

Llegado a este punto, estamos en disposición de declarar un programa que realice operaciones en base de datos de la entidad Author y con capacidad de escribir en un log. Como comentamos al inicio de la entrada, el programa declara un conjunto de operaciones representativas con un objetivo pedagógico. El snippet del código es el siguiente:

implicit val cs = IO.contextShift(ExecutionContexts.synchronous)
final case class DatabaseConfig( host: String, port: String, user: String, password: Secret[String])
def loadEnvironmentVariables(): DatabaseConfig = {
  val host: ConfigValue[String] = env("DDBB_HOST").or(prop("ddbb.host")).as[String]
  val port: ConfigValue[String] = env("DDBB_PORT").or(prop("ddbb.port")).as[String]
  val user: ConfigValue[String] = env("DDBB_USER").or(prop("ddbb.user")).as[String]
  val password: ConfigValue[Secret[String]] = env("DDBB_PWD").secret
  val configureEnv: ConfigValue[DatabaseConfig] = (host, port, user, password).parMapN(DatabaseConfig)
  configureEnv.load[IO].unsafeRunSync()
}
val dataConfigure: DatabaseConfig = loadEnvironmentVariables()
private val xa = Transactor.fromDriverManager[IO](
  "com.mysql.jdbc.Driver",
  s"jdbc:mysql://${dataConfigure.host}:${dataConfigure.port}/doobie",
  s"${dataConfigure.user}",
  s"${dataConfigure.password.value}",
  Blocker.liftExecutionContext(ExecutionContexts.synchronous) // just for testing
)
def programBusiness(xa: Aux[IO, Unit])(implicit DB: DBOperations[DoobiePureComposingApp],
L: LogOperations[DoobiePureComposingApp]): Free[DoobiePureComposingApp, Response[Option[String]]] = {
  import DB._
  import L._
  for {
    _ <- debugLog("Configuring database...")
    _ <- configure(xa)
    _ <- debugLog("Creating database...")
    result <- createSchema()
    numInsert1 <- insert(Author(0, "AuthorTest1"))
    numInsert2 <- insert(Author(0, "AuthorTest2"))
    numDelete1 <- delete(key = 1)
    nameAuthor <- select(key = 2)
    _ <- infoLog("Created Database.")
  } yield {
    nameAuthor
  }
}
val result = programBusiness(xa).foldMap(interpreter)
println(s"Result=${result}")

El código presentado, realiza la carga de la configuración de base de datos desde variables de entorno utilizando la librería Ciris; esta funcionalidad, queda definida en la función loadEnvironmentVariables la cual retorna una clase DatabaseConfig con la configuración; se define el Transactor de la librería Doobie con la configuración la cual es utilizada posteriormente en la declaración del programa; y, por último, se declara el programa en la función programBusiness el cual define como implícitos las referencias a las instancias de los DSL con el tipo DoobiePureComposingApp y, como retorno, un tipo Free con el tipo DoobiePureComposingApp y el tipo de retorno del programa Response[Option[String]].

La salida por consola es la siguiente:

[*** DEBUG] Configuring database…
[*** DEBUG] Creating database…
[*** INFO] Created Database.
Result=Right(Some(AuthorTest2))

Para el lector interesado en el código puede acceder a través del siguiente enlace y, para la entrada en donde explico el uso de la librería Ciris, puede acceder mediante el siguiente enlace

FreeMonad en Cats

Definimos FreeMonad como aquella construcción que permite construir una mónada desde un functor. En la presente entrada, FreeMonad en Cats, describiré cómo definir y utilizar una FreeMonad mediante un ejemplo.

Según Wikipedia, definimos funtor como sigue:

“En teoría de categorías un funtor o functor es una función de una categoría a otra que lleva objetos a objetos y morfismos a morfismos de manera que la composición de morfismos y las identidades se preserven”.

Un functor cumple la propiedad de identidad y de composición.

Según Wikipedia, definimos mónada como sigue:

“En la programación funcional , una mónada (monad en inglés), es una estructura que representa cálculos definidos como una secuencia de pasos”

Una mónada cumple la propiedad de identidad y asociativa.

La FreeMonad permite lo siguiente:

  • Representar cálculos con estado como datos y ejecutarlos.
  • Ejecutar cálculos recursivos.
  • Definir un DSL.
  • Reorientar un cálculo a otro intérprete utilizando transformaciones naturales.

La implementación de FreeMonad que utilizaremos en el ejercicio es aquella que se ha definido en la librería Cats y, el módulo de cats empleado, es el paquete que se define en cats-free. Una FreeMonad se define como Free[_]

La definición de dependencias del módulo sbt en el fichero build.sbt es la siguiente:

lazy val catsEffect = (project in file("cats-effect"))
  .settings(
    name := "example-cats-effect",
    assemblySettings,
    scalacOptions ++= basicScalacOptions,
    libraryDependencies ++=
      catsEffectDependencies ++ Seq(
        scalaTest
      )
  )
lazy val catsEffectDependencies = Seq(
  cats_effect,
  cats_effect_laws
)

La definición de las coordenadas de los paquetes a utilizar en el módulo para la librería cats-free son las siguientes:

lazy val cats_effect_laws = "org.typelevel" %% "cats-effect-laws" % "2.2.0" % "test"
lazy val cats_free = "org.typelevel" %% "cats-free" % "2.2.0"

Presentación del problema

Supongamos que tenemos un aplicativo el cual maneja una entidad Autor y necesitamos persistir los datos de la entidad Autor en base de datos. Para ello, necesitamos realizar operaciones sobre base de datos como son: creación del esquema, insertar, borrar y buscar elementos. La definición de la entidad de dominio del problema se define en la siguiente case class:

case class Author(id:Long, name: String)

Para dar solución a este problema, definiremos un DSL que soporte las funcionalidades requeridas. Evidentemente, es un problema teórico con el objetivo describir la definición de una FreeMonad.

Definición de la gramática

La gramática del DSL queda definida en un ADT (Tipo de dato Algebraico) el cual define las operaciones a realizar. El ADT con las operaciones de base de datos OperationDB[A] queda definido en la siguiente estructura de clases:

sealed trait OperationDB[A]
case class CreateSchema() extends OperationDB[OperationDBResponse[Boolean]]
case class Insert(author: Author) extends OperationDB[OperationDBResponse[Int]]
case class Select(key: Int) extends OperationDB[OperationDBResponseOption[String]]
case class Delete(key: Int) extends OperationDB[OperationDBResponse[Int]]

Las operaciones de base de datos son: CreateSchema, operación para la creación del esquema de la entidad Autor; Insert, operación de inserción de un Autor; Select, búsqueda de un Autor en función de una clave; y, Delete, eliminación de un Autor por clave. El tipo parametrizado es el tipo de retorno de cada operación cuyos tipos son los representados en los siguiente alias:

type OperationDBResponse[A] = Either[Exception, A]
type OperationDBResponseOption[A] = Either[Exception, Option[A]]

Los alias descritos anteriormente define contenedores binarios de Tipo Either cuyo valor Left representa una excepción y, su valor Right, representa el resultado de la operación de tipo A u Option[A]

Definición del DSL

El DSL se define mediante una FreeMonad la cual, utilizando Cats, queda definida con el tipo Free[_]. Así, el alias del tipo de operación del DSL se define utilizando el siguiente alias:

type Operation[A] = Free[OperationDB, A]

El alias Operation[A] define una FreeMonad cuyo valor de entrada es una operación de tipo OperationDB y cuyo resultado es un tipo genérico A.

El DSL debe de definir funciones para las siguientes operaciones: creación de un esquema, inserción, borrado y consulta. Las funciones del DSL son las siguientes:

import cats.free.Free
import cats.free.Free.liftF
[...]
def createSchema(): Operation[OperationDBResponse[Boolean]] =
    liftF[OperationDB, OperationDBResponse[Boolean]](CreateSchema())
def insert(elem: Author): Operation[OperationDBResponse[Int]] =
    liftF[OperationDB, OperationDBResponse[Int]](Insert(elem))
def delete(key: Int): Operation[OperationDBResponse[Int]] =
    liftF[OperationDB, OperationDBResponse[Int]](Delete(key))
def select(key: Int): Operation[OperationDBResponseOption[String]] =
    liftF[OperationDB, OperationDBResponseOption[String]](Select(key))

Las funciones del DSL utilizan la función liftF que permite subir de contexto de una operación OperationDB con un tipo de respuesta a una FreeMonad, Free[_].

Definición del intérprete del DSL

Una vez definido el lenguaje de dominio es necesario definir aquel intérprete que sea capaz de traducir el lenguaje en operaciones de base de datos. En nuestro caso, utilizaremos una entidad repositorio el cual sea capaz de realizar las operaciones de base de datos, consiguiendo desacoplar las operaciones de interpretación del lenguaje con las operaciones directas de base de datos.

El repositorio es un objeto llamado AuthorRepository el cual utiliza la librería funcional Doobie para realizar las operaciones.

Para cada operación del lenguaje, el intérprete gestiona un estado. Los estados son los siguientes: Init, estado inicial que representa el estado previo a la creación del esquema; Created, estado que indica que el esquema ha sido creado; Deleted, estado que representa la eliminación del esquema, en la práctica no se utiliza.

Los estados quedan representados en la siguiente estructura:

sealed trait StateDatabase
case object Init extends StateDatabase
case object Created extends StateDatabase
case object Deleted extends StateDatabase

Dada la existencia de estados, el intérprete utilizará la mónada estado para gestionar el retorno de estado de cada operación.

El intérprete define un functor de transformación, representado por el símbolo ~> el cual transforma entidades de tipo OpearetionDB en OperationState. El functor intérprete recibe como parámetro un Transactor de Doobie de la base de datos con la que se opera. El snippet de código del intérprete es el siguiente:

import cats.~>
[...]
def pureInterpreter(xa: Aux[IO, Unit]): OperationDB ~> OperationState = new(OperationDB ~> OperationState){
override def apply[A](fa: OperationDB[A]): OperationState[A] = fa match {
  case CreateSchema() => {
    val resultCreateSchema: Either[Exception, Boolean] = AuthorRepository.createSchemaIntoMySqlB(xa)
    resultCreateSchema
      .fold( ex => State[StateDatabase, OperationDBResponse[Boolean]]{ state => (Init,  resultCreateSchema) },
        value => State[StateDatabase, OperationDBResponse[Boolean]]{ state => (Created,  resultCreateSchema) } )
  }
  case Insert(author) =>{
    State[StateDatabase, OperationDBResponse[Int]]{ state =>
      (Created,  AuthorRepository.insertAuthorIntoMySql(xa, author))
    }
  }
  case Delete(key) => {
    State[StateDatabase, OperationDBResponse[Int]]{ state =>
      (Created,  AuthorRepository.deleteAuthorById(xa, key))
    }
  }
  case Select(key) => {
    State[StateDatabase, OperationDBResponseOption[String]]{ state =>
      (Created,  AuthorRepository.selectAuthorById(xa, key))
    }
  }
 }
}

Definición del repositorio de la entidad Autor.

El repositorio con las operaciones de base de datos está definiado en un objeto AuthorRepository que utiliza la librería Doobie. El objeto define un conjunto de funciones con las operaciones que utiliza el intérprete del DSL; como ejemplo, la operación de creación del esquema es la siguiente:

val dropAuthor: ConnectionIO[Int] = sql"""DROP TABLE IF EXISTS Author""".update.run
val createAuthor: ConnectionIO[Int] = sql"""CREATE TABLE Author (id SERIAL, name text)""".update.run
def createSchemaIntoMySqlA[A](xa: Aux[IO, Unit]): Either[Exception, Unit] = {
  val creator: ConnectionIO[Unit] = for {
    _ <- dropAuthor
    _ <- createAuthor
  } yield ()
  try{
    val resultDatabase: Unit = creator.transact(xa).unsafeRunSync
    Right(resultDatabase)
  } catch {
    case e: java.sql.SQLException =>{
      println(s"Error create schema: ${e}")
      Left(e)
    }
  }
}

Para el lector interesado en el código del repositorio puede acceder en el siguiente enlace.

Definición de los programas

Una vez definida en un objeto AuthorDSL las funciones del DSL, alias e intérpretes, estamos en disposición de definir las declaraciones de los programas que pueden utilizar dicho lenguaje; para ello, necesitamos importar el DSL y definir el Transactor de base de datos, estas operaciones, se realizan de la siguiente forma:

import doobie.Transactor
import doobie.util.ExecutionContexts
import es.ams.freemonaddoobie.AuthorDSL._
[...]
implicit val cs = IO.contextShift(ExecutionContexts.synchronous)
val xa = Transactor.fromDriverManager[IO](
  "com.mysql.jdbc.Driver",
  "jdbc:mysql://localhost:3306/doobie",
  "root",
  "root",
  Blocker.liftExecutionContext(ExecutionContexts.synchronous) // just for testing
)

Para la correcta ejecución del programa es necesario tener una base de datos levantada en la configuración que se especifica en el Transactor; en mi caso, he utilizado un contenedor Docker con una base de datos MySQL.

La definición de un programa está compuesta por la secuencia de funciones del DSL definidas en un for comprehension y, para su ejecución, se utiliza  la función foldMap con el intérprete definido. Al utilizar la mónada estado, es necesario arrancar la ejecución con un estado.

En los siguientes apartados, se describen ejemplos de las declaraciones de programas del DSL definido

1.- Ejemplo de programa de creación de un esquema.

def createDatabase(): Operation[Either[Exception, Boolean]] = for {
  result <- createSchema()
} yield (result)
val resultCreate = createDatabase().foldMap(pureInterpreter(xa)).run(Init).value
println(s"Create database=${resultCreate}")
println

La salida por consola es la siguiente:

Create database=(Created,Right(true))

2.- Ejemplo de programa de inserción de un Autor en base de datos.

def insertAuthor(): Operation[Either[Exception, Int]] = for {
  num <- insert(Author(0, "Author1"))
} yield (num)
val resultInsertAuthor = insertAuthor().foldMap(pureInterpreter(xa)).run(Created).value
println(s"Insert Author=${resultInsertAuthor}")
Println

La salida por consola es la siguiente:

Insert Author=(Created,Right(1))

3.- Ejemplo de programa de eliminación de un Autor en base de datos.

def deleteAuthor(): Operation[Either[Exception, Int]] = for {
  numInsert10 <- insert(Author(0, "Author10"))
  numInsert11 <- insert(Author(0, "Author11"))
  numInsert12 <- insert(Author(0, "Author12"))
  numDeleted <- delete(2)
} yield (numDeleted)
val resultDeleteAuthor = deleteAuthor().foldMap(pureInterpreter(xa)).run(Created).value
println(s"Delete Author=${resultDeleteAuthor}")
println

La salida por consola es la siguiente:

Delete Author=(Created,Right(1))

4.- Ejemplo de programa de consulta de un autor por clave.

def selectAuthorOK(): Operation[Either[Exception, Option[String]]] = for {
  author <- select(1)
} yield (author)
val resultSelectAuthorOK = selectAuthorOK.foldMap(pureInterpreter(xa)).run(Created).value
println(s"Select Author=${resultSelectAuthorOK}")
println

La salida por consola es la siguiente:

Select Author=(Created,Right(Some(Author1)))

Para el lector interesado puede acceder al código de la entrada en el siguiente enlace.