All Downloads are FREE. Search and download functionalities are using the official Maven repository.

harness.http.client.HttpResponse.scala Maven / Gradle / Ivy

package harness.http.client

import cats.syntax.option.*
import harness.core.*
import harness.http.client.error.HeaderError
import harness.web.*
import harness.zio.*
import zio.*
import zio.json.*

sealed abstract class HttpResponse[ResponseBody] private (
    final val result: HttpResponse.Result[ResponseBody],
) extends ResponseOps.Builder1[Any, ResponseBody] { self =>

  final val responseCode: HttpCode = self.result.fields.responseCode
  final val headers: Map[String, List[String]] = self.result.fields.headers
  final val contentLength: Option[Long] = self.result.fields.contentLength
  final val body: ResponseBody = self.result.fields.body

  // =====| Content Length |=====

  final def getContentLength: Task[Long] = self.result.fields.getContentLength
  final def contentLengthInt: Task[Option[Int]] = self.result.fields.contentLengthInt
  final def getContentLengthInt: Task[Int] = self.result.fields.getContentLengthInt

  // =====| Body |=====

  def bodyAsString: RIO[Logger, String]

  final def withBody[ResponseBody2](body: ResponseBody2, bodyOps: HttpResponse.BodyOps[ResponseBody2]): HttpResponse[ResponseBody2] =
    HttpResponse.Const[ResponseBody2](
      HttpResponse.Result[ResponseBody2](
        self.result.fields.withBody(body),
        bodyOps,
      ),
    )

  final def toResponseStringBody: RIO[Logger, HttpResponse[String]] =
    bodyAsString.map(self.withBody(_, HttpResponse.BodyOps.forStringBody))

  // =====| Headers |=====

  def getHeader[T](header: String)(implicit decoder: StringDecoder[T]): Task[T] =
    findHeader[T](header).someOrFail(HeaderError(header, "Missing header"))
  def findHeader[T](header: String)(implicit decoder: StringDecoder[T]): Task[Option[T]] =
    self.headers.get(header.toLowerCase) match {
      case Some(value :: Nil) => ZIO.fromEither(decoder.decode(value)).mapBoth(e => HeaderError(header, s"Error decoding: $e"), _.some)
      case Some(Nil)          => ZIO.fail(HeaderError(header, "Header is present, but has no values")) // TODO (KR) : ZIO.none ?
      case Some(values)       => ZIO.fail(HeaderError(header, s"Header is present, but has multiple values (${values.size})"))
      case None               => ZIO.none
    }

  def getJsonHeader[T: JsonDecoder](header: String): Task[T] = self.getHeader[T](header)(StringDecoder.fromJsonDecoder[T])
  def findJsonHeader[T: JsonDecoder](header: String): Task[Option[T]] = self.findHeader[T](header)(StringDecoder.fromJsonDecoder[T])

  def getHeaderValues(header: String): Task[List[String]] =
    self.headers.get(header.toLowerCase) match {
      case Some(values) => ZIO.succeed(values)
      case None         => ZIO.fail(HeaderError(header, "Missing header"))
    }

  // =====| Other |=====

  override protected def getResponse: RIO[Scope, HttpResponse[ResponseBody]] = ZIO.succeed(self)

  // =====| Show |=====

  def showHeaders: String =
    headers.map { (h, vs) => s"\n - $h:${vs.map { v => s"\n   - $v" }.mkString}" }.mkString("Headers:", "", "")

  // TODO (KR) : showSetCookies

  def show: RIO[Logger, String] =
    self.bodyAsString.map { bodyString =>
      s"""Response Code: ${self.responseCode}
         |${self.showHeaders}
         |Content Length: ${contentLength.fold("not provided")(_.toStringCommas)}
         |Body:\n$bodyString""".stripMargin
    }

  override def toString: String =
    s"""Response Code: ${self.responseCode}
       |${self.showHeaders}
       |Content Length: ${contentLength.fold("not provided")(_.toStringCommas)}""".stripMargin

}
object HttpResponse {

  final class Cached[ResponseBody] private[HttpResponse] (
      result: Result[ResponseBody],
      bodyRef: Ref.Synchronized[Option[String]],
  ) extends HttpResponse[ResponseBody](result) { self =>

    override def bodyAsString: RIO[Logger, String] =
      self.bodyRef.modifyZIO {
        case b @ Some(value) => Logger.log.debug("Already loaded body").as((value, b))
        case None            => self.result.ops.getStringBody(self.result.fields).map(b => (b, b.some))
      }

  }

  final class Const[ResponseBody] private[HttpResponse] (
      result: Result[ResponseBody],
  ) extends HttpResponse[ResponseBody](result) { self =>
    override def bodyAsString: RIO[Logger, String] = self.result.ops.getStringBody(self.result.fields)
  }

  def fromResult[ResponseBody](result: HttpResponse.Result[ResponseBody]): UIO[HttpResponse[ResponseBody]] =
    for {
      bodyRef <- Ref.Synchronized.make(Option.empty[String])
    } yield new HttpResponse.Cached(result, bodyRef)

  final case class ResultFields[ResponseBody] private (
      responseCode: HttpCode,
      headers: Map[String, List[String]],
      contentLength: Option[Long],
      body: ResponseBody,
  ) { self =>

    def withBody[ResponseBody2](body: ResponseBody2): ResultFields[ResponseBody2] = self.copy(body = body)

    def getContentLength: Task[Long] =
      ZIO.getOrFailWith(new RuntimeException("Expected to have content length, but it was not present in response"))(contentLength)

    def contentLengthInt: Task[Option[Int]] =
      ZIO.foreach(self.contentLength)(safeConvertContentLength)

    def getContentLengthInt: Task[Int] =
      self.getContentLength.flatMap(safeConvertContentLength)

  }
  object ResultFields {

    def make[ResponseBody](
        responseCode: HttpCode,
        headers: Map[String, List[String]],
        contentLength: Option[Long],
        body: ResponseBody,
    ): ResultFields[ResponseBody] =
      ResultFields(
        responseCode,
        headers.map { (k, vs) => (k.toLowerCase, vs) },
        contentLength.flatMap(cl => Option.when(cl != -1L)(cl)),
        body,
      )

  }

  final case class BodyOps[ResponseBody](
      getStringBody: ResultFields[ResponseBody] => RIO[Logger, String],
      forwardBodyToPath: (Path, ResponseBody) => Task[Long],
  )
  object BodyOps {
    val forStringBody: BodyOps[String] =
      BodyOps[String](
        fields => ZIO.succeed(fields.body),
        (path, body) => path.writeString(body).as(body.length.toLong),
      )
  }

  final case class Result[ResponseBody](
      fields: ResultFields[ResponseBody],
      ops: BodyOps[ResponseBody],
  )

  private def safeConvertContentLength(contentLength: Long): Task[Int] =
    if (contentLength <= Int.MaxValue) ZIO.succeed(contentLength.toInt)
    else ZIO.fail(new RuntimeException(s"ContentLength ($contentLength) is too large to fit inside an Int"))

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy