Skip to content

Commit

Permalink
Issue #146 - Add HttpClient using http4s
Browse files Browse the repository at this point in the history
  • Loading branch information
kevin-lee committed Jan 20, 2021
1 parent 8801f97 commit 4aafadc
Show file tree
Hide file tree
Showing 4 changed files with 461 additions and 0 deletions.
148 changes: 148 additions & 0 deletions src/main/scala/kevinlee/http/HttpClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package kevinlee.http;

import cats.Monad
import cats.data.EitherT
import cats.effect._
import cats.syntax.all._
import effectie.cats.EffectConstructor
import effectie.cats.EitherTSupport._
import fs2.text
import io.circe.Decoder
import loggerf.cats._
import loggerf.syntax._
import org.http4s.Status.Successful
import org.http4s._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.client.Client
import org.http4s.headers._

/** @author Kevin Lee
* @since 2021-01-03
*/
trait HttpClient[F[_]] {

def request[A](
httpRequest: HttpRequest
)(
implicit entityDecoderA: Decoder[A]
): F[Either[HttpError, A]]

}

object HttpClient {

def apply[F[_]: Monad: EffectConstructor: ConcurrentEffect: Log](client: Client[F]): HttpClient[F] =
new HttpClientF[F](client)

@SuppressWarnings(Array("org.wartremover.warts.Any", "org.wartremover.warts.Nothing"))
final class HttpClientF[F[_]: Monad: EffectConstructor: ConcurrentEffect: Log](
client: Client[F]
) extends HttpClient[F] {

override def request[A](
httpRequest: HttpRequest
)(
implicit entityDecoderA: Decoder[A]
): F[Either[HttpError, A]] =
sendRequest[A](httpRequest).value

private[this] def sendRequest[A](
httpRequest: HttpRequest,
)(
implicit entityDecoderA: Decoder[A]
): EitherT[F, HttpError, A] =
for {
request <- EitherT.fromEither(
HttpRequest.toHttp4s(
httpRequest,
)
)

postProcessedReq <- eitherTRightF[HttpError](postProcessRequest(request, implicitly[EntityDecoder[F, A]].consumes))

res <-
log(
EitherT(
client
.run(postProcessedReq)
.use[F, Either[HttpError, A]](responseHandler(httpRequest))
)
// .leftFlatMap(err => EitherT(effectOf(HttpError.recoverFromOptional404[A](err))))
)(
err => error(err.show),
res => info(String.valueOf(res)),
)
} yield res

private[this] def postProcessRequest(request: F[Request[F]], mediaRanges: Set[MediaRange]): F[Request[F]] =
request.map { req =>
val mediaRangeList = mediaRanges.toList
mediaRangeList.headOption.fold(req) { head =>
req.putHeaders(
Accept(MediaRangeAndQValue(head), mediaRangeList.drop(1).map(MediaRangeAndQValue(_)): _*)
)
}
}

private[this] def responseHandler[A](
httpRequest: HttpRequest
)(
implicit decoderA: Decoder[A],
): Response[F] => F[Either[HttpError, A]] = {
case Successful(successResponse) =>
implicitly[EntityDecoder[F, A]]
.decode(successResponse, strict = false)
.leftMap(failure =>
HttpError.responseBodyDecodingFailure(
failure.message,
failure.cause,
)
)
.value

case failedResponse =>
val fOfBody: F[Option[String]] =
(
if (failedResponse.status.isEntityAllowed) {
failedResponse
.body
.through(text.utf8Decode)
.through(text.lines)
.compile[F, F, String]
.string
.some
} else {
none[F[String]]
}
).sequence
fOfBody.map { maybeBody =>
val httpResponse = HttpResponse(
HttpResponse.Status(
HttpResponse.Status.Code(failedResponse.status.code),
HttpResponse.Status.Reason(failedResponse.status.reason),
),
HttpResponse.fromHttp4sHeaders(failedResponse.headers),
maybeBody.map(HttpResponse.Body.apply),
)
failedResponse.status.code match {
case Status.BadRequest.code =>
HttpError.badRequest(httpRequest, httpResponse).asLeft[A]

case Status.RequestTimeout.code =>
HttpError.requestTimeout(httpResponse).asLeft[A]

case Status.InternalServerError.code =>
HttpError.internalServerError(httpResponse).asLeft[A]

case Status.UnprocessableEntity.code =>
HttpError.unprocessableEntity(httpRequest, httpResponse).asLeft[A]

case _ =>
HttpError.failedResponse(httpResponse).asLeft[A]
}

}
}

}
}
79 changes: 79 additions & 0 deletions src/main/scala/kevinlee/http/HttpError.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package kevinlee.http

import cats.Show
import cats.syntax.all._

/** @author Kevin Lee
* @since 2021-01-03
*/
sealed trait HttpError

object HttpError {
final case class InvalidUri(
uriString: String,
errorMessage: String,
) extends HttpError
final case class ResponseBodyDecodingFailure(
message: String,
cause: Option[Throwable],
) extends HttpError
final case class FailedResponse(
httpResponse: HttpResponse
) extends HttpError
final case class UnhandledThrowable(
throwable: Throwable
) extends HttpError
final case class BadRequest(
httpRequest: HttpRequest,
httpResponse: HttpResponse,
) extends HttpError
final case class InternalServerError(
httpResponse: HttpResponse
) extends HttpError
final case class RequestTimeout(
httpResponse: HttpResponse
) extends HttpError
final case class Forbidden(
httpRequest: HttpRequest,
httpResponse: HttpResponse,
) extends HttpError
final case class UnprocessableEntity(
httpRequest: HttpRequest,
httpResponse: HttpResponse,
) extends HttpError

def invalidUri(uriString: String, errorMessage: String): HttpError = InvalidUri(uriString, errorMessage)

def responseBodyDecodingFailure(message: String, cause: Option[Throwable]): HttpError =
ResponseBodyDecodingFailure(message, cause)

def failedResponse(httpResponse: HttpResponse): HttpError = FailedResponse(httpResponse)

def unhandledThrowable(throwable: Throwable): HttpError = UnhandledThrowable(throwable)

def badRequest(httpRequest: HttpRequest, httpResponse: HttpResponse): HttpError =
BadRequest(httpRequest, httpResponse)

def internalServerError(httpResponse: HttpResponse): HttpError =
InternalServerError(httpResponse)

def requestTimeout(httpResponse: HttpResponse): HttpError = RequestTimeout(httpResponse)

def recoverFromOptional404[A](httpError: HttpError): Either[HttpError, Option[A]] = httpError match {
case HttpError.FailedResponse(
HttpResponse(HttpResponse.Status(HttpResponse.Status.Code(404), _), _, _)
) =>
none[A].asRight[HttpError]
case error =>
error.asLeft[Option[A]]
}

def forbidden(httpRequest: HttpRequest, httpResponse: HttpResponse): HttpError = Forbidden(httpRequest, httpResponse)

def unprocessableEntity(httpRequest: HttpRequest, httpResponse: HttpResponse): HttpError =
UnprocessableEntity(httpRequest, httpResponse)

@SuppressWarnings(Array("org.wartremover.warts.ToString"))
implicit final val show: Show[HttpError] = _.toString

}
164 changes: 164 additions & 0 deletions src/main/scala/kevinlee/http/HttpRequest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package kevinlee.http;

import cats.{Applicative, Show}
import cats.syntax.all._
import io.circe.Json
import io.estatico.newtype.macros._
import HttpRequest.Method.{Delete, Get, Patch, Post, Put}
import org.http4s.{Request, Header => Http4sHeader, Uri => Http4sUri}

/** @author Kevin Lee
* @since 2021-01-03
*/
final case class HttpRequest(
httpMethod: HttpRequest.Method,
uri: HttpRequest.Uri,
headers: List[HttpRequest.Header],
params: List[HttpRequest.Param],
body: Option[Json],
)

@SuppressWarnings(Array("org.wartremover.warts.ImplicitConversion", "org.wartremover.warts.ImplicitParameter"))
object HttpRequest {
sealed trait Method

object Method {
case object Get extends Method
case object Post extends Method
case object Put extends Method
case object Patch extends Method
case object Delete extends Method

def get: Method = Get
def post: Method = Post
def put: Method = Put
def patch: Method = Patch
def delete: Method = Delete

def render(method: Method): String = method match {
case Get => "GET"
case Post => "POST"
case Put => "PUT"
case Patch => "PATCH"
case Delete => "DELETE"
}

implicit final val show: Show[Method] = render
}

implicit final val show: Show[HttpRequest] = { httpRequest =>
val headerString = httpRequest.headers.map { header =>
val (name, value) = header.header
val nameInLower = name.toLowerCase
if (nameInLower.contains("auth") || nameInLower.contains("password"))
s"($name: ***Protected***)"
else
s"($name: $value)"
}.mkString("[", ", ", "]")
val paramsString = httpRequest.params.map { param =>
val (name, value) = param.param
val nameInLower = name.toLowerCase
if (nameInLower.contains("auth") || nameInLower.contains("password"))
s"($name: ***Protected***)"
else
s"($name: $value)"
}.mkString("[", ", ", "]")
val bodyString = httpRequest.body.fold("")(_.spaces2)
s"HttpRequest(method=${httpRequest.httpMethod.show}, url=${httpRequest.uri.uri}, headers=$headerString, params=$paramsString, body=$bodyString)"
}

import org.http4s.circe.CirceEntityCodec._
import org.http4s.client.dsl.Http4sClientDsl._
import org.http4s.dsl.request._

@SuppressWarnings(Array("org.wartremover.warts.Any", "org.wartremover.warts.Nothing"))
def toHttp4s[F[_]: Applicative](
httpRequest: HttpRequest,
): Either[HttpError, F[Request[F]]] =
httpRequest.uri.toHttp4s.map { uri =>
val http4sHeaders = httpRequest.headers.map(_.toHttp4s)
httpRequest.httpMethod match {
case Get =>
httpRequest
.body
.fold(GET.apply(uri, http4sHeaders: _*))(
GET.apply(
_,
uri,
http4sHeaders: _*
)
)
case Post =>
httpRequest
.body
.fold(POST.apply(uri, http4sHeaders: _*))(
POST.apply(
_,
uri,
http4sHeaders: _*
)
)
case Put =>
httpRequest
.body
.fold(PUT.apply(uri, http4sHeaders: _*))(
PUT.apply(
_,
uri,
http4sHeaders: _*
)
)
case Patch =>
httpRequest
.body
.fold(PATCH.apply(uri, http4sHeaders: _*))(
PATCH.apply(
_,
uri,
http4sHeaders: _*
)
)
case Delete =>
httpRequest
.body
.fold(DELETE.apply(uri, http4sHeaders: _*))(
DELETE.apply(
_,
uri,
http4sHeaders: _*
)
)
}
}

@newtype case class Uri(uri: String) {
def toHttp4s: Either[HttpError, Http4sUri] =
Http4sUri
.fromString(uri)
.leftMap(parseFailure => HttpError.invalidUri(uri, parseFailure.message))
}

@newtype case class Header(header: (String, String)) {
def toHttp4s: Http4sHeader = Http4sHeader(header._1, header._2)
}

@newtype case class Param(param: (String, String))

implicit final class HttpRequestOps(val httpRequest: HttpRequest) extends AnyVal {
def withHeader(header: Header): HttpRequest =
httpRequest.copy(headers = httpRequest.headers :+ header)
}

def withParams(httpMethod: Method, uri: Uri, params: List[Param]): HttpRequest =
HttpRequest(httpMethod, uri, List.empty[Header], params, none[Json])

def withBody(httpMethod: Method, uri: Uri, body: Json): HttpRequest =
HttpRequest(httpMethod, uri, List.empty[Header], List.empty[Param], body.some)

def withHeadersAndBody(httpMethod: Method, uri: Uri, headers: List[Header], body: Json): HttpRequest =
HttpRequest(httpMethod, uri, headers, List.empty[Param], body.some)

def withoutBody(httpMethod: Method, uri: Uri): HttpRequest =
HttpRequest(httpMethod, uri, List.empty[Header], List.empty[Param], none[Json])

}
Loading

0 comments on commit 4aafadc

Please sign in to comment.