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

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

package harness.http.client

import harness.endpoint.error.DecodingFailure
import harness.endpoint.spec.*
import harness.endpoint.transfer.*
import harness.endpoint.types.*
import harness.endpoint.types.Types.*
import harness.web.*
import harness.zio.*
import java.net.{HttpURLConnection, URL, URLEncoder}
import scala.jdk.CollectionConverters.*
import zio.*

trait HttpClientPlatformSpecificImpl { self: HttpClientPlatformSpecific =>

  override val defaultClient: HttpClient =
    new HttpClient {

      private inline def encodeQueryParam(key: String, value: String): String =
        s"${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"

      private inline def encodeQueryParams(queryParams: List[(String, String)]): String =
        if (queryParams.isEmpty) ""
        else queryParams.map(encodeQueryParam(_, _)).mkString("?", "&", "")

      private inline def makeUrl(url: String, paths: List[String], queryParams: List[(String, String)]): Task[URL] =
        ZIO.attempt { new URL(s"$url${paths.map(p => s"/${URLEncoder.encode(p, "UTF-8")}").mkString}${encodeQueryParams(queryParams)}") }

      private inline def getConnection(url: URL): RIO[Scope, HttpURLConnection] =
        ZIO.attempt { url.openConnection() }.mapError(new RuntimeException(s"Error opening URL connection for: $url", _)).flatMap {
          case httpURLConnection: HttpURLConnection => ZIO.acquireRelease(ZIO.succeed(httpURLConnection)) { con => ZIO.attempt { con.disconnect() }.orDie }
          case tmpCon                               => ZIO.fail(new RuntimeException(s"Expected HttpURLConnection, but got: ${tmpCon.getClass.getName}"))
        }

      private inline def setMethod(con: HttpURLConnection, method: HttpMethod): Task[Unit] =
        ZIO.attempt { con.setRequestMethod(method.method) }

      private inline def setHeaders(con: HttpURLConnection, headers: Map[String, List[String]]): Task[Unit] =
        ZIO.foreachDiscard(headers.toList) {
          case (header, value :: Nil) => ZIO.attempt { con.setRequestProperty(header, value) }
          case (header, values)       => ZIO.foreachDiscard(values) { value => ZIO.attempt { con.addRequestProperty(header, value) } }
        }

      private def setBody(con: HttpURLConnection, body: OutputStream): Task[Unit] =
        body match {
          case OutputStream.Empty => ZIO.unit
          case body: OutputStream.NonEmpty =>
            ZIO.scoped {
              for {
                os: java.io.OutputStream <- ZIO.fromAutoCloseable(ZIO.attempt(con.getOutputStream))
                _ <- body match {
                  case OutputStream.Str(string)        => ZIO.attempt { os.write(string.getBytes) }
                  case OutputStream.File(path)         => ZIO.scoped { path.inputStream.flatMap { is => ZIO.attempt { is.transferTo(os) } } }
                  case OutputStream.ForwardRaw(stream) => ZIO.attempt { stream.transferTo(os) }
                }
                _ <- ZIO.attempt { os.flush() }.mapError(new RuntimeException("Error flushing request body", _))
              } yield ()
            }
        }

      private inline def getResponseParamsAndStream(con: HttpURLConnection): RIO[Scope, (HttpResponseParams, InputStream)] =
        for {
          responseCode: HttpCode <- ZIO.attempt { con.getResponseCode() }.mapBoth(new RuntimeException("Error getting response code", _), HttpCode(_))
          contentLength: Long <- ZIO.attempt { con.getContentLengthLong() }.mapError(new RuntimeException("Error getting content length", _))
          javaHeaders: java.util.Map[String, java.util.List[String]] <- ZIO.attempt { con.getHeaderFields() }
          scalaHeaders: Map[String, List[String]] = javaHeaders.asScala.toList.flatMap { case (k, v) => Option(k).map(k => (k.toLowerCase, v.asScala.toList)) }.toMap
          body: java.io.InputStream <-
            if (responseCode.is2xx) ZIO.acquireClosable { ZIO.attempt { con.getInputStream() } }
            else if (responseCode.is4xxOr5xx) ZIO.acquireClosable { ZIO.attempt { con.getErrorStream() } }
            else
              Logger.log.warning("Response is not 2xx, 4xx, or 5xx. Assuming to use 'inputStream' and not 'errorStream'") *>
                ZIO.acquireClosable { ZIO.attempt { con.getInputStream() } }
        } yield (
          HttpResponseParams(responseCode, scalaHeaders, Option.when(contentLength >= 0L)(contentLength)),
          InputStream(body),
        )

      private def readResponseBody[ET <: EndpointType.Any](
          outputBodySchema: BodyCodec[OutputBody[ET]],
          errorSchema: ErrorSchema[Error[ET]],
      )(
          requestParams: HttpRequestParams,
          responseParams: HttpResponseParams,
          body: InputStream,
      ): IO[Error[ET], Receive[OutputBody[ET]]] =
        if (responseParams.responseCode.is2xx)
          outputBodySchema.in(responseParams.contentLength.toList, body.wrapped).orDie
        else
          body.readString.orDie.flatMap { stringBody =>
            errorSchema.decode(responseParams.responseCode, stringBody) match {
              case Some(Right(error)) => ZIO.fail(error)
              case Some(Left(error))  => ZIO.die(DecodingFailure(SchemaSource.Body :: Nil, DecodingFailure.Cause.DecodeFail(error)))
              case None =>
                responseParams.responseCode match {
                  case HttpCode.`404` =>
                    ZIO.dieMessage(s"Target HTTP server does not handle: ${requestParams.paths.mkString("/", "/", "")}\n$stringBody")
                  case HttpCode.`405` =>
                    ZIO.dieMessage(s"Target HTTP server does not handle ${requestParams.method.method}: ${requestParams.paths.mkString("/", "/", "")}\n$stringBody")
                  case _ =>
                    ZIO.die(DecodingFailure(SchemaSource.Body :: Nil, DecodingFailure.Cause.DecodeFail(s"Unexpected response code: ${responseParams.responseCode}")))
                }
            }
          }

      override protected def send_internal[ET <: EndpointType.Any](
          inputBodySchema: BodyCodec[InputBody[ET]],
          outputBodySchema: BodyCodec[OutputBody[ET]],
          errorSchema: ErrorSchema[Error[ET]],
      )(
          requestParams: HttpRequestParams,
          body: Send[InputBody[ET]],
      ): ZIO[Scope, Error[ET], Receive[OutputBody[ET]]] =
        for {
          url <- makeUrl(requestParams.url, requestParams.paths, requestParams.queryParams).orDie
          _ <- Logger.log.debug(s"Sending HTTP request to: $url")
          con: HttpURLConnection <- getConnection(url).orDie
          _ <- setMethod(con, requestParams.method).orDie
          _ <- ZIO.attempt { con.setDoOutput(true) }.orDie
          _ <- setHeaders(con, requestParams.headers).orDie
          _ <- setBody(con, inputBodySchema.out(body)).orDie
          (responseParams, stream) <- getResponseParamsAndStream(con).orDie
          result <- readResponseBody[ET](outputBodySchema, errorSchema)(requestParams, responseParams, stream)
        } yield result

    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy