Low level DSL

Imagine you were already using free monads for desiginng your application:

As always, start with some imports

import cats._
import cats.free._
import cats.data._
import cats.effect.Sync

Algebras

Then, we need to start defining our algebras. Here’s is the algebra related to logging

Log

object Log {
  sealed trait LogF[A]
  case class Info(msg: String) extends LogF[Unit]
  case class Error(msg: String) extends LogF[Unit]

  class LogC[F[_]](implicit I: InjectK[LogF, F]) {
    def info(msg: String): Free[F, Unit] = Free.inject(Info(msg))
    def error(msg: String): Free[F, Unit] = Free.inject(Error(msg))
  }

  object LogC {
    implicit def logC[F[_]](implicit I: InjectK[LogF, F]): LogC[F] = new LogC[F]
  }

  def interp[F[_]: Sync]: LogF ~> F = new (LogF ~> F) {
    def apply[A](logF: LogF[A]): F[A] =  logF match {
      case Info(msg) => Sync[F].delay(println(s"[info]: $msg"))
      case Error(msg) => Sync[F].delay(println(s"[error]: $msg"))
    }
  }
}
// defined object Log

Console IO

And this one does IO.

object IOEff {

sealed trait IOF[A]
  case object Read extends IOF[String]
  case class Write(msg: String) extends IOF[Unit]

  class IOC[F[_]](implicit I: InjectK[IOF, F]) {
    def read: Free[F, String] = Free.inject(Read)
    def write(str: String): Free[F, Unit] = Free.inject(Write(str))
  }

  object IOC {
    implicit def ioC[F[_]](implicit I: InjectK[IOF, F]): IOC[F] = new IOC[F]
  }

  def interp[F[_]: Sync]: IOF ~> F = new (IOF ~> F) {
	// this could be implemented using scala.io.StdIn, for example
	def readline: String = "line read!"

    def apply[A](ioF: IOF[A]): F[A] = ioF match {
      case Read => Sync[F].delay(readline)
      case Write(msg) => Sync[F].delay(println(s"$msg"))
    }
  }
}

Combining our effects

And finally, we should need to build everything together. For that purpose, we will need a EitherK. This datatype basically tells the typesystem about our effects, saying that our Eff type can be either a Log or a IO value.

object App {
  import IOEff._
  import Log._

  type Eff[A] = EitherK[LogF, IOF, A]

  val name = "pepegar"

  def program(implicit Log: LogC[Eff], IO: IOC[Eff]) = for {
    // this dinamically generated documentation, we cannot ask for input, but we should do `name <- IO.read`
    _ <- Log.info(s"name was $name")
    _ <- IO.write(s"hello $name")
  } yield name

  def interp[F[_]: Sync]: Eff ~> F = Log.interp or IOEff.interp
}

You could use this as follows:

scala> import cats.effect.IO
import cats.effect.IO

scala> App.program foldMap App.interp[IO]
res0: cats.effect.IO[String] = IO$1306689539

Interleaving Hammock in a Free program

Normally, extending this kind of programs is a bit cumbersome because you need to write all the boilerplate to embed a library into a free-based architecture, and then use it yourself. However, with Hammock, you can import hammock.free._ and enjoy:

object App {
  import hammock.Uri
  import IOEff._
  import Log._
  import cats._
  import cats.effect.IO
  import hammock.free.algebra._
  import hammock.jvm.free._

  type Eff1[A] = EitherK[LogF, IOF, A]
  type Eff[A] = EitherK[HttpRequestF, Eff1, A]

  def program(implicit
    Log: LogC[Eff],
    IO: IOC[Eff],
    Hammock: HttpRequestC[Eff]
  ) = for {
    _ <- IO.write("What's the ID?")
    id = "4" // for the sake of docs, lets hardcode this... It should be `id <- IO.read`
    _ <- Log.info(s"id was $id")
    response <- Hammock.get(Uri.unsafeParse(s"https://jsonplaceholder.typicode.com/users?id=${id.toString}"), Map())
  } yield response

  def interp1[F[_]: Sync]: Eff1 ~> F = Log.interp or IOEff.interp
  def interp[F[_]: Sync]: Eff ~> F = Interpreter[F].trans or interp1 // interpret Hammock's effects
}

Result

scala> val result = App.program foldMap App.interp[IO]
result: cats.effect.IO[hammock.HttpResponse] = IO$705188614

scala> result.unsafeRunSync
What's the ID?
[info]: id was 4
res1: hammock.HttpResponse = HttpResponse(Status(200,OK,OK),Map(Vary -> Accept-Encoding, Transfer-Encoding -> chunked, Server -> cloudflare-nginx, Access-Control-Allow-Credentials -> true, Etag -> W/"23f-dNckueF2Qy9kPGGjhoIfeuBW8rg", Expires -> Sun, 12 Nov 2017 05:07:18 GMT, Connection -> keep-alive, CF-Cache-Status -> HIT, Cache-Control -> public, max-age=14400, X-Content-Type-Options -> nosniff, Via -> 1.1 vegur, Content-Type -> application/json; charset=utf-8, Pragma -> no-cache, Date -> Sun, 12 Nov 2017 01:07:18 GMT, CF-RAY -> 3bc597f7e8676254-LIS, Set-Cookie -> __cfduid=d77157dca685f8a1af7d31690568e74ee1510448838; expires=Mon, 12-Nov-18 01:07:18 GMT; path=/; domain=.typicode.com; HttpOnly; Secure, X-Powered-By -> Express),[  {    "id": 4,    "name": "Patri...

High level DSL

This package provides a high level DSL to use Hammock without the hassle of dealing with Free monads, interpreters, and so on.

Of course, you’re still able to use lo level API if needed to create your requests.

Example of use

import hammock._
// import hammock._

import hammock.jvm.free.Interpreter
// import hammock.jvm.free.Interpreter

import hammock.hi._
// import hammock.hi._

import hammock.hi.dsl._
// import hammock.hi.dsl._

import cats._
// import cats._

import cats.implicits._
// import cats.implicits._

import cats.effect.IO
// import cats.effect.IO

implicit val interp = Interpreter[IO]
// interp: hammock.jvm.free.Interpreter[cats.effect.IO] = hammock.jvm.free.Interpreter@509c2cb7

val opts = (header("user" -> "pepegar") &> cookie(Cookie("track", "a lot")))(Opts.default)
// opts: hammock.hi.Opts = Opts(None,Map(user -> pepegar),Some(List(Cookie(track,a lot,None,None,None,None,None,None,None,None))))

val response = Hammock.getWithOpts(Uri.unsafeParse("http://httpbin.org/get"), opts).exec[IO]
// response: cats.effect.IO[hammock.HttpResponse] = IO$1940835890

Opts

The high level DSL uses a Opts datatype for describing the request. This Opts type is later compiled by the withOpts methods to the Free representation of the request.

case class Opts(
  auth: Option[Auth],
  headers: Map[String, String],
  cookies: Option[List[Cookie]])

All the combinators for manipulating the Opts type return a value of the type Opts => Opts, so you can combine directly via the andThen combinator of Function1. Also, Hammock provides a helper combinator &> for composing Opts => Opts functions.

Combinators that operate on Opts

signature description
auth(a: Auth): Opts => Opts Sets the auth field in opts
cookies_!(cookies: List[Cookie]): Opts => Opts Substitutes the current value of cookies in the given Opts by its param.
cookies(cookies: List[Cookie]): Opts => Opts Appends the given cookies to the current value of cookies.
cookie(cookie: Cookie): Opts => Opts Adds the given Cookie to the Opts value.
headers_!(headers: Map[String, String]): Opts => Opts Sets the headers.
headers(headers: Map[String, String]): Opts => Opts Appends the given headers to the former ones.
header(header: (String, String)): Opts => Opts Appends current header (a (String, String) value) to the headers map.

Here’s an example of how can you use the high level DSL:

val req = {
  auth(Auth.BasicAuth("pepegar", "p4ssw0rd")) &>
    cookie(Cookie("track", "A lot")) &>
    header("user" -> "potatoman")
}
// req: hammock.hi.Opts => hammock.hi.Opts = scala.Function1$$Lambda$2030/1276042904@584bd482

Authentication

There are a number of authentication headers already implemented in Hammock:

  • Basic auth (Auth.BasicAuth(user: String, pass: String)): Authenticate with user and password.
  • OAuth2 Bearer token (Auth.OAuth2Bearer(token: String)): Authenticate with an OAuth2 Bearer token. This is treated by many services like a user/password pair
  • OAuth2 token (Auth.OAuth2Token(token: String)): This is a not really standard bearer token. Will be treated by services as user/password.

Cookies

Cookies in Hammock are represented by the Cookie type:

@Lenses case class Cookie(
  name: String,
  value: String,
  expires: Option[Date] = None,
  maxAge: Option[Int] = None,
  domain: Option[String] = None,
  path: Option[String] = None,
  secure: Option[Boolean] = None,
  httpOnly: Option[Boolean] = None,
  sameSite: Option[SameSite] = None,
  custom: Option[Map[String, String]] = None)

The @Lenses annotation (from Monocle) provides lenses for all the fields in a case class.

As you can see most of the behaviour of the cookie can be handled by the type itself. For example, adding a MaxAge setting to a cookie is just matter of doing:

val cookie = Cookie("_ga", "werwer")
// cookie: hammock.hi.Cookie = Cookie(_ga,werwer,None,None,None,None,None,None,None,None)

Cookie.maxAge.set(Some(234))(cookie)
// res2: hammock.hi.Cookie = Cookie(_ga,werwer,None,Some(234),None,None,None,None,None,None)

Headers

Headers in the Opts type are represented by a Map[String, String]. In this field, you normally want to put all the headers that are not strictly cookies or authentication header.

Codecs

Hammock uses a typeclass Codec[A] for encoding request bodies and decoding response contents. Its signature is the following:

import hammock.CodecException

trait Codec[A] {
  def encode(a: A): String
  def decode(str: String): Either[CodecException, A]
}

Currently, this interface is implemented for circe codecs, so you can just grab hammock-circe:

libraryDependencies += "hammock" %% "hammock-circe" % "0.7.1"

And use it directly:

import hammock._
// import hammock._

import hammock.circe._
// import hammock.circe._

import hammock.circe.implicits._
// import hammock.circe.implicits._

import io.circe._
// import io.circe._

import io.circe.generic.auto._
// import io.circe.generic.auto._

case class MyClass(stringField: String, intField: Int)
// defined class MyClass

Codec[MyClass].decode("""{"stringField": "This is Hammock!", "intField": 33}""")
// res3: Either[hammock.CodecException,MyClass] = Right(MyClass(This is Hammock!,33))

Codec[MyClass].decode("this is not a valid json")
// res4: Either[hammock.CodecException,MyClass] = Left(hammock.CodecException: expected true got t (line 1, column 1))

Codec[MyClass].encode(MyClass("hello dolly", 99))
// res5: String = {"stringField":"hello dolly","intField":99}

// Also, you can use Codec's syntax as follows:

import Codec._
// import Codec._

"""{"stringField": "This is Hammock!", "intField": 33}""".decode[MyClass]
// res8: Either[hammock.CodecException,MyClass] = Right(MyClass(This is Hammock!,33))

"this is not a valid json".decode[MyClass]
// res9: Either[hammock.CodecException,MyClass] = Left(hammock.CodecException: expected true got t (line 1, column 1))

MyClass("hello dolly", 99).encode
// res10: String = {"stringField":"hello dolly","intField":99}