diff --git a/src/main/scala/kevinlee/http/HttpClient.scala b/src/main/scala/kevinlee/http/HttpClient.scala new file mode 100644 index 0000000..dda94c6 --- /dev/null +++ b/src/main/scala/kevinlee/http/HttpClient.scala @@ -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] + } + + } + } + + } +} diff --git a/src/main/scala/kevinlee/http/HttpError.scala b/src/main/scala/kevinlee/http/HttpError.scala new file mode 100644 index 0000000..9ad0b31 --- /dev/null +++ b/src/main/scala/kevinlee/http/HttpError.scala @@ -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 + +} diff --git a/src/main/scala/kevinlee/http/HttpRequest.scala b/src/main/scala/kevinlee/http/HttpRequest.scala new file mode 100644 index 0000000..1e5b215 --- /dev/null +++ b/src/main/scala/kevinlee/http/HttpRequest.scala @@ -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]) + +} diff --git a/src/main/scala/kevinlee/http/HttpResponse.scala b/src/main/scala/kevinlee/http/HttpResponse.scala new file mode 100644 index 0000000..d723b52 --- /dev/null +++ b/src/main/scala/kevinlee/http/HttpResponse.scala @@ -0,0 +1,70 @@ +package kevinlee.http + +import cats.Show +import cats.syntax.all._ +import io.estatico.newtype.macros._ +import org.http4s.Headers + +/** @author Kevin Lee + * @since 2021-01-03 + */ +final case class HttpResponse( + status: HttpResponse.Status, + headers: List[HttpResponse.Header], + body: Option[HttpResponse.Body], +) + +@SuppressWarnings(Array("org.wartremover.warts.ImplicitConversion", "org.wartremover.warts.ImplicitParameter")) +object HttpResponse { + final case class Status(code: Status.Code, reason: Status.Reason) + object Status { + @newsubtype case class Code(code: Int) + object Code { + def unapply(code: Code): Option[Int] = code.code.some + } + @newtype case class Reason(reason: String) + + implicit final val show: Show[Status] = { + case Status(code, reason) => + s"Status(${code.code}, ${reason.reason})" + } + } + + @newtype case class Header(header: (String, String)) + + implicit final class HttpResponseOps(val httpResponse: HttpResponse) extends AnyVal { + def withHeader(header: Header): HttpResponse = + httpResponse.copy(headers = httpResponse.headers :+ header) + + def findHeaderValueByName(f: String => Boolean): Option[String] = + httpResponse + .headers + .find(_.header match { + case (name, _) => + f(name) + }) + .map(_.header._2) + } + + implicit final val show: Show[HttpResponse] = { httpResponse => + val headerString = httpResponse + .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 bodyString = httpResponse.body.fold("")(_.body) + s"HttpRequest(method=${httpResponse.status.show}, headers=$headerString, body=$bodyString)" + } + + def fromHttp4sHeaders(headers: Headers): List[Header] = + headers.toList.map(header => Header(header.name.toString -> header.value)) + + @newtype case class Body(body: String) + +}