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._

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: Inject[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: Inject[LogF, F]): LogC[F] = new LogC[F]
  }

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

IO

And this one does IO.

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

  class IOC[F[_]](implicit I: Inject[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: Inject[IOF, F]): IOC[F] = new IOC[F]
  }

  import scala.io.StdIn._

  def interp[F[_]](implicit ME: MonadError[F, Throwable]): IOF ~> F = new (IOF ~> F) {
    def apply[A](ioF: IOF[A]): F[A] = ioF match {
      case Read => ME.catchNonFatal(readLine : String)
      case Write(msg) => ME.catchNonFatal(println(s"$msg"))
    }
  }
}

Combining our effects

And finally, we should need to build everything together. For that purpose, we will need a Coproduct. 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 IO._
  import Log._

  type Eff[A] = Coproduct[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[_]](implicit ME: MonadError[F, Throwable]): Eff ~> F = Log.interp or IO.interp
}

You could use this as follows:

scala> import cats.implicits._
import cats.implicits._

scala> import scala.util.Try
import scala.util.Try

scala> App.program foldMap App.interp[Try]
[info]: name was pepegar
hello pepegar
res0: scala.util.Try[String] = Success(pepegar)

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 IO._
  import Log._
  import cats._
  import hammock.free.algebra._
  import hammock.jvm.free._

  type Eff1[A] = Coproduct[LogF, IOF, A]
  type Eff[A] = Coproduct[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[_]](implicit ME: MonadError[F, Throwable]): Eff1 ~> F = Log.interp(ME) or IO.interp(ME)
  def interp[F[_]](implicit ME: MonadError[F, Throwable]): Eff ~> F = Interpreter().trans(ME) or interp1(ME) // interpret Hammock's effects
}

Result

scala> import scala.util.Try
import scala.util.Try

scala> import cats.implicits._
import cats.implicits._

scala> App.program foldMap App.interp[Try]
What's the ID?
[info]: id was 4
res1: scala.util.Try[hammock.HttpResponse] = Success(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 -> Thu, 13 Jul 2017 12:09:26 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 -> Thu, 13 Jul 2017 08:09:26 GMT, CF-RAY -> 37dac2921ec12f41-MAD, Set-Cookie -> __cfduid=dca5ec9ae0fa77193b6cc77d72cd004ef1499933366; expires=Fri, 13-Jul-18 08:09:26 GMT; path=/; domain=.typicode.com; HttpOnly, X-Powered-By -> Express),[  {    "id": 4,    "name": "Patricia Lebsac...

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._

implicit val interp = Interpreter()
// interp: hammock.jvm.free.Interpreter = hammock.jvm.free.Interpreter@571e1fbb

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[Try]
// response: scala.util.Try[hammock.HttpResponse] = Success(HttpResponse(Status(200,OK,OK),Map(Access-Control-Allow-Origin -> *, Server -> meinheld/0.6.1, Access-Control-Allow-Credentials -> true, X-Processed-Time -> 0.000675916671753, Connection -> keep-alive, Content-Length -> 318, Via -> 1.1 vegur, Content-Type -> application/json, Date -> Thu, 13 Jul 2017 08:09:28 GMT, X-Powered-By -> Flask),{  "args": {},   "headers": {    "Accept-Encoding": "gzip,deflate",     "Connection": "close",     "Host": "httpbin.org",     "Set-Cookie": "track=a lot",     "User": "pepegar",     "User-Agent": "Apache-HttpClient/4.5.2 (Java/1.8.0_131)"  },   "origin": "83.36.143.84",   "url": "http://httpbin.org/get"}))

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$1803/259811929@63e78f53

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.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}