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

io.github.paoloboni.http.HttpClient.scala Maven / Gradle / Ivy

/*
 * Copyright (c) 2023 Paolo Boni
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
 * this software and associated documentation files (the "Software"), to deal in
 * the Software without restriction, including without limitation the rights to
 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
 * the Software, and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

package io.github.paoloboni.http

import cats.effect.kernel.Async
import cats.effect.std.Queue
import cats.effect.syntax.all._
import cats.syntax.all._
import fs2.{Pipe, Stream}
import io.circe.Decoder
import io.circe.parser.decode
import io.github.paoloboni.http.ratelimit._
import org.typelevel.log4cats.Logger
import sttp.capabilities
import sttp.capabilities.fs2.Fs2Streams
import sttp.client3.{BodySerializer, SttpBackend, _}
import sttp.model.Uri
import sttp.ws.WebSocketFrame

import scala.concurrent.duration.{Duration, DurationInt}

sealed class HttpClient[F[_]: Logger](requestTimeout: Duration)(implicit
    F: Async[F],
    client: SttpBackend[F, Any with Fs2Streams[F] with capabilities.WebSockets]
) {

  def get[RESPONSE](
      uri: Uri,
      responseAs: ResponseAs[RESPONSE, Any],
      limiters: List[RateLimiter[F]],
      headers: Map[String, String] = Map.empty,
      weight: Int = 1
  ): F[RESPONSE] = {
    val httpRequest = basicRequest
      .headers(headers)
      .get(uri)
      .response(responseAs)
    sendRequest(httpRequest, limiters, weight)
  }

  def post[REQUEST: BodySerializer, RESPONSE](
      uri: Uri,
      requestBody: Option[REQUEST],
      responseAs: ResponseAs[RESPONSE, Any],
      limiters: List[RateLimiter[F]],
      headers: Map[String, String] = Map.empty,
      weight: Int = 1
  ): F[RESPONSE] = {
    val preparedRequest = basicRequest
      .headers(headers)
      .post(uri)
      .response(responseAs)
    val httpRequest = requestBody.fold(preparedRequest)(preparedRequest.body(_))
    sendRequest(httpRequest, limiters, weight)
  }

  def delete[RESPONSE](
      uri: Uri,
      responseAs: ResponseAs[RESPONSE, Any],
      limiters: List[RateLimiter[F]],
      headers: Map[String, String] = Map.empty,
      weight: Int = 1
  ): F[RESPONSE] = {
    val httpRequest = basicRequest
      .headers(headers)
      .delete(uri)
      .response(responseAs)
    sendRequest(httpRequest, limiters, weight)
  }

  private sealed trait Control
  private case object Init                       extends Control
  private case class PartialFrame(value: String) extends Control
  private case class FullFrame(value: String)    extends Control
  private case object Stop                       extends Control

  def ws[DataFrame: Decoder](
      uri: Uri
  ): Stream[F, DataFrame] = {
    def webSocketFramePipe(
        q: Queue[F, Option[Either[Throwable, DataFrame]]]
    ): Pipe[F, WebSocketFrame.Data[_], WebSocketFrame] = { input =>
      input
        .scan[Control](Init) {
          case (PartialFrame(previous), WebSocketFrame.Text(payload, false, _)) => PartialFrame(previous + payload)
          case (_, WebSocketFrame.Text(payload, false, _))                      => PartialFrame(payload)
          case (PartialFrame(previous), WebSocketFrame.Text(payload, true, _))  => FullFrame(previous + payload)
          case (_, WebSocketFrame.Text(payload, true, _))                       => FullFrame(payload)
          case (_, _)                                                           => Stop
        }
        .collect {
          case fullFrame: FullFrame => fullFrame
          case Stop                 => Stop
        }
        .evalMapFilter[F, WebSocketFrame] {
          case FullFrame(payload) =>
            (decode[DataFrame](payload) match {
              case Left(ex) =>
                Logger[F].error(ex)("Failed to decode frame: " + payload) *> q.offer(Some(Left(ex))) // stopping
              case Right(decoded) =>
                q.offer(Some(Right(decoded)))
            }) *> F.pure(None)
          case _ =>
            q.offer(None).map(_ => None) // stopping
        }
    }

    Stream
      .resource {
        for {
          _     <- Logger[F].debug("ws connecting to: " + uri.toString()).toResource
          queue <- Queue.unbounded[F, Option[Either[Throwable, DataFrame]]].toResource
          _ <- basicRequest
            .get(uri)
            .readTimeout(requestTimeout)
            .response(asWebSocketStreamAlways(Fs2Streams[F])(webSocketFramePipe(queue)))
            .send(client)
            .flatMap { response =>
              Logger[F].debug("response: " + response)
            }
            .onError { case err => queue.offer(err.asLeft.some) }
            .background
        } yield queue
      }
      .flatMap(Stream.fromQueueNoneTerminated(_))
      .rethrow
  }

  private def sendRequest[RESPONSE](
      request: RequestT[Identity, RESPONSE, Any],
      limiters: List[RateLimiter[F]],
      weight: Int
  ): F[RESPONSE] = {
    val processRequest = F.defer(for {
      _           <- Logger[F].debug(s"${request.method} ${request.uri}")
      rawResponse <- request.readTimeout(requestTimeout).send(client)
    } yield rawResponse.body)
    limiters.foldLeft(processRequest) { case (response, limiter) =>
      limiter.await(response, weight = weight)
    }
  }
}

object HttpClient {
  def make[F[_]: Async: Logger](requestTimeout: Duration = 5.seconds)(implicit
      client: SttpBackend[F, Any with Fs2Streams[F] with capabilities.WebSockets]
  ): F[HttpClient[F]] =
    new HttpClient[F](requestTimeout).pure[F]
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy