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

tamer.Registry.scala Maven / Gradle / Ivy

package tamer

import log.effect.LogWriter
import log.effect.zio.ZioLogWriter.log4sFromName
import sttp.client4._
import sttp.client4.upicklejson.default._
import sttp.model._
import upickle.default._
import zio._
import zio.cache._

trait Registry {
  def getOrRegisterId(subject: String, schema: Schema): Task[Int]
  def verifySchema(id: Int, schema: Schema): Task[Unit]
}

object Registry {
  private[this] final type LoadingCache[K, R] = Cache[(Backend[Task], String, Option[RegistryAuthConfig], LogWriter[Task], K, Schema), Throwable, R]

  object SttpRegistry {
    private[this] final case class SchemaString(schema: String)
    private[this] object SchemaString {
      implicit val rw: ReadWriter[SchemaString] = macroRW[SchemaString]
    }
    private[this] final case class SubjectIdVersionSchemaString(subject: String, id: Int, version: Int, schema: String)
    private[this] object SubjectIdVersionSchemaString {
      implicit val rw: ReadWriter[SubjectIdVersionSchemaString] = macroRW[SubjectIdVersionSchemaString]
    }
    private[this] final case class Id(id: Int)
    private[this] object Id {
      implicit val rw: ReadWriter[Id] = macroRW[Id]
    }

    private[this] final val schemaRegistryV1MediaType = MediaType.unsafeParse("application/vnd.schemaregistry.v1+json")
    private[this] final val schemaRegistryMediaType   = MediaType.unsafeParse("application/vnd.schemaregistry+json")
    private[this] final val request = basicRequest.headers(
      Header.accept(schemaRegistryV1MediaType, schemaRegistryMediaType, MediaType.ApplicationJson),
      Header.contentType(schemaRegistryV1MediaType)
    )

    private[this] def requestWithAuth(maybeRegistryAuth: Option[RegistryAuthConfig]) = maybeRegistryAuth match {
      case Some(RegistryAuthConfig.Basic(userInfo)) => request.header(Header.authorization("Basic", userInfo))
      case Some(RegistryAuthConfig.Bearer(token))   => request.header(Header.authorization("Bearer", token))
      case None                                     => request
    }

    private[this] final def getId(
        backend: Backend[Task],
        url: String,
        maybeRegistryAuth: Option[RegistryAuthConfig],
        log: LogWriter[Task],
        subject: String,
        schema: Schema
    ): Task[Int] =
      requestWithAuth(maybeRegistryAuth)
        .post(uri"$url/subjects/$subject?normalize=false&deleted=false")
        .body(SchemaString(schema.show))
        .response(asJson[SubjectIdVersionSchemaString])
        .send(backend)
        .flatMap(response => ZIO.fromEither(response.body.map(_.id)))
        .tap(id => log.debug(s"retrieved existing writer schema id: $id"))
    private[this] final def register(
        backend: Backend[Task],
        url: String,
        maybeRegistryAuth: Option[RegistryAuthConfig],
        log: LogWriter[Task],
        subject: String,
        schema: Schema
    ): Task[Int] =
      requestWithAuth(maybeRegistryAuth)
        .post(uri"$url/subjects/$subject/versions?normalize=false")
        .body(SchemaString(schema.show))
        .response(asJson[Id])
        .send(backend)
        .flatMap(response => ZIO.fromEither(response.body.map(_.id)))
        .tap(id => log.info(s"registered with id $id new subject $subject writer schema ${schema.show}"))
    private[this] final def get(
        backend: Backend[Task],
        url: String,
        maybeRegistryAuth: Option[RegistryAuthConfig],
        log: LogWriter[Task],
        id: Int
    ): Task[String] =
      requestWithAuth(maybeRegistryAuth)
        .get(uri"$url/schemas/ids/$id?subject=")
        .response(asJson[SchemaString])
        .send(backend)
        .flatMap(response => ZIO.fromEither(response.body.map(_.schema)))
        .tap(_ => log.debug(s"retrieved writer schema id: $id"))
    private[this] final def verify(schema: Schema, writerSchema: String): Task[Unit] =
      ZIO
        .succeed(schema.isCompatible(writerSchema))
        .filterOrElseWith(_.isEmpty) { errors =>
          ZIO.fail(TamerError(s"Backwards incompatible schema, reader: '${schema.show}' vs writer: '$writerSchema': ${errors.mkString(", ")}"))
        }
        .unit

    final def getOrRegisterId(
        backend: Backend[Task],
        url: String,
        maybeRegistryAuth: Option[RegistryAuthConfig],
        log: LogWriter[Task],
        subject: String,
        schema: Schema
    ): Task[Int] =
      getId(backend, url, maybeRegistryAuth, log, subject, schema) <> register(backend, url, maybeRegistryAuth, log, subject, schema)
    final def verifySchema(
        backend: Backend[Task],
        url: String,
        maybeRegistryAuth: Option[RegistryAuthConfig],
        log: LogWriter[Task],
        id: Int,
        schema: Schema
    ): Task[Unit] =
      get(backend, url, maybeRegistryAuth, log, id).flatMap(verify(schema, _)).unit
  }
  sealed abstract class SttpRegistry(
      backend: Backend[Task],
      url: String,
      maybeRegistryAuth: Option[RegistryAuthConfig],
      schemaToIdCache: LoadingCache[String, Int],
      schemaIdToValidationCache: LoadingCache[Int, Unit],
      log: LogWriter[Task]
  ) extends Registry {
    override final def getOrRegisterId(subject: String, schema: Schema): Task[Int] =
      schemaToIdCache.get((backend, url, maybeRegistryAuth, log, subject, schema))
    override final def verifySchema(id: Int, schema: Schema): Task[Unit] =
      schemaIdToValidationCache.get((backend, url, maybeRegistryAuth, log, id, schema))
  }

  object FakeRegistry extends Registry {
    override final def getOrRegisterId(subject: String, schema: Schema): Task[Int] = ZIO.succeed(-1)
    override final def verifySchema(id: Int, schema: Schema): Task[Unit]           = ZIO.unit
  }

  private[tamer] final val fakeRegistryZIO: RIO[Scope, Registry] = ZIO.succeed(Registry.FakeRegistry)
}

final case class RegistryProvider(from: RegistryConfig => RIO[Scope, Registry])

object RegistryProvider {
  implicit final val defaultRegistryProvider: RegistryProvider = RegistryProvider { config =>
    val sttpBackend = sttp.client4.httpclient.zio.HttpClientZioBackend.scoped()
    val schemaToIdCache = Cache.makeWithKey(config.cacheSize, Lookup((Registry.SttpRegistry.getOrRegisterId _).tupled))(
      _ => config.expiration,
      { case (_, _, _, _, subject, schema) => (subject, schema) }
    )
    val schemaIdToValidationCache = Cache.makeWithKey(config.cacheSize, Lookup((Registry.SttpRegistry.verifySchema _).tupled))(
      _ => config.expiration,
      { case (_, _, _, _, id, schema) => (id, schema) }
    )
    val log = log4sFromName.provideEnvironment(ZEnvironment("tamer.SttpRegistry"))
    (sttpBackend <*> schemaToIdCache <*> schemaIdToValidationCache <*> log)
      .mapError(TamerError("Cannot construct SttpRegistry client", _))
      .flatMap { case (backend, schemaToIdCache, schemaIdToValidationCache, log) =>
        ZIO.succeed(new Registry.SttpRegistry(backend, config.url, config.maybeRegistryAuth, schemaToIdCache, schemaIdToValidationCache, log) {}) <*
          log.info(s"SttpRegistry client created successfully")
      }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy