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 e5e393f
Show file tree
Hide file tree
Showing 4 changed files with 486 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

}
Loading

0 comments on commit e5e393f

Please sign in to comment.