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

pillars.httpclient.httpclient.scala Maven / Gradle / Ivy

Go to download

pillars-http-client is a scala 3 library providing http client services for writing backend applications

There is a newer version: 0.4.3
Show newest version
package pillars.httpclient

import cats.effect.Async
import cats.effect.Resource
import cats.effect.std.Console
import cats.effect.syntax.all.*
import cats.syntax.all.*
import fs2.io.file.Files
import fs2.io.net.Network
import io.circe.Codec
import io.circe.Decoder
import io.circe.Encoder
import io.circe.derivation.Configuration
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*
import org.http4s.ProductComment
import org.http4s.ProductId
import org.http4s.Request
import org.http4s.Response
import org.http4s.Uri
import org.http4s.client.Client
import org.http4s.client.middleware.FollowRedirect
import org.http4s.client.middleware.Logger
import org.http4s.headers.`User-Agent`
import org.http4s.netty.client.NettyClientBuilder
import org.typelevel.otel4s.trace.Tracer
import pillars.Logging
import pillars.Module
import pillars.Modules
import pillars.Pillars
import pillars.PillarsError
import pillars.PillarsError.*
import pillars.Run
import pillars.syntax.all.*
import sttp.tapir.AnyEndpoint
import sttp.tapir.DecodeResult
import sttp.tapir.Endpoint
import sttp.tapir.PublicEndpoint
import sttp.tapir.ValidationError
import sttp.tapir.client.http4s.Http4sClientInterpreter
import sttp.tapir.client.http4s.Http4sClientOptions

class Loader extends pillars.Loader:
    override type M[F[_]] = HttpClient[F]

    override def key: Module.Key = HttpClient.Key

    override def load[F[_]: Async: Network: Tracer: Console](
        context: pillars.Loader.Context[F],
        modules: Modules[F]
    ): Resource[F, HttpClient[F]] =
        import context.*
        given Files[F] = Files.forAsync[F]
        for
            _       <- Resource.eval(logger.info("Loading HTTP client module"))
            conf    <- Resource.eval(reader.read[HttpClient.Config]("http-client"))
            metrics <- ClientMetrics(observability).toResource
            client  <- NettyClientBuilder[F]
                           .withHttp2
                           .withNioTransport
                           .withUserAgent(conf.userAgent)
                           .resource
                           .map: client =>
                               val logging        =
                                   if conf.logging.enabled then
                                       Logger[F](
                                         logHeaders = conf.logging.headers,
                                         logBody = conf.logging.body,
                                         logAction = conf.logging.logAction
                                       )
                                   else identity[Client[F]]
                               val followRedirect =
                                   if conf.followRedirect then FollowRedirect[F](10) else identity[Client[F]]
                               client
                                   |> metrics.middleware
                                   |> logging
                                   |> followRedirect
                                   |> HttpClient(conf)
            _       <- Resource.eval(logger.info("HTTP client module loaded"))
        yield client
        end for
    end load
end Loader

final case class HttpClient[F[_]: Async](config: HttpClient.Config)(client: org.http4s.client.Client[F])
    extends pillars.Module[F], Client[F]:
    override type ModuleConfig = HttpClient.Config
    export client.*

    private val interpreter = Http4sClientInterpreter[F](Http4sClientOptions.default)

    def call[SI, I, EO, O, R](
        endpoint: PublicEndpoint[I, EO, O, R],
        uri: Option[Uri],
        handler: FailureHandler[F, EO, O] = FailureHandler.default[F, EO, O]
    )(input: I): F[Either[EO, O]] =
        callRequest(endpoint, uri)(interpreter.toRequest(endpoint, uri)(input))
    end call

    def callSecure[SI, I, EO, O, R](
        endpoint: Endpoint[SI, I, EO, O, R],
        uri: Option[Uri],
        handler: FailureHandler[F, EO, O] = FailureHandler.default[F, EO, O]
    )(securityInput: SI, input: I): F[Either[EO, O]] =
        callRequest(endpoint, uri)(interpreter.toSecureRequest(endpoint, uri)(securityInput)(input))
    end callSecure

    private def callRequest[I, EO, O](
        endpoint: AnyEndpoint,
        uri: Option[Uri],
        handler: FailureHandler[F, EO, O] = FailureHandler.default[F, EO, O]
    )(interpret: (Request[F], Response[F] => F[DecodeResult[Either[EO, O]]])) =
        val (request, parseResponse) = interpret
        client
            .run(request)
            .use(parseResponse)
            .flatMap:
                case DecodeResult.Value(v)         => v.pure[F]
                case failure: DecodeResult.Failure => handler.handle(endpoint, uri, failure)
    end callRequest

end HttpClient

def http[F[_]](using p: Pillars[F]): HttpClient[F] = p.module[HttpClient[F]](HttpClient.Key)

object HttpClient:
    case object Key extends Module.Key:
        override def name: String = "http-client"

    final case class Config(
        followRedirect: Boolean = true,
        userAgent: `User-Agent` = Config.defaultUserAgent,
        logging: Logging.HttpConfig = Logging.HttpConfig()
    ) extends pillars.Config

    object Config:
        given Configuration         = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
        given Decoder[`User-Agent`] = Decoder.decodeString.emap(s =>
            `User-Agent`.parse(10)(s).leftMap(f => s"Invalid User-Agent '$s': ${f.message}")
        )

        private def encodeUserAgent(ua: `User-Agent`): String =
            def encodeProductId(p: ProductId): String = p.version.fold(p.value)(v => s"${p.value}/$v")
            val productStr                            = encodeProductId(ua.product)
            ua.rest.map {
                case p: ProductId          => encodeProductId(p)
                case ProductComment(value) => s"($value)"
            }.prepended(productStr).mkString(" ")
        end encodeUserAgent

        given Encoder[`User-Agent`] = Encoder.encodeString.contramap(encodeUserAgent)
        given Codec[Config]         = Codec.AsObject.derivedConfigured

        private val defaultUserAgent: `User-Agent` = `User-Agent`(ProductId("pillars", None), ProductId("netty", None))
    end Config

    enum Error(endpoint: AnyEndpoint, uri: Option[Uri], val number: ErrorNumber, val message: Message)
        extends PillarsError:
        case DecodingError(endpoint: AnyEndpoint, uri: Option[Uri], raw: String, cause: Throwable) extends Error(
              endpoint,
              uri,
              ErrorNumber(1001),
              Message.assume(s"Cannot decode output $raw. Cause is $cause")
            )
        case Missing(endpoint: AnyEndpoint, uri: Option[Uri])
            extends Error(endpoint, uri, ErrorNumber(1002), Message("Missing"))
        case Multiple[R](endpoint: AnyEndpoint, uri: Option[Uri], vs: Seq[R])
            extends Error(endpoint, uri, ErrorNumber(1003), Message("Multiple response"))
        case InvalidInput(endpoint: AnyEndpoint, uri: Option[Uri], errors: List[ValidationError[?]])
            extends Error(endpoint, uri, ErrorNumber(1004), Message("Invalid input"))
        case Mismatch(endpoint: AnyEndpoint, uri: Option[Uri], expected: String, actual: String)
            extends Error(endpoint, uri, ErrorNumber(1005), Message("Type mismatch"))

        override def code: Code = Code("HTTP")

        override def details: Option[Message] =
            Message.option(s"""
              |uri: $uri
              |endpoint: $endpoint
              |""")
    end Error
end HttpClient

trait FailureHandler[F[_], EO, O]:
    def handle(endpoint: AnyEndpoint, uri: Option[Uri], failure: DecodeResult.Failure): F[Either[EO, O]]

object FailureHandler:
    def default[F[_]: Async, EO, O]: FailureHandler[F, EO, O] =
        (endpoint: AnyEndpoint, uri: Option[Uri], failure: DecodeResult.Failure) =>
            import HttpClient.Error.*
            failure match
                case DecodeResult.Error(raw, error)          =>
                    DecodingError(endpoint, uri, raw, error).raiseError[F, Either[EO, O]]
                case DecodeResult.Missing                    => Missing(endpoint, uri).raiseError[F, Either[EO, O]]
                case DecodeResult.Multiple(vs)               => Multiple(endpoint, uri, vs).raiseError[F, Either[EO, O]]
                case DecodeResult.Mismatch(expected, actual) =>
                    Mismatch(endpoint, uri, expected, actual).raiseError[F, Either[EO, O]]
                case DecodeResult.InvalidValue(errors)       =>
                    InvalidInput(endpoint, uri, errors).raiseError[F, Either[EO, O]]
            end match
end FailureHandler

private[httpclient] final case class Config(followRedirect: Boolean)

extension [I, EO, O, R](endpoint: PublicEndpoint[I, EO, O, R])
    def call[F[_]](uri: Option[Uri])(input: I): Run[F, F[Either[EO, O]]] =
        http.call(endpoint, uri)(input)

extension [SI, I, EO, O, R](endpoint: Endpoint[SI, I, EO, O, R])
    def call[F[_]](uri: Option[Uri])(securityInput: SI, input: I): Run[F, F[Either[EO, O]]] =
        http.callSecure(endpoint, uri)(securityInput, input)

//trait ClientMiddleware:
//    implicit class ClientMiddlewareOps[F[_]: Tracer: Async, A](client: Client[F]):
//        def runTraced(request: Request[F]): F[Response[F]] =
//            Tracer[F].spanBuilder("client-request")
//                .addAttribute(Attribute("http.method", request.method.name))
//                .addAttribute(Attribute("http.url", request.uri.renderString))
//                .withSpanKind(SpanKind.Client)
//                .wrapResource(client.run(request))
//                .build
//                .use:
//                    case span @ Span.Res(response) =>
//                        for
//                            _ <- span.addAttribute(Attribute("http.status-code", response.status.code.toLong))
//                            _ <- if response.status.isSuccess then
//                                span.setStatus(Status.Ok)
//                            else
//                                span.setStatus(Status.Error)
//                        yield response




© 2015 - 2025 Weber Informatics LLC | Privacy Policy