diff --git a/build.sbt b/build.sbt index 288ef1d..b5cd86f 100644 --- a/build.sbt +++ b/build.sbt @@ -50,7 +50,14 @@ lazy val core = Libraries.tapirCirce, Libraries.derevoCirce, Libraries.enumeratum, - Libraries.enumeratumCirce + Libraries.enumeratumCirce, + Libraries.sttpClient, + Libraries.sttpBackendCats, + Libraries.derevoPureconfig, + Libraries.sttpBackendMonix, + Libraries.zioInterop, + Libraries.sttpCirce, + Libraries.sttpSlf4j ) ) @@ -72,7 +79,11 @@ lazy val api = Libraries.tapirDocs, Libraries.tapirOpenApi, Libraries.derevoPureconfig, - Libraries.pureconfig + Libraries.pureconfig, + Libraries.sttpClient, + Libraries.sttpBackendCats, + Libraries.sttpCirce, + Libraries.sttpSlf4j ) ) .dependsOn(core) diff --git a/config/explorer-api.conf b/config/explorer-api.conf index cd72c57..5fea5f0 100644 --- a/config/explorer-api.conf +++ b/config/explorer-api.conf @@ -1,6 +1,6 @@ http { - host = "0.0.0.0" - port = 8081 + host = "127.0.0.1" + port = 8084 } pg { diff --git a/modules/api/src/main/resources/base.conf b/modules/api/src/main/resources/base.conf index 4ea0f0c..5f124c7 100644 --- a/modules/api/src/main/resources/base.conf +++ b/modules/api/src/main/resources/base.conf @@ -1,10 +1,10 @@ http { - host = "127.0.0.1" + host = "0.0.0.0" port = 8084 } pg { - url = "jdbc:postgresql://1.1.1.1:5432/cexplorer" + url = "url" user = "user" pass = "pass" connection-timeout = 10s @@ -15,4 +15,13 @@ pg { requests { max-limit-transactions = 500 max-limit-outputs = 500 +} + +web-socket-cfg { + uri = "ws://ogmios" +} + +timeouts { + connect-timeout = 10s + timeout = 10s } \ No newline at end of file diff --git a/modules/api/src/main/resources/logback.xml b/modules/api/src/main/resources/logback.xml index c070fa1..6c0e180 100644 --- a/modules/api/src/main/resources/logback.xml +++ b/modules/api/src/main/resources/logback.xml @@ -13,13 +13,13 @@ - /var/log/cardano-explorer-backend/explorer-api.log + /Users/aleksandr/IdeaProjects/cardano-explorer-backend-scala/logs/explorer-api.log - /var/log/cardano-explorer-backend/explorer-api.%d{yyyy-MM-dd}.%i.log + /Users/aleksandr/IdeaProjects/cardano-explorer-backend-scala/logs/explorer-api.%d{yyyy-MM-dd}.%i.log diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/App.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/App.scala index 58f956f..161d1b6 100644 --- a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/App.scala +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/App.scala @@ -1,22 +1,45 @@ package io.ergolabs.cardano.explorer.api +import cats.arrow.FunctionK +import cats.data.ReaderT import cats.effect.{Blocker, Resource} +import cats.syntax.option._ +import cats.~> +import io.ergolabs.cardano.explorer.api +import io.ergolabs.cardano.explorer.api.App.{InitF, RunF} import io.ergolabs.cardano.explorer.api.configs.ConfigBundle -import io.ergolabs.cardano.explorer.api.v1.services.{Assets, Blocks, Outputs, Transactions, NetworkParamsService} +import sttp.capabilities.WebSockets +import io.ergolabs.cardano.explorer.api.v1.services.{Assets, Blocks, NetworkParamsService, Outputs, Transactions} import io.ergolabs.cardano.explorer.core.db.repositories.RepoBundle +import io.ergolabs.cardano.explorer.core.gateway.WebSocketGateway +import io.ergolabs.cardano.explorer.core.ogmios.service.OgmiosService import org.http4s.server.Server +import sttp.client3.SttpBackend import sttp.tapir.server.http4s.Http4sServerOptions import tofu.doobie.log.EmbeddableLogHandler import tofu.doobie.transactor.Txr import tofu.lift.{IsoK, Unlift} +import zio._ +import zio.interop.monix._ import tofu.logging.Logs import tofu.logging.derivation.loggable.generate import zio.interop.catz._ -import zio.{ExitCode, URIO, ZIO} +import monix.eval +import zio.{ExitCode, RIO, URIO, ZIO} object App extends EnvApp[AppContext] { implicit val serverOptions: Http4sServerOptions[RunF, RunF] = Http4sServerOptions.default[RunF, RunF] + //todo: only for debug + implicit def fromTask2I: eval.Task ~> InitF = new FunctionK[eval.Task, InitF]{ + override def apply[A](fa: eval.Task[A]): InitF[A] = ZIO.fromMonixTask(fa) + } + implicit def fromTask2F: eval.Task ~> RunF = new FunctionK[eval.Task, RunF]{ + override def apply[A](fa: eval.Task[A]): RunF[A] = new ReaderT[InitF, AppContext, A](ctx => ZIO.fromMonixTask(fa)) + } + def fromF2Task(unlift: Unlift[RunF, InitF]): RunF ~> eval.Task = new FunctionK[RunF, eval.Task]{ + override def apply[A](fa: RunF[A]): eval.Task[A] = Runtime.default.unsafeRun(unlift.lift(fa).toMonixTask) + } def run(args: List[String]): URIO[zio.ZEnv, ExitCode] = init(args.headOption).use(_ => ZIO.never).orDie @@ -27,12 +50,16 @@ object App extends EnvApp[AppContext] { configs <- Resource.eval(ConfigBundle.load[InitF](configPathOpt, blocker)) ctx = AppContext.init(configs) implicit0(ul: Unlift[RunF, InitF]) = Unlift.byIso(IsoK.byFunK(wr.runContextK(ctx))(wr.liftF)) + implicit0(rF: FunctionK[RunF, eval.Task]) = fromF2Task(ul) trans <- PostgresTransactor.make[InitF]("explorer-db-pool", configs.pg) implicit0(xa: Txr.Continuational[RunF]) = Txr.continuational[RunF](trans.mapK(wr.liftF)) implicit0(elh: EmbeddableLogHandler[xa.DB]) <- Resource.eval(doobieLogging.makeEmbeddableHandler[InitF, RunF, xa.DB]("explorer-db-logging")) implicit0(logsDb: Logs[InitF, xa.DB]) = Logs.sync[InitF, xa.DB] implicit0(txReps: RepoBundle[xa.DB]) <- Resource.eval(RepoBundle.make[InitF, xa.DB]) + implicit0(sttpBackF: SttpBackend[RunF, WebSockets]) <- makeSttpBackend[InitF, RunF](ctx, none) + implicit0(gateway: WebSocketGateway[RunF]) <- Resource.eval(WebSocketGateway.make[InitF, RunF](configs.webSocketCfg)) + implicit0(ogmios: OgmiosService[RunF]) <- Resource.eval(OgmiosService.make[InitF, RunF]) implicit0(m: NetworkParamsService[RunF]) = NetworkParamsService.make[RunF, xa.DB] implicit0(txs: Transactions[RunF]) = Transactions.make[RunF, xa.DB] implicit0(outs: Outputs[RunF]) = Outputs.make[RunF, xa.DB] diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/configs/ClientTimeouts.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/configs/ClientTimeouts.scala new file mode 100644 index 0000000..d8f455f --- /dev/null +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/configs/ClientTimeouts.scala @@ -0,0 +1,10 @@ +package io.ergolabs.cardano.explorer.api.configs + +import derevo.derive +import derevo.pureconfig.pureconfigReader +import tofu.logging.derivation.loggable + +import scala.concurrent.duration.FiniteDuration + +@derive(pureconfigReader, loggable) +final case class ClientTimeouts(connectTimeout: FiniteDuration, timeout: FiniteDuration) diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/configs/ConfigBundle.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/configs/ConfigBundle.scala index b4c3af1..e67b1d1 100644 --- a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/configs/ConfigBundle.scala +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/configs/ConfigBundle.scala @@ -2,11 +2,18 @@ package io.ergolabs.cardano.explorer.api.configs import derevo.derive import derevo.pureconfig.pureconfigReader +import io.ergolabs.cardano.explorer.core.gateway.WebSocketSettings import tofu.logging.derivation.loggable import tofu.optics.macros.{promote, ClassyOptics} @derive(loggable, pureconfigReader) @ClassyOptics -final case class ConfigBundle(@promote http: HttpConfig, @promote pg: PgConfig, @promote requests: RequestConfig) +final case class ConfigBundle( + @promote http: HttpConfig, + @promote pg: PgConfig, + @promote requests: RequestConfig, + @promote webSocketCfg: WebSocketSettings, + @promote timeouts: ClientTimeouts +) object ConfigBundle extends ConfigBundleCompanion[ConfigBundle] diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/package.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/package.scala new file mode 100644 index 0000000..bb8c91c --- /dev/null +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/package.scala @@ -0,0 +1,41 @@ +package io.ergolabs.cardano.explorer + +import cats.{~>, Applicative, Defer} +import cats.effect.{Concurrent, ContextShift, Resource} +import io.ergolabs.cardano.explorer.api.configs.ClientTimeouts +import monix.eval.Task +import org.asynchttpclient.DefaultAsyncHttpClientConfig +import sttp.capabilities.WebSockets +import sttp.client3._ +import sttp.client3.SttpBackend +import sttp.client3.asynchttpclient.monix.AsyncHttpClientMonixBackend +import sttp.client3.impl.cats.implicits._ +import sttp.client3.logging.slf4j.Slf4jLoggingBackend +import tofu.WithRun + +package object api { + + def makeSttpBackend[I[_]: Defer: Applicative, F[_]: Concurrent: ContextShift]( + ctx: AppContext, + httpClientTimeouts: Option[ClientTimeouts] = None + )(implicit + WR: WithRun[F, I, AppContext], + fromTaskI: Task ~> I, + fromTaskF: Task ~> F, + toTaskF: F ~> Task + ): Resource[I, SttpBackend[F, WebSockets]] = + AsyncHttpClientMonixBackend.resource().mapK(fromTaskI).map(_.mapK(fromTaskF, toTaskF)) + + def setupTimeouts(builder: DefaultAsyncHttpClientConfig.Builder, config: ClientTimeouts) = { + import config._ + builder + .setConnectTimeout(connectTimeout.toMillis.toInt) + .setReadTimeout(timeout.toMillis.toInt) + .setRequestTimeout( + timeout.toMillis.toInt + connectTimeout.toMillis.toInt * 2 + ) + } + + def wrapWithLogs[F[_], S](delegate: SttpBackend[F, S]): SttpBackend[F, S] = + Slf4jLoggingBackend(delegate, logRequestBody = true, logResponseBody = true) +} diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/models/EnvParams.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/models/EnvParams.scala index 9108fa3..b11f63a 100644 --- a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/models/EnvParams.scala +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/models/EnvParams.scala @@ -4,6 +4,8 @@ import derevo.circe.magnolia.{decoder, encoder} import derevo.derive import io.circe.Json import io.ergolabs.cardano.explorer.api.v1.instances._ +import io.ergolabs.cardano.explorer.core.ogmios.models.EraInfo +import io.ergolabs.cardano.explorer.core.ogmios.models.OgmiosResponseBody.EraSummaries import io.ergolabs.cardano.explorer.core.types.{Bytea, PoolId} import sttp.tapir.Schema @@ -12,7 +14,8 @@ final case class EnvParams( pparams: ProtocolParams, network: NetworkName, sysstart: SystemStart, - collateralPercent: Option[Int] + collateralPercent: Option[Int], + eraHistory: List[EraInfo] ) object EnvParams { @@ -24,4 +27,5 @@ object EnvParams { .modify(_.network)(_.description("Network Id")) .modify(_.sysstart)(_.description("System start")) .modify(_.collateralPercent)(_.description("Collateral Percent")) + .modify(_.eraHistory)(_.description("Era history")) } diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/routes/OutputsRoutes.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/routes/OutputsRoutes.scala index 7d19acd..5d85efd 100644 --- a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/routes/OutputsRoutes.scala +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/routes/OutputsRoutes.scala @@ -1,11 +1,12 @@ package io.ergolabs.cardano.explorer.api.v1.routes -import cats.effect.{Concurrent, ContextShift, Timer} +import cats.effect.{Concurrent, ContextShift, Sync, Timer} import cats.syntax.semigroupk._ import io.ergolabs.cardano.explorer.api.configs.RequestConfig import io.ergolabs.cardano.explorer.api.v1.endpoints.OutputsEndpoints import io.ergolabs.cardano.explorer.api.v1.services.Outputs import io.ergolabs.cardano.explorer.api.v1.syntax._ +import tofu.syntax.monadic._ import org.http4s.HttpRoutes import sttp.tapir.server.http4s.{Http4sServerInterpreter, Http4sServerOptions} @@ -42,7 +43,10 @@ final class OutputsRoutes[F[_]: Concurrent: ContextShift: Timer](requestConfig: def getUnspentByAddrR: HttpRoutes[F] = interpreter.toRoutes(endpoints.getUnspentByAddr) { case (addr, paging) => - service.getUnspentByAddr(addr, paging).eject + for { + test <- service.getUnspentByAddr(addr, paging) + res <- service.getUnspentByAddr(addr, paging).eject + } yield res } def getUnspentByPCredR: HttpRoutes[F] = diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/NetworkParamsService.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/NetworkParamsService.scala index 466fa4e..ea970f8 100644 --- a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/NetworkParamsService.scala +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/NetworkParamsService.scala @@ -13,6 +13,7 @@ import io.ergolabs.cardano.explorer.core.types.PoolId import tofu.Throws import tofu.syntax.raise._ import io.circe.parser +import io.ergolabs.cardano.explorer.core.ogmios.service.OgmiosService trait NetworkParamsService[F[_]] { @@ -21,29 +22,34 @@ trait NetworkParamsService[F[_]] { object NetworkParamsService { - def make[F[_], D[_]: Monad: LiftConnectionIO: Throws](implicit + def make[F[_]: Monad, D[_]: Monad: LiftConnectionIO: Throws](implicit txr: Txr[F, D], - repos: RepoBundle[D] - ): NetworkParamsService[F] = new Live[F, D](txr, repos) + repos: RepoBundle[D], + ogmios: OgmiosService[F] + ): NetworkParamsService[F] = new Live[F, D](txr, repos, ogmios) - final class Live[F[_], D[_]: Monad: Throws](txr: Txr[F, D], repos: RepoBundle[D]) extends NetworkParamsService[F] { + final class Live[F[_]: Monad, D[_]: Monad: Throws](txr: Txr[F, D], repos: RepoBundle[D], ogmios: OgmiosService[F]) + extends NetworkParamsService[F] { def getNetworkParams: F[EnvParams] = - (for { - meta <- repos.network.getMeta - epochParams <- repos.network.getLastEpochParams - costModel <- repos.network.getCostModel(epochParams.costModelId) - parsedCm <- parser.parse(costModel).toRaise - transformed <- parsedCm.as[Map[String, Map[String, Int]]].toRaise + ((for { + meta <- repos.network.getMeta + epochParams <- repos.network.getLastEpochParams + costModel <- repos.network.getCostModel(epochParams.costModelId) + parsedCm <- parser.parse(costModel).toRaise[D] + transformed <- parsedCm.as[Map[String, Map[String, Int]]].toRaise[D] cmCorrectFormat = transformed.map { case (k, v) => "PlutusScriptV1" -> v } - } yield - EnvParams( - ProtocolParams.fromEpochParams(epochParams, cmCorrectFormat), - NetworkName(meta.networkName), - SystemStart.fromExplorer(meta.startTime), - epochParams.collateralPercent + } yield (meta, epochParams, cmCorrectFormat)) ||> txr.trans).flatMap { + case (meta, epochParams, cmCorrectFormat) => + ogmios.getEraSummaries.map(eraSums => + EnvParams( + ProtocolParams.fromEpochParams(epochParams, cmCorrectFormat), + NetworkName(meta.networkName), + SystemStart.fromExplorer(meta.startTime), + epochParams.collateralPercent, + eraSums.infoList + ) ) - ) ||> txr.trans - + } } } diff --git a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/Outputs.scala b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/Outputs.scala index a541140..9d2bc67 100644 --- a/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/Outputs.scala +++ b/modules/api/src/main/scala/io/ergolabs/cardano/explorer/api/v1/services/Outputs.scala @@ -2,6 +2,7 @@ package io.ergolabs.cardano.explorer.api.v1.services import cats.Monad import cats.data.{NonEmptyList, OptionT} +import cats.effect.Sync import io.ergolabs.cardano.explorer.api.v1.models._ import io.ergolabs.cardano.explorer.core.db.models.{Output => DbOutput} import io.ergolabs.cardano.explorer.core.db.repositories.RepoBundle diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ScriptPurpose.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ScriptPurpose.scala index 16b2a3a..1ec8965 100644 --- a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ScriptPurpose.scala +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ScriptPurpose.scala @@ -9,6 +9,7 @@ import sttp.tapir.Schema sealed abstract class ScriptPurpose(override val entryName: String) extends EnumEntry object ScriptPurpose extends Enum[ScriptPurpose] { + val values = findValues case object Spend extends ScriptPurpose("spend") diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/GatewayError.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/GatewayError.scala new file mode 100644 index 0000000..1e24def --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/GatewayError.scala @@ -0,0 +1,8 @@ +package io.ergolabs.cardano.explorer.core.gateway + +sealed trait GatewayError extends Throwable + +object GatewayError { + final case class UnexpectedError(description: String) extends GatewayError + final case class GatewayResponseDecodingFailure(description: String) extends GatewayError +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/WebSocketGateway.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/WebSocketGateway.scala new file mode 100644 index 0000000..f4c7b70 --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/WebSocketGateway.scala @@ -0,0 +1,67 @@ +package io.ergolabs.cardano.explorer.core.gateway + +import cats.effect.Sync +import cats.{Apply, Functor, Monad} +import io.circe.{Decoder, Encoder, Json} +import sttp.capabilities.WebSockets +import cats.syntax.either._ +import derevo.derive +import io.ergolabs.cardano.explorer.core.gateway.GatewayError.{GatewayResponseDecodingFailure, UnexpectedError} +import sttp.client3.{ws, _} +import sttp.model.Uri +import sttp.ws.WebSocket +import tofu.Raise +import tofu.higherKind.Mid +import tofu.higherKind.derived.representableK +import tofu.logging.{Loggable, Logging, Logs} +import tofu.syntax.raise._ +import tofu.syntax.monadic._ +import tofu.syntax.logging._ +import tofu.syntax.embed._ + +@derive(representableK) +trait WebSocketGateway[F[_]] { + + def send[Req, Res](req: Req)(implicit encoder: Encoder[Req], decoder: Decoder[Res]): F[Res] +} + +object WebSocketGateway { + + def make[I[_]: Functor, F[_]: Sync: Raise[*[_], GatewayError]]( + settings: WebSocketSettings + )(implicit backend: SttpBackend[F, WebSockets], logs: Logs[I, F]): I[WebSocketGateway[F]] = + logs.forService[WebSocketGateway[F]].map { implicit logging => + (new WebSocketGatewayTracingMid[F](settings) attach (new Live[F](backend, settings): WebSocketGateway[F])) + } + + final private class Live[F[_]: Sync: Raise[*[_], GatewayError]]( + backend: SttpBackend[F, WebSockets], + settings: WebSocketSettings + ) extends WebSocketGateway[F] { + + def send[Req, Res](req: Req)(implicit encoder: Encoder[Req], decoder: Decoder[Res]): F[Res] = + basicRequest + .response(asWebSocket[F, Res](ws => handler(req, ws))) + .get(Uri.unsafeParse(settings.uri)) + .send(backend) + .flatMap(_.body.leftMap(UnexpectedError).toRaise) + + def handler[Req, Res](req: Req, ws: WebSocket[F])(implicit encoder: Encoder[Req], decoder: Decoder[Res]): F[Res] = + for { + _ <- ws.sendBinary(encoder(req).toString().getBytes()) + responseText <- ws.receiveText() + _ <- Sync[F].delay(println(s"result: ${responseText}")) + decodedRes <- decoder + .decodeJson(Json.fromString(responseText)) + .leftMap(decFailure => GatewayResponseDecodingFailure(decFailure.getMessage())) + .toRaise[F] + } yield decodedRes + } + + final private class WebSocketGatewayTracingMid[F[_]: Apply](settings: WebSocketSettings)(implicit logging: Logging[F]) + extends WebSocketGateway[Mid[F, *]] { + + def send[Req, Res](req: Req)(implicit encoder: Encoder[Req], decoder: Decoder[Res]): Mid[F, Res] = + trace"Going to send req: ${req.toString} to ${settings.uri} by web socket gateway" *> _ + } +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/WebSocketSettings.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/WebSocketSettings.scala new file mode 100644 index 0000000..f176669 --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/gateway/WebSocketSettings.scala @@ -0,0 +1,8 @@ +package io.ergolabs.cardano.explorer.core.gateway + +import derevo.derive +import derevo.pureconfig.pureconfigReader +import tofu.logging.derivation.loggable + +@derive(pureconfigReader, loggable) +final case class WebSocketSettings(uri: String) diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EpochParams.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EpochParams.scala new file mode 100644 index 0000000..28f30c0 --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EpochParams.scala @@ -0,0 +1,13 @@ +package io.ergolabs.cardano.explorer.core.ogmios.models + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import sttp.tapir.Schema + +@derive(encoder, decoder) +final case class EpochParams(epochLength: Int, slotLength: Int, safeZone: Int) + +object EpochParams { + + implicit val schema: Schema[EpochParams] = Schema.derived[EpochParams] +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EpochSlot.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EpochSlot.scala new file mode 100644 index 0000000..9f68c1b --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EpochSlot.scala @@ -0,0 +1,13 @@ +package io.ergolabs.cardano.explorer.core.ogmios.models + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import sttp.tapir.Schema + +@derive(encoder, decoder) +final case class EpochSlot(time: Int, slot: Int, epoch: Int) + +object EpochSlot { + + implicit val schema: Schema[EpochSlot] = Schema.derived[EpochSlot] +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EraInfo.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EraInfo.scala new file mode 100644 index 0000000..1142692 --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/EraInfo.scala @@ -0,0 +1,13 @@ +package io.ergolabs.cardano.explorer.core.ogmios.models + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import sttp.tapir.Schema + +@derive(encoder, decoder) +final case class EraInfo(start: EpochSlot, end: EpochSlot, parameters: EpochParams) + +object EraInfo { + + implicit val schema: Schema[EraInfo] = Schema.derived[EraInfo] +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosRequest.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosRequest.scala new file mode 100644 index 0000000..dd1f157 --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosRequest.scala @@ -0,0 +1,27 @@ +package io.ergolabs.cardano.explorer.core.ogmios.models + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import enumeratum.values.{StringCirceEnum, StringEnum, StringEnumEntry} +import io.ergolabs.cardano.explorer.core.ogmios.models.Query +import io.ergolabs.cardano.explorer.core.ogmios.models.Query.QueryName + +@derive(encoder, decoder) +final case class OgmiosRequest( + `type`: String, + version: String, + servicename: String, + methodname: String, + args: Query +) + +object OgmiosRequest { + + def makeRequest(query: QueryName): OgmiosRequest = OgmiosRequest( + "jsonwsp/request", + "1.0", + "ogmios", + "Query", + Query(query) + ) +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosResponse.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosResponse.scala new file mode 100644 index 0000000..1329429 --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosResponse.scala @@ -0,0 +1,23 @@ +package io.ergolabs.cardano.explorer.core.ogmios.models + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import io.circe.{Decoder, Encoder} +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} + +final case class OgmiosResponse[Body <: OgmiosResponseBody]( + `type`: String, + version: String, + servicename: String, + methodname: String, + result: Body +) + +object OgmiosResponse { + + implicit def encoder[Body <: OgmiosResponseBody](implicit bodyEncoder: Encoder[Body]): Encoder[OgmiosResponse[Body]] = + deriveEncoder + + implicit def decoder[Body <: OgmiosResponseBody](implicit bodyDecoder: Decoder[Body]): Decoder[OgmiosResponse[Body]] = + deriveDecoder +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosResponseBody.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosResponseBody.scala new file mode 100644 index 0000000..cedcd3e --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/OgmiosResponseBody.scala @@ -0,0 +1,16 @@ +package io.ergolabs.cardano.explorer.core.ogmios.models + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import enumeratum._ +import sttp.tapir.Schema + +sealed trait OgmiosResponseBody extends EnumEntry + +object OgmiosResponseBody extends Enum[OgmiosResponseBody] { + + @derive(encoder, decoder) + final case class EraSummaries(infoList: List[EraInfo]) extends OgmiosResponseBody + + val values = findValues +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/Query.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/Query.scala new file mode 100644 index 0000000..706b691 --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/models/Query.scala @@ -0,0 +1,20 @@ +package io.ergolabs.cardano.explorer.core.ogmios.models + +import derevo.circe.magnolia.{decoder, encoder} +import derevo.derive +import enumeratum.values.{StringCirceEnum, StringEnum, StringEnumEntry} +import io.ergolabs.cardano.explorer.core.ogmios.models.Query.QueryName + +@derive(encoder, decoder) +final case class Query(query: QueryName) + +object Query { + + sealed abstract class QueryName(val value: String) extends StringEnumEntry + + object QueryName extends StringEnum[QueryName] with StringCirceEnum[QueryName] { + case object EraSummaries extends QueryName("eraSummaries") + + val values: IndexedSeq[QueryName] = findValues + } +} diff --git a/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/service/OgmiosService.scala b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/service/OgmiosService.scala new file mode 100644 index 0000000..4e4e7be --- /dev/null +++ b/modules/core/src/main/scala/io/ergolabs/cardano/explorer/core/ogmios/service/OgmiosService.scala @@ -0,0 +1,41 @@ +package io.ergolabs.cardano.explorer.core.ogmios.service + +import cats.{Apply, Functor, Monad} +import derevo.derive +import io.ergolabs.cardano.explorer.core.gateway.WebSocketGateway +import io.ergolabs.cardano.explorer.core.ogmios.models.OgmiosResponseBody._ +import io.ergolabs.cardano.explorer.core.ogmios.models.{OgmiosRequest, OgmiosResponse} +import io.ergolabs.cardano.explorer.core.ogmios.models.OgmiosResponseBody.EraSummaries +import io.ergolabs.cardano.explorer.core.ogmios.models.Query.QueryName +import tofu.higherKind.Mid +import tofu.higherKind.derived.representableK +import tofu.logging.{Logging, Logs} +import tofu.syntax.monadic._ +import tofu.syntax.logging._ + +@derive(representableK) +trait OgmiosService[F[_]] { + + def getEraSummaries: F[EraSummaries] +} + +object OgmiosService { + + def make[I[_]: Functor, F[_]: Monad](implicit gateway: WebSocketGateway[F], logs: Logs[I, F]): I[OgmiosService[F]] = + logs.forService[OgmiosService[F]].map { implicit logging => + new OgmiosServiceTracingMid[F]() attach new Live[F](gateway) + } + + final private class Live[F[_]: Monad](gateway: WebSocketGateway[F]) extends OgmiosService[F] { + + def getEraSummaries: F[EraSummaries] = + gateway + .send[OgmiosRequest, OgmiosResponse[EraSummaries]](OgmiosRequest.makeRequest(QueryName.EraSummaries)) + .map(_.result) + } + + final private class OgmiosServiceTracingMid[F[_]: Apply](implicit logging: Logging[F]) extends OgmiosService[Mid[F, *]] { + + def getEraSummaries: Mid[F, EraSummaries] = info"Going to get era summaries from Ogmios" *> _ + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index cfa28e5..932c8fe 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -8,7 +8,9 @@ object Dependencies { val catsEffect = "2.5.3" val doobie = "0.13.4" val pureconfig = "0.14.1" + val sttp = "3.1.1" val tapir = "0.18.3" + val zioInterop = "3.4.0.0-RC1" val newtype = "0.4.3" val mouse = "0.26.2" val enumeratum = "1.7.0" @@ -44,6 +46,14 @@ object Dependencies { val pureconfig = "com.github.pureconfig" %% "pureconfig-cats-effect" % V.pureconfig + val sttpClient = "com.softwaremill.sttp.client3" %% "core" % V.sttp + val sttpBackendMonix = "com.softwaremill.sttp.client3" %% "async-http-client-backend-monix" % V.sttp + val sttpBackendCats = "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % V.sttp + val sttpCirce = "com.softwaremill.sttp.client3" %% "circe" % V.sttp + val sttpSlf4j = "com.softwaremill.sttp.client3" %% "slf4j-backend" % V.sttp + + val zioInterop = "dev.zio" % "zio-interop-monix_2.13" % V.zioInterop + val tapirCore = "com.softwaremill.sttp.tapir" %% "tapir-core" % V.tapir val tapirCirce = "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % V.tapir val tapirHttp4s = "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % V.tapir