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

com.permutive.pubsub.producer.http.internal.DefaultHttpPublisher.scala Maven / Gradle / Ivy

package com.permutive.pubsub.producer.http.internal

import java.util.Base64

import alleycats.syntax.foldable._
import cats.effect._
import cats.instances.list._
import cats.syntax.all._
import cats.{Applicative, Foldable, Traverse}
import com.github.plokhotnyuk.jsoniter_scala.core._
import com.github.plokhotnyuk.jsoniter_scala.macros._
import com.permutive.pubsub.http.oauth.{AccessToken, DefaultTokenProvider}
import com.permutive.pubsub.http.util.RefreshableEffect
import com.permutive.pubsub.producer.Model.MessageId
import com.permutive.pubsub.producer.encoder.MessageEncoder
import com.permutive.pubsub.producer.http.PubsubHttpProducerConfig
import com.permutive.pubsub.producer.{Model, PubsubProducer}
import io.chrisdavenport.log4cats.Logger
import org.http4s.Method._
import org.http4s.Uri._
import org.http4s._
import org.http4s.client._
import org.http4s.client.dsl.Http4sClientDsl
import org.http4s.headers._

import scala.util.control.NoStackTrace

private[http] class DefaultHttpPublisher[F[_], A: MessageEncoder] private (
  baseApiUrl: Uri,
  topic: Model.Topic,
  client: Client[F],
  tokenF: F[AccessToken],
)(
  implicit F: Async[F]
) extends PubsubProducer[F, A]
    with Http4sClientDsl[F] {
  import DefaultHttpPublisher._

  final private[this] val publishRoute = baseApiUrl.copy(path = baseApiUrl.path.concat(":publish"))

  final override def produce(record: A, metadata: Map[String, String], uniqueId: String): F[MessageId] =
    produceMany[List](List(Model.SimpleRecord(record, metadata, uniqueId))).map(_.head)

  final override def produceMany[G[_]: Traverse](records: G[Model.Record[A]]): F[List[MessageId]] =
    for {
      msgs <- records.traverse(recordToMessage)
      json <- F.delay(writeToArray(MessageBundle(msgs)))
      resp <- sendHttpRequest(json)
    } yield resp

  private def sendHttpRequest(json: Array[Byte]): F[List[MessageId]] =
    for {
      token <- tokenF
      req <- POST(
        json,
        publishRoute.withQueryParam("access_token", token.accessToken),
        `Content-Type`(MediaType.application.json)
      )
      resp <- client.expectOr[Array[Byte]](req)(onError)
      resp <- F.delay(readFromArray[MessageIds](resp))
    } yield resp.messageIds

  @inline
  private def recordToMessage(record: Model.Record[A]): F[Message] =
    F.fromEither(
      MessageEncoder[A]
        .encode(record.value)
        .map(toMessage(_, record.uniqueId, record.metadata))
    )

  @inline
  private def toMessage(bytes: Array[Byte], uniqueId: String, attributes: Map[String, String]): Message =
    Message(
      data = Base64.getEncoder.encodeToString(bytes),
      messageId = uniqueId,
      attributes = attributes
    )

  @inline
  private def onError(resp: Response[F]): F[Throwable] =
    resp.as[String].map(FailedRequestToPubsub.apply)
}

private[http] object DefaultHttpPublisher {

  def resource[F[_]: Concurrent: Timer: Logger, A: MessageEncoder](
    projectId: Model.ProjectId,
    topic: Model.Topic,
    serviceAccountPath: String,
    config: PubsubHttpProducerConfig[F],
    httpClient: Client[F]
  ): Resource[F, PubsubProducer[F, A]] =
    for {
      tokenProvider <- Resource.liftF(
        if (config.isEmulator) DefaultTokenProvider.noAuth.pure[F]
        else DefaultTokenProvider.google(serviceAccountPath, httpClient)
      )
      accessTokenRefEffect <- RefreshableEffect.createRetryResource(
        refresh = tokenProvider.accessToken,
        refreshInterval = config.oauthTokenRefreshInterval,
        onRefreshSuccess = config.onTokenRefreshSuccess.getOrElse(Applicative[F].unit),
        onRefreshError = config.onTokenRefreshError,
        retryDelay = config.oauthTokenFailureRetryDelay,
        retryNextDelay = config.oauthTokenFailureRetryNextDelay,
        retryMaxAttempts = config.oauthTokenFailureRetryMaxAttempts,
        onRetriesExhausted = config.onTokenRetriesExhausted,
      )
    } yield new DefaultHttpPublisher[F, A](
      baseApiUrl = createBaseApiUri(projectId, topic, config),
      topic = topic,
      client = httpClient,
      tokenF = accessTokenRefEffect.value
    )

  def createBaseApiUri[F[_]](
    projectId: Model.ProjectId,
    topic: Model.Topic,
    config: PubsubHttpProducerConfig[F]
  ): Uri =
    Uri(
      scheme = Option(if (config.port == 443) Uri.Scheme.https else Uri.Scheme.http),
      authority = Option(Uri.Authority(host = RegName(config.host), port = Option(config.port))),
      path = s"/v1/projects/${projectId.value}/topics/${topic.value}"
    )

  case class Message(
    data: String,
    messageId: String,
    attributes: Map[String, String]
  )

  case class MessageBundle[G[_]](
    messages: G[Message]
  )

  case class MessageIds(
    messageIds: List[MessageId]
  )

  implicit final def foldableMessagesCodec[G[_]](implicit G: Foldable[G]): JsonValueCodec[G[Message]] =
    new JsonValueCodec[G[Message]] {
      override def decodeValue(in: JsonReader, default: G[Message]): G[Message] = ???

      override def encodeValue(x: G[Message], out: JsonWriter): Unit = {
        out.writeArrayStart()
        x.foreach(MessageCodec.encodeValue(_, out))
        out.writeArrayEnd()
      }

      override def nullValue: G[Message] = ???
    }

  implicit final val MessageCodec: JsonValueCodec[Message] =
    JsonCodecMaker.make[Message](CodecMakerConfig)

  implicit final def messageBundleCodec[G[_]: Foldable]: JsonValueCodec[MessageBundle[G]] =
    JsonCodecMaker.make[MessageBundle[G]](CodecMakerConfig)

  implicit final val MessageIdsCodec: JsonValueCodec[MessageIds] =
    JsonCodecMaker.make[MessageIds](CodecMakerConfig)

  case class FailedRequestToPubsub(response: String)
      extends Throwable(s"Failed request to pubsub. Response was: $response")
      with NoStackTrace
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy