moia-dev / scala-http-client

Extends the akka-http-client with retry logic, error handling, logging and signing

GitHub

Scala-HTTP-Client

This is a wrapper around the akka-http-client that adds

  • handling for domain errors as HTTP 400 returns
  • retry logic
  • deadlines
  • error handling
  • logging
  • AWS request signing

Build & Test Scala 2.13

Usage

libraryDependencies += "io.moia" %% "scala-http-client" % "3.1.0"
// create the client
val httpClient = new HttpClient(
  config           = HttpClientConfig("http", "127.0.0.1", 8888),
  name             = "TestClient",
  httpMetrics      = HttpMetrics.none,
  retryConfig      = RetryConfig.default,
  clock            = Clock.systemUTC(),
  awsRequestSigner = None
)
// make a request
val response: Future[HttpClientResponse] = httpClient.request(
  method   = HttpMethods.POST,
  entity   = HttpEntity.apply("Example"),
  path     = "/test",
  headers  = Seq.empty,
  deadline = Deadline.now + 10.seconds
)
// map the response to your model
response.flatMap {
  case HttpClientSuccess(content) => Unmarshal(content).to[MySuccessObject].map(Right(_))
  case DomainError(content)       => Unmarshal(content).to[DomainErrorObject].map(Left(_))
  case failure: HttpClientFailure => throw GatewayException(failure.toString)
}

See SimpleExample.scala for a complete example.

HttpClientResponses

The lib outputs the following response objects (see io.moia.scalaHttpClient.HttpClientResponse):

  • HTTP 2xx Success => HttpClientSuccess
  • HTTP 3xx Redirect => not implemented yet
  • HTTP 400 Bad Request with entity => is mapped to DomainError ⚠️
  • HTTP 400 Bad Request without entity => HttpClientError
  • HTTP 4xx, 5xx, others => HttpClientError
  • if the deadline expired => DeadlineExpired
  • if an AwsRequestSigner is given, but the request already includes an "Authorization" header => AlreadyAuthorizedException
  • weird akka-errors => ExceptionOccurred

Custom Logging

To use a custom logger (for correlation ids etc), you can use the typed LoggingHttpClient. First create a custom LoggerTakingImplicit:

import com.typesafe.scalalogging._
import org.slf4j.LoggerFactory

object CustomLogging {
  case class LoggingContext(context: String)

  implicit val canLogString: CanLog[LoggingContext] = new CanLog[LoggingContext] {
    override def logMessage(originalMsg: String, ctx: LoggingContext): String = ???
    override def afterLog(ctx: LoggingContext): Unit                          = ???
  }

  val theLogger: LoggerTakingImplicit[LoggingContext] = Logger.takingImplicit(LoggerFactory.getLogger(getClass.getName))
}

Then create a LoggingHttpClient typed to the LoggingContext:

// create the client
val httpClient = new LoggingHttpClient[LoggingContext](
  config           = HttpClientConfig("http", "127.0.0.1", 8888),
  name             = "TestClient",
  httpMetrics      = HttpMetrics.none[LoggingContext],
  retryConfig      = RetryConfig.default,
  clock            = Clock.systemUTC(),
  logger           = CustomLogging.theLogger,
  awsRequestSigner = None
)

// create an implicit logging context
implicit val ctx: LoggingContext = LoggingContext("Logging Context")

// make a request
httpClient.request(HttpMethods.POST, HttpEntity.Empty, "/test", Seq.empty, Deadline.now + 10.seconds)

The request function will use the ctx implicitly.

See LoggingExample.scala for a complete example.

Custom Headers

To use custom-defined headers, you can extend ModeledCustomHeader from akka.http.scaladsl.model.headers:

import akka.http.scaladsl.model.headers.{ModeledCustomHeader, ModeledCustomHeaderCompanion}
import scala.util.Try

final class CustomHeader(id: String) extends ModeledCustomHeader[CustomHeader] {
  override def renderInRequests(): Boolean                           = true
  override def renderInResponses(): Boolean                          = true
  override def companion: ModeledCustomHeaderCompanion[CustomHeader] = CustomHeader
  override def value(): String                                       = id
}
object CustomHeader extends ModeledCustomHeaderCompanion[CustomHeader] {
  override def name: String                            = "custom-header"
  override def parse(value: String): Try[CustomHeader] = Try(new CustomHeader(value))
}

Then simply send them in the request:

val response: Future[HttpClientResponse] = httpClient.request(
  method   = HttpMethods.POST,
  entity   = HttpEntity.apply("Example"),
  path     = "/test",
  headers  = Seq(new CustomHeader("foobar")),
  deadline = Deadline.now + 10.seconds
)

Note: If you want to access the headers from the response, you can do so from the data inside the HttpClientSuccess:

case HttpClientSuccess(content) => content.headers

See HeaderExample.scala for a complete example.

Publishing

Tag the new version (e.g. v3.0.0) and push the tags (git push origin --tags).

You need a public GPG key with your MOIA email and an account on https://oss.sonatype.org that can access the io.moia group.

Add your credentials to ~/.sbt/sonatype_credential and run

sbt:scala-http-client> +publishSigned

Then head to https://oss.sonatype.org/#stagingRepositories, select the repository, Close and then Release it.

Afterwards, add the release to GitHub.