diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5a5e97..d37de0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,6 @@ jobs: steps: - uses: actions/checkout@v2 - name: Build native app - run: sbt GraalVMNativeImage/packageBin + run: sbt "project leaderboard-bifunctor-tf; GraalVMNativeImage/packageBin" - name: Check native app - run: ./target/graalvm-native-image/leaderboard :help + run: ./distage-example-bifunctor-tf/target/graalvm-native-image/leaderboard-bifunctor-tf :help \ No newline at end of file diff --git a/README.md b/README.md index 17d43a2..d77a6e8 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ Example `distage` project presented at Functional Scala 2019 Features [distage](https://izumi.7mind.io/distage/), -[Bifunctor Tagless Final](https://izumi.7mind.io/bio/), -[ZIO Environment](https://zio.dev) for composing test fixtures, and [distage-docker](https://izumi.7mind.io/distage/distage-framework-docker) for setting up test containers. +- [distage-example-bifunctor-tf](distage-example-bifunctor-tf). Written in bifunctorial way with [Bifunctor Tagless Final](https://izumi.7mind.io/bio/), using [ZIO 2](https://zio.dev) as a runtime and ZIO Environment with distage-testkit for composing test fixtures. +- [distage-example-monofunctor-tf](distage-example-monofunctor-tf). Written in monofunctorial way with [Cats Core](https://typelevel.org/cats/), using [ZIO 2](https://zio.dev) as a runtime and ZIO Environment with distage-testkit for composing test fixtures. +- [distage-example-monomorphic-cats](distage-example-monomorphic-cats). Written in monomorphic way with [Cats Effect 3](https://typelevel.org/cats-effect/) as a runtime with distage-testkit for composing test fixtures. + To launch tests that require postgres ensure you have a `docker` daemon running in the background. Use `sbt test` to launch the tests. diff --git a/build.sbt b/build.sbt index 235ef9e..d1c27f5 100644 --- a/build.sbt +++ b/build.sbt @@ -11,6 +11,7 @@ val V = new { val kindProjector = "0.13.3" val circeGeneric = "0.14.6" val graalMetadata = "0.10.1" + val catsEffect = "3.5.4" } val Deps = new { @@ -42,7 +43,29 @@ val Deps = new { val catsCore = "org.typelevel" %% "cats-core" % V.catsCore + val catsEffect = "org.typelevel" %% "cats-effect" % V.catsEffect + val graalMetadata = "org.graalvm.buildtools" % "graalvm-reachability-metadata" % V.graalMetadata + + val CoreDeps = Seq( + distageCore, + distageRoles, + distageConfig, + logstageSlf4j, + distageDocker, + distageTestkit % Test, + scalatest % Test, + scalacheck % Test, + http4sDsl, + http4sServer, + http4sClient % Test, + http4sCirce, + circeGeneric, + doobie, + doobiePostgres, + doobieHikari, + graalMetadata, + ) } inThisBuild( @@ -58,79 +81,104 @@ inThisBuild( // that's just for quick experiments with distage snapshots ThisBuild / resolvers ++= Resolver.sonatypeOssRepos("snapshots") -lazy val leaderboard = project +def makeExampleProject(moduleName: String, dir: String)(deps: Seq[ModuleID]) = + Project(moduleName, file(dir)) + .settings( + name := moduleName, + libraryDependencies ++= deps, + libraryDependencies ++= { + if (scalaVersion.value.startsWith("2")) { + Seq(compilerPlugin(Deps.kindProjector)) + } else { + Seq.empty + } + }, + scalacOptions -= "-Xfatal-warnings", + scalacOptions -= "-Ykind-projector", + scalacOptions -= "-Wnonunit-statement", + scalacOptions ++= { + if (scalaVersion.value.startsWith("2")) { + Seq( + "-Xsource:3", + "-P:kind-projector:underscore-placeholders", + "-Wmacros:after", + ) + } else { + Seq( + "-source:3.2", + "-Ykind-projector:underscores", + "-Yretain-trees", + ) + } + }, + scalacOptions ++= Seq( + s"-Xmacro-settings:product-name=${name.value}", + s"-Xmacro-settings:product-version=${version.value}", + s"-Xmacro-settings:product-group=${organization.value}", + s"-Xmacro-settings:scala-version=${scalaVersion.value}", + s"-Xmacro-settings:scala-versions=${crossScalaVersions.value.mkString(":")}", + s"-Xmacro-settings:sbt-version=${sbtVersion.value}", + s"-Xmacro-settings:git-repo-clean=${git.gitUncommittedChanges.value}", + s"-Xmacro-settings:git-branch=${git.gitCurrentBranch.value}", + s"-Xmacro-settings:git-described-version=${git.gitDescribedVersion.value.getOrElse("")}", + s"-Xmacro-settings:git-head-commit=${git.gitHeadCommit.value.getOrElse("")}", + ), + GraalVMNativeImage / mainClass := Some("leaderboard.GenericLauncher"), + graalVMNativeImageOptions ++= Seq( + "--no-fallback", + "-H:+ReportExceptionStackTraces", + "--report-unsupported-elements-at-runtime", + "--enable-https", + "--enable-http", + "-J-Xmx8G", + ), + graalVMNativeImageGraalVersion := Some("ol9-java17-22.3.1"), + run / fork := true, + ) + .dependsOn(`graal-resources`) + .enablePlugins(GraalVMNativeImagePlugin, UniversalPlugin) + +lazy val root = project .in(file(".")) - .settings( - name := "leaderboard", - libraryDependencies ++= Seq( - Deps.distageCore, - Deps.distageRoles, - Deps.distageConfig, - Deps.logstageSlf4j, - Deps.distageDocker, - Deps.distageTestkit % Test, - Deps.scalatest % Test, - Deps.scalacheck % Test, - Deps.http4sDsl, - Deps.http4sServer, - Deps.http4sClient % Test, - Deps.http4sCirce, - Deps.circeGeneric, - Deps.doobie, - Deps.doobiePostgres, - Deps.doobieHikari, - Deps.zio, - Deps.zioCats, - Deps.catsCore, - Deps.graalMetadata, - ), - libraryDependencies ++= { - if (scalaVersion.value.startsWith("2")) { - Seq(compilerPlugin(Deps.kindProjector)) - } else { - Seq.empty - } - }, - scalacOptions -= "-Xfatal-warnings", - scalacOptions -= "-Ykind-projector", - scalacOptions -= "-Wnonunit-statement", - scalacOptions ++= { - if (scalaVersion.value.startsWith("2")) { - Seq( - "-Xsource:3", - "-P:kind-projector:underscore-placeholders", - "-Wmacros:after", - ) - } else { - Seq( - "-source:3.2", - "-Ykind-projector:underscores", - "-Yretain-trees", - ) - } - }, - scalacOptions ++= Seq( - s"-Xmacro-settings:product-name=${name.value}", - s"-Xmacro-settings:product-version=${version.value}", - s"-Xmacro-settings:product-group=${organization.value}", - s"-Xmacro-settings:scala-version=${scalaVersion.value}", - s"-Xmacro-settings:scala-versions=${crossScalaVersions.value.mkString(":")}", - s"-Xmacro-settings:sbt-version=${sbtVersion.value}", - s"-Xmacro-settings:git-repo-clean=${git.gitUncommittedChanges.value}", - s"-Xmacro-settings:git-branch=${git.gitCurrentBranch.value}", - s"-Xmacro-settings:git-described-version=${git.gitDescribedVersion.value.getOrElse("")}", - s"-Xmacro-settings:git-head-commit=${git.gitHeadCommit.value.getOrElse("")}", - ), - GraalVMNativeImage / mainClass := Some("leaderboard.GenericLauncher"), - graalVMNativeImageOptions ++= Seq( - "--no-fallback", - "-H:+ReportExceptionStackTraces", - "--report-unsupported-elements-at-runtime", - "--enable-https", - "--enable-http", - "-J-Xmx4G", - ), - graalVMNativeImageGraalVersion := Some("ol9-java17-22.3.1"), - run / fork := true, + .aggregate( + `graal-resources`, + `leaderboard-monofunctor-tf`, + `leaderboard-bifunctor-tf`, + `leaderboard-monomorphic-cats`, + ) + .enablePlugins(GraalVMNativeImagePlugin, UniversalPlugin) // enabled here for CI purposes + +lazy val `graal-resources` = project + .in(file("graal-resources")) + .settings(Compile / resourceDirectory := baseDirectory.value) + +lazy val `leaderboard-monofunctor-tf` = makeExampleProject( + moduleName = "leaderboard-monofunctor-tf", + dir = "distage-example-monofunctor-tf", +)(deps = + Deps.CoreDeps ++ Seq( + Deps.zio, + Deps.zioCats, + Deps.catsCore, ) - .enablePlugins(GraalVMNativeImagePlugin, UniversalPlugin) +) + +lazy val `leaderboard-bifunctor-tf` = makeExampleProject( + moduleName = "leaderboard-bifunctor-tf", + dir = "distage-example-bifunctor-tf", +)(deps = + Deps.CoreDeps ++ Seq( + Deps.zio, + Deps.zioCats, + Deps.catsCore, + ) +) + +lazy val `leaderboard-monomorphic-cats` = makeExampleProject( + moduleName = "leaderboard-monomorphic-cats", + dir = "distage-example-monomorphic-cats", +)(deps = + Deps.CoreDeps ++ Seq( + Deps.catsEffect + ) +) diff --git a/src/main/resources/common-reference.conf b/distage-example-bifunctor-tf/src/main/resources/common-reference.conf similarity index 100% rename from src/main/resources/common-reference.conf rename to distage-example-bifunctor-tf/src/main/resources/common-reference.conf diff --git a/src/main/scala/leaderboard/LeaderboardRole.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/LeaderboardRole.scala similarity index 100% rename from src/main/scala/leaderboard/LeaderboardRole.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/LeaderboardRole.scala diff --git a/src/main/scala/leaderboard/api/HttpApi.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/api/HttpApi.scala similarity index 100% rename from src/main/scala/leaderboard/api/HttpApi.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/api/HttpApi.scala diff --git a/src/main/scala/leaderboard/api/LadderApi.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/api/LadderApi.scala similarity index 100% rename from src/main/scala/leaderboard/api/LadderApi.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/api/LadderApi.scala diff --git a/src/main/scala/leaderboard/api/ProfileApi.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/api/ProfileApi.scala similarity index 100% rename from src/main/scala/leaderboard/api/ProfileApi.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/api/ProfileApi.scala diff --git a/src/main/scala/leaderboard/config/PostgresCfg.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/config/PostgresCfg.scala similarity index 100% rename from src/main/scala/leaderboard/config/PostgresCfg.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/config/PostgresCfg.scala diff --git a/src/main/scala/leaderboard/config/PostgresPortCfg.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/config/PostgresPortCfg.scala similarity index 100% rename from src/main/scala/leaderboard/config/PostgresPortCfg.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/config/PostgresPortCfg.scala diff --git a/src/main/scala/leaderboard/http/HttpServer.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/http/HttpServer.scala similarity index 100% rename from src/main/scala/leaderboard/http/HttpServer.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/http/HttpServer.scala diff --git a/src/main/scala/leaderboard/model/QueryFailure.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/model/QueryFailure.scala similarity index 100% rename from src/main/scala/leaderboard/model/QueryFailure.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/model/QueryFailure.scala diff --git a/src/main/scala/leaderboard/model/RankedProfile.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/model/RankedProfile.scala similarity index 100% rename from src/main/scala/leaderboard/model/RankedProfile.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/model/RankedProfile.scala diff --git a/src/main/scala/leaderboard/model/UserProfile.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/model/UserProfile.scala similarity index 100% rename from src/main/scala/leaderboard/model/UserProfile.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/model/UserProfile.scala diff --git a/src/main/scala/leaderboard/model/package.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/model/package.scala similarity index 100% rename from src/main/scala/leaderboard/model/package.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/model/package.scala diff --git a/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala similarity index 100% rename from src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala diff --git a/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala similarity index 100% rename from src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala diff --git a/src/main/scala/leaderboard/repo/Ladder.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/repo/Ladder.scala similarity index 100% rename from src/main/scala/leaderboard/repo/Ladder.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/repo/Ladder.scala diff --git a/src/main/scala/leaderboard/repo/Profiles.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/repo/Profiles.scala similarity index 100% rename from src/main/scala/leaderboard/repo/Profiles.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/repo/Profiles.scala diff --git a/src/main/scala/leaderboard/services/Ranks.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/services/Ranks.scala similarity index 100% rename from src/main/scala/leaderboard/services/Ranks.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/services/Ranks.scala diff --git a/src/main/scala/leaderboard/sql/SQL.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/sql/SQL.scala similarity index 100% rename from src/main/scala/leaderboard/sql/SQL.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/sql/SQL.scala diff --git a/src/main/scala/leaderboard/sql/TransactorResource.scala b/distage-example-bifunctor-tf/src/main/scala/leaderboard/sql/TransactorResource.scala similarity index 100% rename from src/main/scala/leaderboard/sql/TransactorResource.scala rename to distage-example-bifunctor-tf/src/main/scala/leaderboard/sql/TransactorResource.scala diff --git a/src/test/scala/leaderboard/Rnd.scala b/distage-example-bifunctor-tf/src/test/scala/leaderboard/Rnd.scala similarity index 100% rename from src/test/scala/leaderboard/Rnd.scala rename to distage-example-bifunctor-tf/src/test/scala/leaderboard/Rnd.scala diff --git a/src/test/scala/leaderboard/WiringTest.scala b/distage-example-bifunctor-tf/src/test/scala/leaderboard/WiringTest.scala similarity index 100% rename from src/test/scala/leaderboard/WiringTest.scala rename to distage-example-bifunctor-tf/src/test/scala/leaderboard/WiringTest.scala diff --git a/src/test/scala/leaderboard/tests.scala b/distage-example-bifunctor-tf/src/test/scala/leaderboard/tests.scala similarity index 100% rename from src/test/scala/leaderboard/tests.scala rename to distage-example-bifunctor-tf/src/test/scala/leaderboard/tests.scala diff --git a/src/test/scala/leaderboard/zioenv.scala b/distage-example-bifunctor-tf/src/test/scala/leaderboard/zioenv.scala similarity index 100% rename from src/test/scala/leaderboard/zioenv.scala rename to distage-example-bifunctor-tf/src/test/scala/leaderboard/zioenv.scala diff --git a/distage-example-monofunctor-tf/src/main/resources/common-reference.conf b/distage-example-monofunctor-tf/src/main/resources/common-reference.conf new file mode 100644 index 0000000..4f8dd44 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/resources/common-reference.conf @@ -0,0 +1,13 @@ +postgres { + jdbcDriver = "org.postgresql.Driver" + url = "jdbc:postgresql://{host}:{port}/postgres" + user = "postgres" + password = "postgres" + host = "localhost" + port = 5432 +} + +logger { + levels = {} + json = false +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/LeaderboardRole.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/LeaderboardRole.scala new file mode 100644 index 0000000..fa897cd --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/LeaderboardRole.scala @@ -0,0 +1,319 @@ +package leaderboard + +import cats.Applicative +import distage.StandardAxis.Repo +import distage.plugins.PluginConfig +import distage.{Activation, Lifecycle, Module, ModuleDef} +import izumi.distage.model.definition.StandardAxis.Scene +import izumi.distage.roles.RoleAppMain +import izumi.distage.roles.bundled.{ConfigWriter, Help} +import izumi.distage.roles.model.{RoleDescriptor, RoleService} +import izumi.fundamentals.platform.IzPlatform +import izumi.fundamentals.platform.cli.model.raw.{RawEntrypointParams, RawRoleParams, RawValue} +import logstage.LogIO +import leaderboard.api.{LadderApi, ProfileApi} +import leaderboard.http.HttpServer +import leaderboard.plugins.{LeaderboardPlugin, PostgresDockerPlugin} +import zio.interop.catz.asyncInstance +import zio.Task + +import scala.annotation.unused + +/** + * A role that exposes just the /ladder/ endpoints, it can be launched with + * + * {{{ + * ./launcher :ladder + * }}} + * + * Example session: + * + * {{{ + * curl -X POST http://localhost:8080/ladder/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4/100 + * curl -X GET http://localhost:8080/ladder + * }}} + */ +final class LadderRole[F[_]: Applicative]( + @unused ladderApi: LadderApi[F], + @unused runningServer: HttpServer, + log: LogIO[F], +) extends RoleService[F] { + override def start(roleParameters: RawEntrypointParams, freeArgs: Vector[String]): Lifecycle[F, Unit] = { + Lifecycle.liftF(log.info("Ladder API started!")) + } +} +object LadderRole extends RoleDescriptor { + final val id = "ladder" +} + +/** + * A role that exposes just the /profile/ endpoints, it can be launched with + * + * {{{ + * ./launcher :profile + * }}} + * + * Example session: + * + * {{{ + * curl -X POST http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 -d '{"name": "Kai", "description": "S C A L A"}' + * curl -X GET http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 + * }}} + */ +final class ProfileRole[F[_]: Applicative]( + @unused profileApi: ProfileApi[F], + @unused runningServer: HttpServer, + log: LogIO[F], +) extends RoleService[F] { + override def start(roleParameters: RawEntrypointParams, freeArgs: Vector[String]): Lifecycle[F, Unit] = { + Lifecycle.liftF(log.info("Profile API started!")) + } +} +object ProfileRole extends RoleDescriptor { + final val id = "profile" +} + +/** A composite role that exposes all the endpoints, for convenience, it can be launched with + * + * {{{ + * ./launcher :leaderboard + * }}} + * + * Note that this will have the same effect as launching both [[LadderRole]] and [[ProfileRole]] at the same time. + * + * {{{ + * ./launcher :ladder :profile + * }}} + * + * Example session: + * + * {{{ + * curl -X POST http://localhost:8080/ladder/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4/100 + * curl -X POST http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 -d '{"name": "Kai", "description": "S C A L A"}' + * # check leaderboard + * curl -X GET http://localhost:8080/ladder + * # user profile now shows the rank in the ladder along with profile data + * curl -X GET http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 + * }}} + */ +final class LeaderboardRole[F[_]: Applicative]( + @unused ladderRole: LadderRole[F], + @unused profileRole: ProfileRole[F], + log: LogIO[F], +) extends RoleService[F] { + override def start(roleParameters: RawEntrypointParams, freeArgs: Vector[String]): Lifecycle[F, Unit] = { + Lifecycle.liftF(log.info("Ladder & Profile APIs started!")) + } +} +object LeaderboardRole extends RoleDescriptor { + final val id = "leaderboard" +} + +/** + * Launch the service with dummy configuration. + * + * This will use in-memory repositories and not require an external postgres DB. + * + * Equivalent to: + * {{{ + * ./launcher -u repo:dummy :leaderboard + * }}} + */ +object MainDummy extends MainBase(Activation(Repo -> Repo.Dummy), Vector(RawRoleParams(LeaderboardRole.id))) + +/** + * Launch with production configuration and setup the required postgres DB inside docker. + * + * You will need docker daemon running in the background. + * + * Equivalent to: + * {{{ + * ./launcher -u scene:managed :leaderboard + * }}} + */ +object MainProdDocker extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Managed), Vector(RawRoleParams(LeaderboardRole.id))) + +/** + * Launch with production configuration and external, not dockerized, services. + * + * You will need postgres to be available at `localhost:5432`. + * To set it up with Docker, execute the following command: + * + * {{{ + * docker run --rm -d -p 5432:5432 postgres:12.1 + * }}} + * + * Equivalent to: + * {{{ + * ./launcher :leaderboard + * }}} + */ +object MainProd extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(LeaderboardRole.id))) + +/** + * Launch just the `ladder` APIs with dummy repositories + * + * Equivalent to: + * {{{ + * ./launcher -u repo:dummy :ladder + * }}} + */ +object MainLadderDummy extends MainBase(Activation(Repo -> Repo.Dummy), Vector(RawRoleParams(LadderRole.id))) + +/** + * Launch just the `ladder` APIs with postgres repositories and dockerized postgres service + * + * Equivalent to: + * {{{ + * ./launcher -u scene:managed :ladder + * }}} + */ +object MainLadderProdDocker extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Managed), Vector(RawRoleParams(LadderRole.id))) + +/** + * Launch just the `ladder` APIs with postgres repositories and external postgres service + * + * You will need postgres to be available at `localhost:5432` + * + * Equivalent to: + * {{{ + * ./launcher :ladder + * }}} + */ +object MainLadderProd extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(LadderRole.id))) + +/** + * Launch just the `profile` APIs with dummy repositories + * + * Equivalent to: + * {{{ + * ./launcher -u repo:dummy :profile + * }}} + */ +object MainProfileDummy extends MainBase(Activation(Repo -> Repo.Dummy), Vector(RawRoleParams(ProfileRole.id))) + +/** + * Launch just the `profile` APIs with postgres repositories and dockerized postgres service + * + * Equivalent to: + * {{{ + * ./launcher -u scene:managed :profile + * }}} + */ +object MainProfileProdDocker extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Managed), Vector(RawRoleParams(ProfileRole.id))) + +/** + * Launch just the `profile` APIs with postgres repositories and external postgres service + * + * Equivalent to: + * {{{ + * ./launcher :profile + * }}} + */ +object MainProfileProd extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(ProfileRole.id))) + +/** + * Display help message with all available launcher arguments + * and command-line parameters for all roles + * + * Equivalent to: + * {{{ + * ./launcher :help + * }}} + */ +object MainHelp extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(Help.id))) + +/** + * Write the default configuration files for each role into JSON files in `./config`. + * Configurations in @see {{{izumi.distage.config.ConfigModuleDef#makeConfig}}} + * are read from resources: + * + * - common-reference.conf - (configuration shared across all roles) + * - \${roleName}-reference.conf - (role-specific configuration, overrides `common`) + * + * Equivalent to: + * {{{ + * ./launcher :configwriter + * }}} + */ +object MainWriteReferenceConfigs + extends MainBase( + activation = { + Activation(Repo -> Repo.Prod, Scene -> Scene.Provided) + }, + requiredRoles = { + Vector( + RawRoleParams( + role = ConfigWriter.id, + roleParameters = RawEntrypointParams( + flags = Vector.empty, + // output configs in "hocon" format, instead of "json" + values = Vector(RawValue("format", "hocon")), + ), + freeArgs = Vector.empty, + ) + ) + }, + ) + +/** + * Generic launcher not set to run a specific role by default, + * use command-line arguments to choose one or multiple roles: + * + * {{{ + * + * # launch app with prod repositories + * + * ./launcher :leaderboard + * + * # launch app with dummy repositories + * + * ./launcher -u repo:dummy :leaderboard + * + * # launch just the ladder API, without profiles + * + * ./launcher :ladder + * + * # display help + * + * ./launcher :help + * + * # write configs in HOCON format to ./default-configs + * + * ./launcher :configwriter -format hocon -t default-configs + * + * # print help, dump configs and launch app with dummy repositories + * + * ./launcher -u repo:dummy :help :configwriter :leaderboard + * + * }}} + */ +object GenericLauncher extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector.empty) + +sealed abstract class MainBase( + activation: Activation, + requiredRoles: Vector[RawRoleParams], +) extends RoleAppMain.LauncherCats[Task] { + + override def requiredRoles(argv: RoleAppMain.ArgV): Vector[RawRoleParams] = { + requiredRoles + } + + override def pluginConfig: PluginConfig = { + if (IzPlatform.isGraalNativeImage) { + // Only this would work reliably for NativeImage + PluginConfig.const(List(LeaderboardPlugin, PostgresDockerPlugin)) + } else { + // Runtime discovery with PluginConfig.cached might be convenient for pure jvm projects during active development + // Once the project gets to the maintenance stage it's a good idea to switch to PluginConfig.const + PluginConfig.cached(pluginsPackage = "leaderboard.plugins") + } + } + + protected override def roleAppBootOverrides(argv: RoleAppMain.ArgV): Module = super.roleAppBootOverrides(argv) ++ new ModuleDef { + make[Activation].named("default").fromValue(defaultActivation ++ activation) + } + + private def defaultActivation = Activation(Scene -> Scene.Provided) + +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/HttpApi.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/HttpApi.scala new file mode 100644 index 0000000..7179d44 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/HttpApi.scala @@ -0,0 +1,7 @@ +package leaderboard.api + +import org.http4s.HttpRoutes + +trait HttpApi[F[_]] { + def http: HttpRoutes[F] +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/LadderApi.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/LadderApi.scala new file mode 100644 index 0000000..7372af8 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/LadderApi.scala @@ -0,0 +1,31 @@ +package leaderboard.api + +import cats.MonadThrow +import cats.implicits.* +import io.circe.syntax.* +import leaderboard.repo.Ladder +import org.http4s.HttpRoutes +import org.http4s.circe.* +import org.http4s.dsl.Http4sDsl + +final class LadderApi[F[_]: MonadThrow]( + dsl: Http4sDsl[F], + ladder: Ladder[F], +) extends HttpApi[F] { + + import dsl.* + + override def http: HttpRoutes[F] = { + HttpRoutes.of { + case GET -> Root / "ladder" => + Ok(for { + res <- ladder.getScores + } yield res.asJson) + + case POST -> Root / "ladder" / UUIDVar(userId) / LongVar(score) => + Ok(for { + _ <- ladder.submitScore(userId, score) + } yield ()) + } + } +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/ProfileApi.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/ProfileApi.scala new file mode 100644 index 0000000..7e1f513 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/api/ProfileApi.scala @@ -0,0 +1,38 @@ +package leaderboard.api + +import cats.effect.Concurrent +import io.circe.syntax.* +import cats.implicits.* +import leaderboard.model.UserProfile +import leaderboard.repo.Profiles +import leaderboard.services.Ranks +import logstage.LogIO +import org.http4s.HttpRoutes +import org.http4s.circe.* +import org.http4s.dsl.Http4sDsl + +final class ProfileApi[F[_]: Concurrent]( + dsl: Http4sDsl[F], + profiles: Profiles[F], + ranks: Ranks[F], + log: LogIO[F], +) extends HttpApi[F] { + + import dsl.* + + override def http: HttpRoutes[F] = { + HttpRoutes.of { + case GET -> Root / "profile" / UUIDVar(userId) => + Ok(for { + res <- ranks.getRank(userId) + } yield res.asJson) + + case rq @ POST -> Root / "profile" / UUIDVar(userId) => + Ok(for { + profile <- rq.decodeJson[UserProfile] + _ <- log.info(s"Saving $profile") + _ <- profiles.setProfile(userId, profile) + } yield ()) + } + } +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/config/PostgresCfg.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/config/PostgresCfg.scala new file mode 100644 index 0000000..891dccb --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/config/PostgresCfg.scala @@ -0,0 +1,8 @@ +package leaderboard.config + +final case class PostgresCfg( + jdbcDriver: String, + url: String, + user: String, + password: String, +) diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/config/PostgresPortCfg.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/config/PostgresPortCfg.scala new file mode 100644 index 0000000..fc97cde --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/config/PostgresPortCfg.scala @@ -0,0 +1,10 @@ +package leaderboard.config + +final case class PostgresPortCfg( + host: String, + port: Int, +) { + def substitute(s: String): String = { + s.replace("{host}", host).replace("{port}", port.toString) + } +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/http/HttpServer.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/http/HttpServer.scala new file mode 100644 index 0000000..050ab55 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/http/HttpServer.scala @@ -0,0 +1,35 @@ +package leaderboard.http + +import cats.effect.Async +import cats.implicits.* +import com.comcast.ip4s.Port +import fs2.io.net.Network +import izumi.distage.model.definition.Lifecycle +import leaderboard.api.HttpApi +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Server + +final case class HttpServer( + server: Server +) + +object HttpServer { + + final class Impl[F[_]]( + allHttpApis: Set[HttpApi[F]] + )(implicit + async: Async[F] + ) extends Lifecycle.Of[F, HttpServer]( + Lifecycle.fromCats { + val combinedApis = allHttpApis.map(_.http).toList.foldK + + EmberServerBuilder + .default(async, Network.forAsync) + .withHttpApp(combinedApis.orNotFound) + .withPort(Port.fromInt(8080).get) + .build + .map(HttpServer(_)) + } + ) + +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/QueryFailure.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/QueryFailure.scala new file mode 100644 index 0000000..68ddb24 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/QueryFailure.scala @@ -0,0 +1,7 @@ +package leaderboard.model + +final case class QueryFailure(queryName: String, cause: Throwable) + extends RuntimeException( + s"""Query "$queryName" failed with ${cause.getMessage}""", + cause, + ) diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/RankedProfile.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/RankedProfile.scala new file mode 100644 index 0000000..fa158b3 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/RankedProfile.scala @@ -0,0 +1,15 @@ +package leaderboard.model + +import io.circe.Codec +import io.circe.generic.semiauto + +final case class RankedProfile( + name: String, + description: String, + rank: Int, + score: Score, +) + +object RankedProfile { + implicit val codec: Codec.AsObject[RankedProfile] = semiauto.deriveCodec +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/UserProfile.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/UserProfile.scala new file mode 100644 index 0000000..754fb85 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/UserProfile.scala @@ -0,0 +1,13 @@ +package leaderboard.model + +import io.circe.Codec +import io.circe.generic.semiauto + +final case class UserProfile( + name: String, + description: String, +) + +object UserProfile { + implicit val codec: Codec.AsObject[UserProfile] = semiauto.deriveCodec +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/package.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/package.scala new file mode 100644 index 0000000..24ae7cc --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/model/package.scala @@ -0,0 +1,8 @@ +package leaderboard + +import java.util.UUID + +package object model { + type UserId = UUID + type Score = Long +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala new file mode 100644 index 0000000..e71b392 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala @@ -0,0 +1,93 @@ +package leaderboard.plugins + +import distage.StandardAxis.Repo +import distage.config.ConfigModuleDef +import distage.plugins.PluginDef +import distage.{ModuleDef, Scene, TagK} +import doobie.util.transactor.Transactor +import izumi.distage.roles.bundled.BundledRolesModule +import izumi.distage.roles.model.definition.RoleModuleDef +import izumi.fundamentals.platform.integration.PortCheck +import zio.Task +import leaderboard.api.{HttpApi, LadderApi, ProfileApi} +import leaderboard.config.{PostgresCfg, PostgresPortCfg} +import leaderboard.http.HttpServer +import leaderboard.repo.{Ladder, Profiles} +import leaderboard.services.Ranks +import leaderboard.sql.{SQL, TransactorResource} +import leaderboard.{LadderRole, LeaderboardRole, ProfileRole} +import org.http4s.dsl.Http4sDsl + +import scala.concurrent.duration.* + +object LeaderboardPlugin extends PluginDef { + include(modules.roles[Task]) + include(modules.api[Task]) + include(modules.repoDummy[Task]) + include(modules.repoProd[Task]) + include(modules.configs) + include(modules.prodConfigs) + + object modules { + def roles[F[_]: TagK]: RoleModuleDef = new RoleModuleDef { + // The `ladder` role + makeRole[LadderRole[F]] + + // The `profile` role + makeRole[ProfileRole[F]] + + // The composite `leaderboard` role that pulls in both `ladder` & `profile` roles + makeRole[LeaderboardRole[F]] + + // Add bundled roles: `help` & `configwriter` + include(BundledRolesModule[F](version = "1.0.0")) + } + + def api[F[_]: TagK]: ModuleDef = new ModuleDef { + // The `ladder` API + make[LadderApi[F]] + // The `profile` API + make[ProfileApi[F]] + + // A set of all APIs + many[HttpApi[F]] + .weak[LadderApi[F]] // add ladder API as a _weak reference_ + .weak[ProfileApi[F]] // add profiles API as a _weak reference_ + + make[HttpServer].fromResource[HttpServer.Impl[F]] + + make[Ranks[F]].from[Ranks.Impl[F]] + + makeTrait[Http4sDsl[F]] + } + + def repoDummy[F[_]: TagK]: ModuleDef = new ModuleDef { + tag(Repo.Dummy) + + make[Ladder[F]].fromResource[Ladder.Dummy[F]] + make[Profiles[F]].fromResource[Profiles.Dummy[F]] + } + + def repoProd[F[_]: TagK]: ModuleDef = new ModuleDef { + tag(Repo.Prod) + + make[Ladder[F]].fromResource[Ladder.Postgres[F]] + make[Profiles[F]].fromResource[Profiles.Postgres[F]] + + make[SQL[F]].from[SQL.Impl[F]] + + make[Transactor[F]].fromResource[TransactorResource[F]] + make[PortCheck].from(new PortCheck(3.seconds)) + } + + val configs: ConfigModuleDef = new ConfigModuleDef { + makeConfig[PostgresCfg]("postgres") + } + val prodConfigs: ConfigModuleDef = new ConfigModuleDef { + // only use this if Scene axis is set to Provided + tag(Scene.Provided) + + makeConfig[PostgresPortCfg]("postgres") + } + } +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala new file mode 100644 index 0000000..4de1706 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala @@ -0,0 +1,32 @@ +package leaderboard.plugins + +import distage.Scene +import izumi.distage.docker.Docker.DockerPort +import izumi.distage.docker.bundled.PostgresDocker +import izumi.distage.docker.modules.DockerSupportModule +import izumi.distage.plugins.PluginDef +import leaderboard.config.PostgresPortCfg +import zio.Task + +object PostgresDockerPlugin extends PluginDef { + // only enable postgres docker when Scene axis is set to Managed + tag(Scene.Managed) + + // add docker support dependencies + include(DockerSupportModule[Task]) + + // launch postgres docker for tests + make[PostgresDocker.Container] + .fromResource(PostgresDocker.make[Task]) + + // spawned docker container port is randomized + // to prevent conflicts, so make PostgresPortCfg + // point to the new port. This will also + // cause the container to start before + // integration check is performed + make[PostgresPortCfg].from { + (docker: PostgresDocker.Container) => + val knownAddress = docker.availablePorts.first(DockerPort.TCP(5432)) + PostgresPortCfg(knownAddress.hostString, knownAddress.port) + } +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/repo/Ladder.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/repo/Ladder.scala new file mode 100644 index 0000000..baa8b26 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/repo/Ladder.scala @@ -0,0 +1,63 @@ +package leaderboard.repo + +import cats.{Applicative, Monad} +import cats.implicits.* +import distage.Lifecycle +import doobie.postgres.implicits.* +import doobie.syntax.string.* +import leaderboard.model.{Score, UserId} +import leaderboard.sql.SQL +import logstage.LogIO + +import scala.collection.concurrent.TrieMap + +trait Ladder[F[_]] { + def submitScore(userId: UserId, score: Score): F[Unit] + def getScores: F[List[(UserId, Score)]] +} + +object Ladder { + final class Dummy[F[_]: Applicative] + extends Lifecycle.LiftF[F, Ladder[F]](for { + state <- Applicative[F].pure(TrieMap.empty[UserId, Score]) + } yield { + new Ladder[F] { + override def submitScore(userId: UserId, score: Score): F[Unit] = + Applicative[F].pure(state.update(userId, score)) + + override def getScores: F[List[(UserId, Score)]] = + Applicative[F].pure(state.toList.sortBy(_._2)(Ordering[Score].reverse)) + } + }) + + final class Postgres[F[_]: Monad]( + sql: SQL[F], + log: LogIO[F], + ) extends Lifecycle.LiftF[F, Ladder[F]](for { + _ <- log.info(s"Creating Ladder table") + _ <- sql.execute("ladder-ddl") { + sql"""create table if not exists ladder ( + | user_id uuid not null, + | score bigint not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + res = new Ladder[F] { + override def submitScore(userId: UserId, score: Score): F[Unit] = + sql + .execute("submit-score") { + sql"""insert into ladder (user_id, score) values ($userId, $score) + |on conflict (user_id) do update set + | score = excluded.score + |""".stripMargin.update.run + }.void + + override val getScores: F[List[(UserId, Score)]] = + sql.execute("get-leaderboard") { + sql"""select user_id, score from ladder order by score DESC + |""".stripMargin.query[(UserId, Score)].to[List] + } + } + } yield res) +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/repo/Profiles.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/repo/Profiles.scala new file mode 100644 index 0000000..5dc7da0 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/repo/Profiles.scala @@ -0,0 +1,68 @@ +package leaderboard.repo + +import distage.Lifecycle +import cats.{Applicative, Monad} +import cats.implicits.* +import doobie.postgres.implicits.* +import doobie.syntax.string.* +import leaderboard.model.{UserId, UserProfile} +import leaderboard.sql.SQL +import logstage.LogIO + +import scala.collection.concurrent.TrieMap + +trait Profiles[F[_]] { + def setProfile(userId: UserId, profile: UserProfile): F[Unit] + def getProfile(userId: UserId): F[Option[UserProfile]] +} + +object Profiles { + final class Dummy[F[_]: Applicative] + extends Lifecycle.LiftF[F, Profiles[F]](for { + state <- Applicative[F].pure(TrieMap.empty[UserId, UserProfile]) + } yield { + new Profiles[F] { + override def setProfile(userId: UserId, profile: UserProfile): F[Unit] = + Applicative[F].pure(state.update(userId, profile)) + + override def getProfile(userId: UserId): F[Option[UserProfile]] = + Applicative[F].pure(state.get(userId)) + } + }) + + final class Postgres[F[_]: Monad]( + sql: SQL[F], + log: LogIO[F], + ) extends Lifecycle.LiftF[F, Profiles[F]](for { + _ <- log.info("Creating Profile table") + _ <- sql.execute("ddl-profiles") { + sql"""create table if not exists profiles ( + | user_id uuid not null, + | name text not null, + | description text not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + } yield new Profiles[F] { + override def setProfile(userId: UserId, profile: UserProfile): F[Unit] = { + sql + .execute("set-profile") { + sql"""insert into profiles (user_id, name, description) + |values ($userId, ${profile.name}, ${profile.description}) + |on conflict (user_id) do update set + | name = excluded.name, + | description = excluded.description + |""".stripMargin.update.run + }.void + } + + override def getProfile(userId: UserId): F[Option[UserProfile]] = { + sql.execute("get-profile") { + sql"""select name, description from profiles + |where user_id = $userId + |""".stripMargin.query[UserProfile].option + } + } + }) +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/services/Ranks.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/services/Ranks.scala new file mode 100644 index 0000000..028d579 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/services/Ranks.scala @@ -0,0 +1,35 @@ +package leaderboard.services + +import cats.MonadThrow +import cats.implicits.* +import leaderboard.model.{RankedProfile, UserId} +import leaderboard.repo.{Ladder, Profiles} + +trait Ranks[F[_]] { + def getRank(userId: UserId): F[Option[RankedProfile]] +} + +object Ranks { + final class Impl[F[_]: MonadThrow]( + ladder: Ladder[F], + profiles: Profiles[F], + ) extends Ranks[F] { + + override def getRank(userId: UserId): F[Option[RankedProfile]] = + for { + maybeProfile <- profiles.getProfile(userId) + scores <- ladder.getScores + res = for { + profile <- maybeProfile + rank = scores.indexWhere(_._1 == userId) + 1 + score = scores.find(_._1 == userId).map(_._2) + } yield RankedProfile( + name = profile.name, + description = profile.description, + rank = rank, + score = score.getOrElse(0), + ) + } yield res + } + +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/sql/SQL.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/sql/SQL.scala new file mode 100644 index 0000000..f875021 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/sql/SQL.scala @@ -0,0 +1,23 @@ +package leaderboard.sql + +import cats.effect.Async +import cats.syntax.applicativeError.* +import doobie.free.connection.ConnectionIO +import doobie.util.transactor.Transactor +import leaderboard.model.QueryFailure + +trait SQL[F[_]] { + def execute[A](queryName: String)(conn: ConnectionIO[A]): F[A] +} + +object SQL { + final class Impl[F[_]: Async]( + transactor: Transactor[F] + ) extends SQL[F] { + override def execute[A](queryName: String)(conn: ConnectionIO[A]): F[A] = { + transactor.trans + .apply(conn) + .handleErrorWith(ex => Async[F].raiseError(QueryFailure(queryName, ex))) + } + } +} diff --git a/distage-example-monofunctor-tf/src/main/scala/leaderboard/sql/TransactorResource.scala b/distage-example-monofunctor-tf/src/main/scala/leaderboard/sql/TransactorResource.scala new file mode 100644 index 0000000..1c0ede3 --- /dev/null +++ b/distage-example-monofunctor-tf/src/main/scala/leaderboard/sql/TransactorResource.scala @@ -0,0 +1,30 @@ +package leaderboard.sql + +import cats.effect.{Async, Sync} +import distage.{Id, Lifecycle} +import doobie.hikari.HikariTransactor +import izumi.distage.model.provisioning.IntegrationCheck +import izumi.fundamentals.platform.integration.{PortCheck, ResourceCheck} +import leaderboard.config.{PostgresCfg, PostgresPortCfg} + +import scala.concurrent.ExecutionContext + +final class TransactorResource[F[_]: Async]( + cfg: PostgresCfg, + portCfg: PostgresPortCfg, + portCheck: PortCheck, + blockingExecutionContext: ExecutionContext @Id("io"), +) extends Lifecycle.OfCats( + HikariTransactor.newHikariTransactor( + driverClassName = cfg.jdbcDriver, + url = portCfg.substitute(cfg.url), + user = cfg.user, + pass = cfg.password, + connectEC = blockingExecutionContext, + ) + ) + with IntegrationCheck[F] { + override def resourcesAvailable(): F[ResourceCheck] = Sync[F].delay { + portCheck.checkPort(portCfg.host, portCfg.port, s"Couldn't connect to postgres at host=${portCfg.host} port=${portCfg.port}") + } +} diff --git a/distage-example-monofunctor-tf/src/test/scala/leaderboard/Rnd.scala b/distage-example-monofunctor-tf/src/test/scala/leaderboard/Rnd.scala new file mode 100644 index 0000000..2cb721c --- /dev/null +++ b/distage-example-monofunctor-tf/src/test/scala/leaderboard/Rnd.scala @@ -0,0 +1,20 @@ +package leaderboard + +import cats.effect.kernel.Sync +import org.scalacheck.Gen.Parameters +import org.scalacheck.{Arbitrary, Prop} + +trait Rnd[F[_]] { + def apply[A: Arbitrary]: F[A] +} + +object Rnd { + final class Impl[F[_]: Sync] extends Rnd[F] { + override def apply[A: Arbitrary]: F[A] = { + Sync[F].delay { + val (p, s) = Prop.startSeed(Parameters.default) + Arbitrary.arbitrary[A].pureApply(p, s) + } + } + } +} diff --git a/distage-example-monofunctor-tf/src/test/scala/leaderboard/WiringTest.scala b/distage-example-monofunctor-tf/src/test/scala/leaderboard/WiringTest.scala new file mode 100644 index 0000000..8d3d701 --- /dev/null +++ b/distage-example-monofunctor-tf/src/test/scala/leaderboard/WiringTest.scala @@ -0,0 +1,5 @@ +package leaderboard + +import izumi.distage.testkit.scalatest.SpecWiring + +final class WiringTest extends SpecWiring(GenericLauncher) diff --git a/distage-example-monofunctor-tf/src/test/scala/leaderboard/tests.scala b/distage-example-monofunctor-tf/src/test/scala/leaderboard/tests.scala new file mode 100644 index 0000000..20949b0 --- /dev/null +++ b/distage-example-monofunctor-tf/src/test/scala/leaderboard/tests.scala @@ -0,0 +1,181 @@ +package leaderboard + +import distage.{DIKey, ModuleDef, Scene} +import izumi.distage.model.definition.Activation +import izumi.distage.model.definition.StandardAxis.Repo +import izumi.distage.plugins.PluginConfig +import izumi.distage.testkit.scalatest.{AssertZIO, SpecZIO} +import leaderboard.model.* +import leaderboard.repo.{Ladder, Profiles} +import leaderboard.services.Ranks +import leaderboard.zioenv.* +import zio.{RIO, Task, ZIO} + +abstract class LeaderboardTest extends SpecZIO with AssertZIO { + override def config = super.config.copy( + pluginConfig = PluginConfig.cached(packagesEnabled = Seq("leaderboard.plugins")), + moduleOverrides = super.config.moduleOverrides ++ new ModuleDef { + make[Rnd[Task]].from[Rnd.Impl[Task]] + }, + // For testing, setup a docker container with postgres, + // instead of trying to connect to an external database + activation = Activation(Scene -> Scene.Managed), + // Instantiate Ladder & Profiles only once per test-run and + // share them and all their dependencies across all tests. + // this includes the Postgres Docker container above and table DDLs + memoizationRoots = Set( + DIKey[Ladder[Task]], + DIKey[Profiles[Task]], + ), + ) +} + +trait DummyTest extends LeaderboardTest { + override final def config = super.config.copy( + activation = super.config.activation ++ Activation(Repo -> Repo.Dummy) + ) +} + +trait ProdTest extends LeaderboardTest { + override final def config = super.config.copy( + activation = super.config.activation ++ Activation(Repo -> Repo.Prod) + ) +} + +final class LadderTestDummy extends LadderTest with DummyTest +final class ProfilesTestDummy extends ProfilesTest with DummyTest +final class RanksTestDummy extends RanksTest with DummyTest + +final class LadderTestPostgres extends LadderTest with ProdTest +final class ProfilesTestPostgres extends ProfilesTest with ProdTest +final class RanksTestPostgres extends RanksTest with ProdTest + +abstract class LadderTest extends LeaderboardTest { + + "Ladder" should { + + /** this test gets dependencies injected through function arguments */ + "submit & get" in { + (rnd: Rnd[Task], ladder: Ladder[Task]) => + for { + user <- rnd[UserId] + score <- rnd[Score] + _ <- ladder.submitScore(user, score) + scores <- ladder.getScores + res = scores.find(_._1 == user).map(_._2) + _ <- assertIO(res contains score) + } yield () + } + + /** this test get dependencies injected via ZIO Env: */ + "assign a higher position in the list to a higher score" in { + for { + user1 <- rnd[UserId] + score1 <- rnd[Score] + user2 <- rnd[UserId] + score2 <- rnd[Score] + + _ <- ladder.submitScore(user1, score1) + _ <- ladder.submitScore(user2, score2) + scores <- ladder.getScores + + user1Rank = scores.indexWhere(_._1 == user1) + user2Rank = scores.indexWhere(_._1 == user2) + + _ <- + if (score1 > score2) { + assertIO(user1Rank < user2Rank) + } else if (score2 > score1) { + assertIO(user2Rank < user1Rank) + } else ZIO.unit + } yield () + } + + } + +} + +abstract class ProfilesTest extends LeaderboardTest { + + "Profiles" should { + + /** that's what the ZIO signature looks like for ZIO Env injection: */ + "set & get" in { + val zioValue: RIO[Profiles[Task] & Rnd[Task], Unit] = for { + user <- rnd[UserId] + name <- rnd[String] + desc <- rnd[String] + profile = UserProfile(name, desc) + _ <- profiles.setProfile(user, profile) + res <- profiles.getProfile(user) + _ <- assertIO(res contains profile) + } yield () + + zioValue + } + + } + +} + +abstract class RanksTest extends LeaderboardTest { + + "Ranks" should { + + /** you can use Argument injection and ZIO Env injection at the same time: */ + "return 0 rank for a user with no score" in { + (ranks: Ranks[Task]) => + for { + user <- rnd[UserId] + name <- rnd[String] + desc <- rnd[String] + profile = UserProfile(name, desc) + _ <- profiles.setProfile(user, profile) + res1 <- ranks.getRank(user) + _ <- assertIO(res1.contains(RankedProfile(name, desc, 0, 0))) + } yield () + } + + "return None for a user with no profile" in { + for { + user <- rnd[UserId] + score <- rnd[Score] + _ <- ladder.submitScore(user, score) + res1 <- ranks.getRank(user) + _ <- assertIO(res1.isEmpty) + } yield () + } + + "assign a higher rank to a user with more score" in { + for { + user1 <- rnd[UserId] + name1 <- rnd[String] + desc1 <- rnd[String] + score1 <- rnd[Score] + + user2 <- rnd[UserId] + name2 <- rnd[String] + desc2 <- rnd[String] + score2 <- rnd[Score] + + _ <- profiles.setProfile(user1, UserProfile(name1, desc1)) + _ <- ladder.submitScore(user1, score1) + + _ <- profiles.setProfile(user2, UserProfile(name2, desc2)) + _ <- ladder.submitScore(user2, score2) + + user1Rank <- ranks.getRank(user1).map(_.get.rank) + user2Rank <- ranks.getRank(user2).map(_.get.rank) + + _ <- + if (score1 > score2) { + assertIO(user1Rank < user2Rank) + } else if (score2 > score1) { + assertIO(user2Rank < user1Rank) + } else ZIO.unit + } yield () + } + + } + +} diff --git a/distage-example-monofunctor-tf/src/test/scala/leaderboard/zioenv.scala b/distage-example-monofunctor-tf/src/test/scala/leaderboard/zioenv.scala new file mode 100644 index 0000000..eb038b3 --- /dev/null +++ b/distage-example-monofunctor-tf/src/test/scala/leaderboard/zioenv.scala @@ -0,0 +1,29 @@ +package leaderboard + +import leaderboard.model.* +import leaderboard.repo.{Ladder, Profiles} +import leaderboard.services.Ranks +import org.scalacheck.Arbitrary +import zio.{RIO, Task, ZIO} + +object zioenv { + + object ladder extends Ladder[RIO[Ladder[Task], _]] { + def submitScore(userId: UserId, score: Score): RIO[Ladder[Task], Unit] = ZIO.serviceWithZIO(_.submitScore(userId, score)) + def getScores: RIO[Ladder[Task], List[(UserId, Score)]] = ZIO.serviceWithZIO(_.getScores) + } + + object profiles extends Profiles[RIO[Profiles[Task], _]] { + override def setProfile(userId: UserId, profile: UserProfile): RIO[Profiles[Task], Unit] = ZIO.serviceWithZIO(_.setProfile(userId, profile)) + override def getProfile(userId: UserId): RIO[Profiles[Task], Option[UserProfile]] = ZIO.serviceWithZIO(_.getProfile(userId)) + } + + object ranks extends Ranks[RIO[Ranks[Task], _]] { + override def getRank(userId: UserId): RIO[Ranks[Task], Option[RankedProfile]] = ZIO.serviceWithZIO(_.getRank(userId)) + } + + object rnd extends Rnd[RIO[Rnd[Task], _]] { + override def apply[A: Arbitrary]: RIO[Rnd[Task], A] = ZIO.serviceWithZIO(_.apply[A]) + } + +} diff --git a/distage-example-monomorphic-cats/src/main/resources/common-reference.conf b/distage-example-monomorphic-cats/src/main/resources/common-reference.conf new file mode 100644 index 0000000..4f8dd44 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/resources/common-reference.conf @@ -0,0 +1,13 @@ +postgres { + jdbcDriver = "org.postgresql.Driver" + url = "jdbc:postgresql://{host}:{port}/postgres" + user = "postgres" + password = "postgres" + host = "localhost" + port = 5432 +} + +logger { + levels = {} + json = false +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/LeaderboardRole.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/LeaderboardRole.scala new file mode 100644 index 0000000..b958025 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/LeaderboardRole.scala @@ -0,0 +1,317 @@ +package leaderboard + +import cats.effect.IO +import distage.StandardAxis.Repo +import distage.plugins.PluginConfig +import distage.{Activation, Lifecycle, Module, ModuleDef} +import izumi.distage.model.definition.StandardAxis.Scene +import izumi.distage.roles.RoleAppMain +import izumi.distage.roles.bundled.{ConfigWriter, Help} +import izumi.distage.roles.model.{RoleDescriptor, RoleService} +import izumi.fundamentals.platform.IzPlatform +import izumi.fundamentals.platform.cli.model.raw.{RawEntrypointParams, RawRoleParams, RawValue} +import logstage.LogIO +import leaderboard.api.{LadderApi, ProfileApi} +import leaderboard.http.HttpServer +import leaderboard.plugins.{LeaderboardPlugin, PostgresDockerPlugin} + +import scala.annotation.unused + +/** + * A role that exposes just the /ladder/ endpoints, it can be launched with + * + * {{{ + * ./launcher :ladder + * }}} + * + * Example session: + * + * {{{ + * curl -X POST http://localhost:8080/ladder/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4/100 + * curl -X GET http://localhost:8080/ladder + * }}} + */ +final class LadderRole( + @unused ladderApi: LadderApi, + @unused runningServer: HttpServer, + log: LogIO[IO], +) extends RoleService[IO] { + override def start(roleParameters: RawEntrypointParams, freeArgs: Vector[String]): Lifecycle[IO, Unit] = { + Lifecycle.liftF(log.info("Ladder API started!")) + } +} +object LadderRole extends RoleDescriptor { + final val id = "ladder" +} + +/** + * A role that exposes just the /profile/ endpoints, it can be launched with + * + * {{{ + * ./launcher :profile + * }}} + * + * Example session: + * + * {{{ + * curl -X POST http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 -d '{"name": "Kai", "description": "S C A L A"}' + * curl -X GET http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 + * }}} + */ +final class ProfileRole( + @unused profileApi: ProfileApi, + @unused runningServer: HttpServer, + log: LogIO[IO], +) extends RoleService[IO] { + override def start(roleParameters: RawEntrypointParams, freeArgs: Vector[String]): Lifecycle[IO, Unit] = { + Lifecycle.liftF(log.info("Profile API started!")) + } +} +object ProfileRole extends RoleDescriptor { + final val id = "profile" +} + +/** A composite role that exposes all the endpoints, for convenience, it can be launched with + * + * {{{ + * ./launcher :leaderboard + * }}} + * + * Note that this will have the same effect as launching both [[LadderRole]] and [[ProfileRole]] at the same time. + * + * {{{ + * ./launcher :ladder :profile + * }}} + * + * Example session: + * + * {{{ + * curl -X POST http://localhost:8080/ladder/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4/100 + * curl -X POST http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 -d '{"name": "Kai", "description": "S C A L A"}' + * # check leaderboard + * curl -X GET http://localhost:8080/ladder + * # user profile now shows the rank in the ladder along with profile data + * curl -X GET http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 + * }}} + */ +final class LeaderboardRole( + @unused ladderRole: LadderRole, + @unused profileRole: ProfileRole, + log: LogIO[IO], +) extends RoleService[IO] { + override def start(roleParameters: RawEntrypointParams, freeArgs: Vector[String]): Lifecycle[IO, Unit] = { + Lifecycle.liftF(log.info("Ladder & Profile APIs started!")) + } +} +object LeaderboardRole extends RoleDescriptor { + final val id = "leaderboard" +} + +/** + * Launch the service with dummy configuration. + * + * This will use in-memory repositories and not require an external postgres DB. + * + * Equivalent to: + * {{{ + * ./launcher -u repo:dummy :leaderboard + * }}} + */ +object MainDummy extends MainBase(Activation(Repo -> Repo.Dummy), Vector(RawRoleParams(LeaderboardRole.id))) + +/** + * Launch with production configuration and setup the required postgres DB inside docker. + * + * You will need docker daemon running in the background. + * + * Equivalent to: + * {{{ + * ./launcher -u scene:managed :leaderboard + * }}} + */ +object MainProdDocker extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Managed), Vector(RawRoleParams(LeaderboardRole.id))) + +/** + * Launch with production configuration and external, not dockerized, services. + * + * You will need postgres to be available at `localhost:5432`. + * To set it up with Docker, execute the following command: + * + * {{{ + * docker run --rm -d -p 5432:5432 postgres:12.1 + * }}} + * + * Equivalent to: + * {{{ + * ./launcher :leaderboard + * }}} + */ +object MainProd extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(LeaderboardRole.id))) + +/** + * Launch just the `ladder` APIs with dummy repositories + * + * Equivalent to: + * {{{ + * ./launcher -u repo:dummy :ladder + * }}} + */ +object MainLadderDummy extends MainBase(Activation(Repo -> Repo.Dummy), Vector(RawRoleParams(LadderRole.id))) + +/** + * Launch just the `ladder` APIs with postgres repositories and dockerized postgres service + * + * Equivalent to: + * {{{ + * ./launcher -u scene:managed :ladder + * }}} + */ +object MainLadderProdDocker extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Managed), Vector(RawRoleParams(LadderRole.id))) + +/** + * Launch just the `ladder` APIs with postgres repositories and external postgres service + * + * You will need postgres to be available at `localhost:5432` + * + * Equivalent to: + * {{{ + * ./launcher :ladder + * }}} + */ +object MainLadderProd extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(LadderRole.id))) + +/** + * Launch just the `profile` APIs with dummy repositories + * + * Equivalent to: + * {{{ + * ./launcher -u repo:dummy :profile + * }}} + */ +object MainProfileDummy extends MainBase(Activation(Repo -> Repo.Dummy), Vector(RawRoleParams(ProfileRole.id))) + +/** + * Launch just the `profile` APIs with postgres repositories and dockerized postgres service + * + * Equivalent to: + * {{{ + * ./launcher -u scene:managed :profile + * }}} + */ +object MainProfileProdDocker extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Managed), Vector(RawRoleParams(ProfileRole.id))) + +/** + * Launch just the `profile` APIs with postgres repositories and external postgres service + * + * Equivalent to: + * {{{ + * ./launcher :profile + * }}} + */ +object MainProfileProd extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(ProfileRole.id))) + +/** + * Display help message with all available launcher arguments + * and command-line parameters for all roles + * + * Equivalent to: + * {{{ + * ./launcher :help + * }}} + */ +object MainHelp extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector(RawRoleParams(Help.id))) + +/** + * Write the default configuration files for each role into JSON files in `./config`. + * Configurations in @see {{{izumi.distage.config.ConfigModuleDef#makeConfig}}} + * are read from resources: + * + * - common-reference.conf - (configuration shared across all roles) + * - \${roleName}-reference.conf - (role-specific configuration, overrides `common`) + * + * Equivalent to: + * {{{ + * ./launcher :configwriter + * }}} + */ +object MainWriteReferenceConfigs + extends MainBase( + activation = { + Activation(Repo -> Repo.Prod, Scene -> Scene.Provided) + }, + requiredRoles = { + Vector( + RawRoleParams( + role = ConfigWriter.id, + roleParameters = RawEntrypointParams( + flags = Vector.empty, + // output configs in "hocon" format, instead of "json" + values = Vector(RawValue("format", "hocon")), + ), + freeArgs = Vector.empty, + ) + ) + }, + ) + +/** + * Generic launcher not set to run a specific role by default, + * use command-line arguments to choose one or multiple roles: + * + * {{{ + * + * # launch app with prod repositories + * + * ./launcher :leaderboard + * + * # launch app with dummy repositories + * + * ./launcher -u repo:dummy :leaderboard + * + * # launch just the ladder API, without profiles + * + * ./launcher :ladder + * + * # display help + * + * ./launcher :help + * + * # write configs in HOCON format to ./default-configs + * + * ./launcher :configwriter -format hocon -t default-configs + * + * # print help, dump configs and launch app with dummy repositories + * + * ./launcher -u repo:dummy :help :configwriter :leaderboard + * + * }}} + */ +object GenericLauncher extends MainBase(Activation(Repo -> Repo.Prod, Scene -> Scene.Provided), Vector.empty) + +sealed abstract class MainBase( + activation: Activation, + requiredRoles: Vector[RawRoleParams], +) extends RoleAppMain.LauncherCats[IO] { + + override def requiredRoles(argv: RoleAppMain.ArgV): Vector[RawRoleParams] = { + requiredRoles + } + + override def pluginConfig: PluginConfig = { + if (IzPlatform.isGraalNativeImage) { + // Only this would work reliably for NativeImage + PluginConfig.const(List(LeaderboardPlugin, PostgresDockerPlugin)) + } else { + // Runtime discovery with PluginConfig.cached might be convenient for pure jvm projects during active development + // Once the project gets to the maintenance stage it's a good idea to switch to PluginConfig.const + PluginConfig.cached(pluginsPackage = "leaderboard.plugins") + } + } + + protected override def roleAppBootOverrides(argv: RoleAppMain.ArgV): Module = super.roleAppBootOverrides(argv) ++ new ModuleDef { + make[Activation].named("default").fromValue(defaultActivation ++ activation) + } + + private def defaultActivation = Activation(Scene -> Scene.Provided) + +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/HttpApi.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/HttpApi.scala new file mode 100644 index 0000000..286af5b --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/HttpApi.scala @@ -0,0 +1,8 @@ +package leaderboard.api + +import cats.effect.IO +import org.http4s.HttpRoutes + +trait HttpApi { + def http: HttpRoutes[IO] +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/LadderApi.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/LadderApi.scala new file mode 100644 index 0000000..1fb14f2 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/LadderApi.scala @@ -0,0 +1,30 @@ +package leaderboard.api + +import cats.effect.IO +import io.circe.syntax.* +import leaderboard.repo.Ladder +import org.http4s.HttpRoutes +import org.http4s.circe.* +import org.http4s.dsl.Http4sDsl + +final class LadderApi( + dsl: Http4sDsl[IO], + ladder: Ladder, +) extends HttpApi { + + import dsl.* + + override def http: HttpRoutes[IO] = { + HttpRoutes.of { + case GET -> Root / "ladder" => + Ok(for { + res <- ladder.getScores + } yield res.asJson) + + case POST -> Root / "ladder" / UUIDVar(userId) / LongVar(score) => + Ok(for { + _ <- ladder.submitScore(userId, score) + } yield ()) + } + } +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/ProfileApi.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/ProfileApi.scala new file mode 100644 index 0000000..8b412e0 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/api/ProfileApi.scala @@ -0,0 +1,37 @@ +package leaderboard.api + +import cats.effect.IO +import io.circe.syntax.* +import leaderboard.model.UserProfile +import leaderboard.repo.Profiles +import leaderboard.services.Ranks +import logstage.LogIO +import org.http4s.HttpRoutes +import org.http4s.circe.* +import org.http4s.dsl.Http4sDsl + +final class ProfileApi( + dsl: Http4sDsl[IO], + profiles: Profiles, + ranks: Ranks, + log: LogIO[IO], +) extends HttpApi { + + import dsl.* + + override def http: HttpRoutes[IO] = { + HttpRoutes.of { + case GET -> Root / "profile" / UUIDVar(userId) => + Ok(for { + res <- ranks.getRank(userId) + } yield res.asJson) + + case rq @ POST -> Root / "profile" / UUIDVar(userId) => + Ok(for { + profile <- rq.decodeJson[UserProfile] + _ <- log.info(s"Saving $profile") + _ <- profiles.setProfile(userId, profile) + } yield ()) + } + } +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/config/PostgresCfg.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/config/PostgresCfg.scala new file mode 100644 index 0000000..891dccb --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/config/PostgresCfg.scala @@ -0,0 +1,8 @@ +package leaderboard.config + +final case class PostgresCfg( + jdbcDriver: String, + url: String, + user: String, + password: String, +) diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/config/PostgresPortCfg.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/config/PostgresPortCfg.scala new file mode 100644 index 0000000..fc97cde --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/config/PostgresPortCfg.scala @@ -0,0 +1,10 @@ +package leaderboard.config + +final case class PostgresPortCfg( + host: String, + port: Int, +) { + def substitute(s: String): String = { + s.replace("{host}", host).replace("{port}", port.toString) + } +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/http/HttpServer.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/http/HttpServer.scala new file mode 100644 index 0000000..591ac49 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/http/HttpServer.scala @@ -0,0 +1,32 @@ +package leaderboard.http + +import cats.effect.IO +import cats.implicits.* +import com.comcast.ip4s.Port +import izumi.distage.model.definition.Lifecycle +import leaderboard.api.HttpApi +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Server + +final case class HttpServer( + server: Server +) + +object HttpServer { + + final class Impl( + allHttpApis: Set[HttpApi] + ) extends Lifecycle.Of[IO, HttpServer]( + Lifecycle.fromCats { + val combinedApis = allHttpApis.map(_.http).toList.foldK + + EmberServerBuilder + .default[IO] + .withHttpApp(combinedApis.orNotFound) + .withPort(Port.fromInt(8080).get) + .build + .map(HttpServer(_)) + } + ) + +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/QueryFailure.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/QueryFailure.scala new file mode 100644 index 0000000..68ddb24 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/QueryFailure.scala @@ -0,0 +1,7 @@ +package leaderboard.model + +final case class QueryFailure(queryName: String, cause: Throwable) + extends RuntimeException( + s"""Query "$queryName" failed with ${cause.getMessage}""", + cause, + ) diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/RankedProfile.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/RankedProfile.scala new file mode 100644 index 0000000..fa158b3 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/RankedProfile.scala @@ -0,0 +1,15 @@ +package leaderboard.model + +import io.circe.Codec +import io.circe.generic.semiauto + +final case class RankedProfile( + name: String, + description: String, + rank: Int, + score: Score, +) + +object RankedProfile { + implicit val codec: Codec.AsObject[RankedProfile] = semiauto.deriveCodec +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/UserProfile.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/UserProfile.scala new file mode 100644 index 0000000..754fb85 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/UserProfile.scala @@ -0,0 +1,13 @@ +package leaderboard.model + +import io.circe.Codec +import io.circe.generic.semiauto + +final case class UserProfile( + name: String, + description: String, +) + +object UserProfile { + implicit val codec: Codec.AsObject[UserProfile] = semiauto.deriveCodec +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/package.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/package.scala new file mode 100644 index 0000000..24ae7cc --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/model/package.scala @@ -0,0 +1,8 @@ +package leaderboard + +import java.util.UUID + +package object model { + type UserId = UUID + type Score = Long +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala new file mode 100644 index 0000000..9bda537 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala @@ -0,0 +1,93 @@ +package leaderboard.plugins + +import cats.effect.IO +import distage.StandardAxis.Repo +import distage.config.ConfigModuleDef +import distage.plugins.PluginDef +import distage.{ModuleDef, Scene} +import doobie.util.transactor.Transactor +import izumi.distage.roles.bundled.BundledRolesModule +import izumi.distage.roles.model.definition.RoleModuleDef +import izumi.fundamentals.platform.integration.PortCheck +import leaderboard.api.{HttpApi, LadderApi, ProfileApi} +import leaderboard.config.{PostgresCfg, PostgresPortCfg} +import leaderboard.http.HttpServer +import leaderboard.repo.{Ladder, Profiles} +import leaderboard.services.Ranks +import leaderboard.sql.{SQL, TransactorResource} +import leaderboard.{LadderRole, LeaderboardRole, ProfileRole} +import org.http4s.dsl.Http4sDsl + +import scala.concurrent.duration.* + +object LeaderboardPlugin extends PluginDef { + include(modules.roles) + include(modules.api) + include(modules.repoDummy) + include(modules.repoProd) + include(modules.configs) + include(modules.prodConfigs) + + object modules { + def roles: RoleModuleDef = new RoleModuleDef { + // The `ladder` role + makeRole[LadderRole] + + // The `profile` role + makeRole[ProfileRole] + + // The composite `leaderboard` role that pulls in both `ladder` & `profile` roles + makeRole[LeaderboardRole] + + // Add bundled roles: `help` & `configwriter` + include(BundledRolesModule[IO](version = "1.0.0")) + } + + def api: ModuleDef = new ModuleDef { + // The `ladder` API + make[LadderApi] + // The `profile` API + make[ProfileApi] + + // A set of all APIs + many[HttpApi] + .weak[LadderApi] // add ladder API as a _weak reference_ + .weak[ProfileApi] // add profiles API as a _weak reference_ + + make[HttpServer].fromResource[HttpServer.Impl] + + make[Ranks].from[Ranks.Impl] + + makeTrait[Http4sDsl[IO]] + } + + def repoDummy: ModuleDef = new ModuleDef { + tag(Repo.Dummy) + + make[Ladder].fromResource[Ladder.Dummy] + make[Profiles].fromResource[Profiles.Dummy] + } + + def repoProd: ModuleDef = new ModuleDef { + tag(Repo.Prod) + + make[Ladder].fromResource[Ladder.Postgres] + make[Profiles].fromResource[Profiles.Postgres] + + make[SQL].from[SQL.Impl] + + make[Transactor[IO]].fromResource[TransactorResource] + make[PortCheck].from(new PortCheck(3.seconds)) + } + + val configs: ConfigModuleDef = new ConfigModuleDef { + makeConfig[PostgresCfg]("postgres") + } + val prodConfigs: ConfigModuleDef = new ConfigModuleDef { + // only use this if Scene axis is set to Provided + tag(Scene.Provided) + + makeConfig[PostgresPortCfg]("postgres") + } + } +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala new file mode 100644 index 0000000..697dbde --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala @@ -0,0 +1,32 @@ +package leaderboard.plugins + +import cats.effect.IO +import distage.Scene +import izumi.distage.docker.Docker.DockerPort +import izumi.distage.docker.bundled.PostgresDocker +import izumi.distage.docker.modules.DockerSupportModule +import izumi.distage.plugins.PluginDef +import leaderboard.config.PostgresPortCfg + +object PostgresDockerPlugin extends PluginDef { + // only enable postgres docker when Scene axis is set to Managed + tag(Scene.Managed) + + // add docker support dependencies + include(DockerSupportModule[IO]) + + // launch postgres docker for tests + make[PostgresDocker.Container] + .fromResource(PostgresDocker.make[IO]) + + // spawned docker container port is randomized + // to prevent conflicts, so make PostgresPortCfg + // point to the new port. This will also + // cause the container to start before + // integration check is performed + make[PostgresPortCfg].from { + (docker: PostgresDocker.Container) => + val knownAddress = docker.availablePorts.first(DockerPort.TCP(5432)) + PostgresPortCfg(knownAddress.hostString, knownAddress.port) + } +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/repo/Ladder.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/repo/Ladder.scala new file mode 100644 index 0000000..b97646a --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/repo/Ladder.scala @@ -0,0 +1,63 @@ +package leaderboard.repo + +import cats.effect.IO +import cats.implicits.* +import distage.Lifecycle +import doobie.postgres.implicits.* +import doobie.syntax.string.* +import leaderboard.model.{Score, UserId} +import leaderboard.sql.SQL +import logstage.LogIO + +import scala.collection.concurrent.TrieMap + +trait Ladder { + def submitScore(userId: UserId, score: Score): IO[Unit] + def getScores: IO[List[(UserId, Score)]] +} + +object Ladder { + final class Dummy + extends Lifecycle.LiftF[IO, Ladder](for { + state <- IO.pure(TrieMap.empty[UserId, Score]) + } yield { + new Ladder { + override def submitScore(userId: UserId, score: Score): IO[Unit] = + IO.pure(state.update(userId, score)) + + override def getScores: IO[List[(UserId, Score)]] = + IO.pure(state.toList.sortBy(_._2)(Ordering[Score].reverse)) + } + }) + + final class Postgres( + sql: SQL, + log: LogIO[IO], + ) extends Lifecycle.LiftF[IO, Ladder](for { + _ <- log.info(s"Creating Ladder table") + _ <- sql.execute("ladder-ddl") { + sql"""create table if not exists ladder ( + | user_id uuid not null, + | score bigint not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + res = new Ladder { + override def submitScore(userId: UserId, score: Score): IO[Unit] = + sql + .execute("submit-score") { + sql"""insert into ladder (user_id, score) values ($userId, $score) + |on conflict (user_id) do update set + | score = excluded.score + |""".stripMargin.update.run + }.void + + override val getScores: IO[List[(UserId, Score)]] = + sql.execute("get-leaderboard") { + sql"""select user_id, score from ladder order by score DESC + |""".stripMargin.query[(UserId, Score)].to[List] + } + } + } yield res) +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/repo/Profiles.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/repo/Profiles.scala new file mode 100644 index 0000000..84f62fd --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/repo/Profiles.scala @@ -0,0 +1,67 @@ +package leaderboard.repo + +import cats.effect.IO +import distage.Lifecycle +import doobie.postgres.implicits.* +import doobie.syntax.string.* +import leaderboard.model.{UserId, UserProfile} +import leaderboard.sql.SQL +import logstage.LogIO + +import scala.collection.concurrent.TrieMap + +trait Profiles { + def setProfile(userId: UserId, profile: UserProfile): IO[Unit] + def getProfile(userId: UserId): IO[Option[UserProfile]] +} + +object Profiles { + final class Dummy + extends Lifecycle.LiftF[IO, Profiles](for { + state <- IO.pure(TrieMap.empty[UserId, UserProfile]) + } yield { + new Profiles { + override def setProfile(userId: UserId, profile: UserProfile): IO[Unit] = + IO.pure(state.update(userId, profile)) + + override def getProfile(userId: UserId): IO[Option[UserProfile]] = + IO.pure(state.get(userId)) + } + }) + + final class Postgres( + sql: SQL, + log: LogIO[IO], + ) extends Lifecycle.LiftF[IO, Profiles](for { + _ <- log.info("Creating Profile table") + _ <- sql.execute("ddl-profiles") { + sql"""create table if not exists profiles ( + | user_id uuid not null, + | name text not null, + | description text not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + } yield new Profiles { + override def setProfile(userId: UserId, profile: UserProfile): IO[Unit] = { + sql + .execute("set-profile") { + sql"""insert into profiles (user_id, name, description) + |values ($userId, ${profile.name}, ${profile.description}) + |on conflict (user_id) do update set + | name = excluded.name, + | description = excluded.description + |""".stripMargin.update.run + } + }.void + + override def getProfile(userId: UserId): IO[Option[UserProfile]] = { + sql.execute("get-profile") { + sql"""select name, description from profiles + |where user_id = $userId + |""".stripMargin.query[UserProfile].option + } + } + }) +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/services/Ranks.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/services/Ranks.scala new file mode 100644 index 0000000..fbd8c90 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/services/Ranks.scala @@ -0,0 +1,34 @@ +package leaderboard.services + +import cats.effect.IO +import leaderboard.model.{RankedProfile, UserId} +import leaderboard.repo.{Ladder, Profiles} + +trait Ranks { + def getRank(userId: UserId): IO[Option[RankedProfile]] +} + +object Ranks { + final class Impl( + ladder: Ladder, + profiles: Profiles, + ) extends Ranks { + + override def getRank(userId: UserId): IO[Option[RankedProfile]] = + for { + maybeProfile <- profiles.getProfile(userId) + scores <- ladder.getScores + res = for { + profile <- maybeProfile + rank = scores.indexWhere(_._1 == userId) + 1 + score = scores.find(_._1 == userId).map(_._2) + } yield RankedProfile( + name = profile.name, + description = profile.description, + rank = rank, + score = score.getOrElse(0), + ) + } yield res + } + +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/sql/SQL.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/sql/SQL.scala new file mode 100644 index 0000000..a514a2c --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/sql/SQL.scala @@ -0,0 +1,22 @@ +package leaderboard.sql + +import cats.effect.IO +import doobie.free.connection.ConnectionIO +import doobie.util.transactor.Transactor +import leaderboard.model.QueryFailure + +trait SQL { + def execute[A](queryName: String)(conn: ConnectionIO[A]): IO[A] +} + +object SQL { + final class Impl( + transactor: Transactor[IO] + ) extends SQL { + override def execute[A](queryName: String)(conn: ConnectionIO[A]): IO[A] = { + transactor.trans + .apply(conn) + .handleErrorWith(ex => IO.raiseError(QueryFailure(queryName, ex))) + } + } +} diff --git a/distage-example-monomorphic-cats/src/main/scala/leaderboard/sql/TransactorResource.scala b/distage-example-monomorphic-cats/src/main/scala/leaderboard/sql/TransactorResource.scala new file mode 100644 index 0000000..64a2909 --- /dev/null +++ b/distage-example-monomorphic-cats/src/main/scala/leaderboard/sql/TransactorResource.scala @@ -0,0 +1,30 @@ +package leaderboard.sql + +import distage.{Id, Lifecycle} +import doobie.hikari.HikariTransactor +import izumi.distage.model.provisioning.IntegrationCheck +import izumi.fundamentals.platform.integration.{PortCheck, ResourceCheck} +import leaderboard.config.{PostgresCfg, PostgresPortCfg} +import cats.effect.IO + +import scala.concurrent.ExecutionContext + +final class TransactorResource( + cfg: PostgresCfg, + portCfg: PostgresPortCfg, + portCheck: PortCheck, + blockingExecutionContext: ExecutionContext @Id("io"), +) extends Lifecycle.OfCats( + HikariTransactor.newHikariTransactor[IO]( + driverClassName = cfg.jdbcDriver, + url = portCfg.substitute(cfg.url), + user = cfg.user, + pass = cfg.password, + connectEC = blockingExecutionContext, + ) + ) + with IntegrationCheck[IO] { + override def resourcesAvailable(): IO[ResourceCheck] = IO { + portCheck.checkPort(portCfg.host, portCfg.port, s"Couldn't connect to postgres at host=${portCfg.host} port=${portCfg.port}") + } +} diff --git a/distage-example-monomorphic-cats/src/test/scala/leaderboard/Rnd.scala b/distage-example-monomorphic-cats/src/test/scala/leaderboard/Rnd.scala new file mode 100644 index 0000000..9f2eb21 --- /dev/null +++ b/distage-example-monomorphic-cats/src/test/scala/leaderboard/Rnd.scala @@ -0,0 +1,20 @@ +package leaderboard + +import cats.effect.IO +import org.scalacheck.Gen.Parameters +import org.scalacheck.{Arbitrary, Prop} + +trait Rnd { + def apply[A: Arbitrary]: IO[A] +} + +object Rnd { + final class Impl extends Rnd { + override def apply[A: Arbitrary]: IO[A] = { + IO { + val (p, s) = Prop.startSeed(Parameters.default) + Arbitrary.arbitrary[A].pureApply(p, s) + } + } + } +} diff --git a/distage-example-monomorphic-cats/src/test/scala/leaderboard/WiringTest.scala b/distage-example-monomorphic-cats/src/test/scala/leaderboard/WiringTest.scala new file mode 100644 index 0000000..8d3d701 --- /dev/null +++ b/distage-example-monomorphic-cats/src/test/scala/leaderboard/WiringTest.scala @@ -0,0 +1,5 @@ +package leaderboard + +import izumi.distage.testkit.scalatest.SpecWiring + +final class WiringTest extends SpecWiring(GenericLauncher) diff --git a/distage-example-monomorphic-cats/src/test/scala/leaderboard/tests.scala b/distage-example-monomorphic-cats/src/test/scala/leaderboard/tests.scala new file mode 100644 index 0000000..1adc6b7 --- /dev/null +++ b/distage-example-monomorphic-cats/src/test/scala/leaderboard/tests.scala @@ -0,0 +1,179 @@ +package leaderboard + +import cats.effect.IO +import distage.{DIKey, ModuleDef, Scene} +import izumi.distage.model.definition.Activation +import izumi.distage.model.definition.StandardAxis.Repo +import izumi.distage.plugins.PluginConfig +import izumi.distage.testkit.scalatest.{AssertCIO, Spec1} +import leaderboard.model.* +import leaderboard.repo.{Ladder, Profiles} +import leaderboard.services.Ranks + +abstract class LeaderboardTest extends Spec1[IO] with AssertCIO { + override def config = super.config.copy( + pluginConfig = PluginConfig.cached(packagesEnabled = Seq("leaderboard.plugins")), + moduleOverrides = super.config.moduleOverrides ++ new ModuleDef { + make[Rnd].from[Rnd.Impl] + }, + // For testing, setup a docker container with postgres, + // instead of trying to connect to an external database + activation = Activation(Scene -> Scene.Managed), + // Instantiate Ladder & Profiles only once per test-run and + // share them and all their dependencies across all tests. + // this includes the Postgres Docker container above and table DDLs + memoizationRoots = Set( + DIKey[Ladder], + DIKey[Profiles], + ), + ) +} + +trait DummyTest extends LeaderboardTest { + override final def config = super.config.copy( + activation = super.config.activation ++ Activation(Repo -> Repo.Dummy) + ) +} + +trait ProdTest extends LeaderboardTest { + override final def config = super.config.copy( + activation = super.config.activation ++ Activation(Repo -> Repo.Prod) + ) +} + +final class LadderTestDummy extends LadderTest with DummyTest +final class ProfilesTestDummy extends ProfilesTest with DummyTest +final class RanksTestDummy extends RanksTest with DummyTest + +final class LadderTestPostgres extends LadderTest with ProdTest +final class ProfilesTestPostgres extends ProfilesTest with ProdTest +final class RanksTestPostgres extends RanksTest with ProdTest + +abstract class LadderTest extends LeaderboardTest { + + "Ladder" should { + + /** this test gets dependencies injected through function arguments */ + "submit & get" in { + (rnd: Rnd, ladder: Ladder) => + for { + user <- rnd[UserId] + score <- rnd[Score] + _ <- ladder.submitScore(user, score) + scores <- ladder.getScores + res = scores.find(_._1 == user).map(_._2) + _ <- assertIO(res contains score) + } yield () + } + + "assign a higher position in the list to a higher score" in { + (rnd: Rnd, ladder: Ladder) => + for { + user1 <- rnd[UserId] + score1 <- rnd[Score] + user2 <- rnd[UserId] + score2 <- rnd[Score] + + _ <- ladder.submitScore(user1, score1) + _ <- ladder.submitScore(user2, score2) + scores <- ladder.getScores + + user1Rank = scores.indexWhere(_._1 == user1) + user2Rank = scores.indexWhere(_._1 == user2) + + _ <- + if (score1 > score2) { + assertIO(user1Rank < user2Rank) + } else if (score2 > score1) { + assertIO(user2Rank < user1Rank) + } else IO.unit + } yield () + } + + } + +} + +abstract class ProfilesTest extends LeaderboardTest { + + "Profiles" should { + + "set & get" in { + (rnd: Rnd, profiles: Profiles) => + for { + user <- rnd[UserId] + name <- rnd[String] + desc <- rnd[String] + profile = UserProfile(name, desc) + _ <- profiles.setProfile(user, profile) + res <- profiles.getProfile(user) + _ <- assertIO(res contains profile) + } yield () + } + + } + +} + +abstract class RanksTest extends LeaderboardTest { + + "Ranks" should { + + "return 0 rank for a user with no score" in { + (rnd: Rnd, ranks: Ranks, profiles: Profiles) => + for { + user <- rnd[UserId] + name <- rnd[String] + desc <- rnd[String] + profile = UserProfile(name, desc) + _ <- profiles.setProfile(user, profile) + res1 <- ranks.getRank(user) + _ <- assertIO(res1.contains(RankedProfile(name, desc, 0, 0))) + } yield () + } + + "return None for a user with no profile" in { + (rnd: Rnd, ranks: Ranks, ladder: Ladder) => + for { + user <- rnd[UserId] + score <- rnd[Score] + _ <- ladder.submitScore(user, score) + res1 <- ranks.getRank(user) + _ <- assertIO(res1.isEmpty) + } yield () + } + + "assign a higher rank to a user with more score" in { + (rnd: Rnd, ranks: Ranks, ladder: Ladder, profiles: Profiles) => + for { + user1 <- rnd[UserId] + name1 <- rnd[String] + desc1 <- rnd[String] + score1 <- rnd[Score] + + user2 <- rnd[UserId] + name2 <- rnd[String] + desc2 <- rnd[String] + score2 <- rnd[Score] + + _ <- profiles.setProfile(user1, UserProfile(name1, desc1)) + _ <- ladder.submitScore(user1, score1) + + _ <- profiles.setProfile(user2, UserProfile(name2, desc2)) + _ <- ladder.submitScore(user2, score2) + + user1Rank <- ranks.getRank(user1).map(_.get.rank) + user2Rank <- ranks.getRank(user2).map(_.get.rank) + + _ <- + if (score1 > score2) { + assertIO(user1Rank < user2Rank) + } else if (score2 > score1) { + assertIO(user2Rank < user1Rank) + } else IO.unit + } yield () + } + + } + +} diff --git a/src/main/resources/META-INF/native-image/auto-cold/reflect-config.json b/graal-resources/META-INF/native-image/auto-cold/reflect-config.json similarity index 100% rename from src/main/resources/META-INF/native-image/auto-cold/reflect-config.json rename to graal-resources/META-INF/native-image/auto-cold/reflect-config.json diff --git a/src/main/resources/META-INF/native-image/auto-hot/proxy-config.json b/graal-resources/META-INF/native-image/auto-hot/proxy-config.json similarity index 100% rename from src/main/resources/META-INF/native-image/auto-hot/proxy-config.json rename to graal-resources/META-INF/native-image/auto-hot/proxy-config.json diff --git a/src/main/resources/META-INF/native-image/auto-hot/reflect-config.json b/graal-resources/META-INF/native-image/auto-hot/reflect-config.json similarity index 100% rename from src/main/resources/META-INF/native-image/auto-hot/reflect-config.json rename to graal-resources/META-INF/native-image/auto-hot/reflect-config.json diff --git a/src/main/resources/META-INF/native-image/auto/jni-config.json b/graal-resources/META-INF/native-image/auto/jni-config.json similarity index 100% rename from src/main/resources/META-INF/native-image/auto/jni-config.json rename to graal-resources/META-INF/native-image/auto/jni-config.json diff --git a/src/main/resources/META-INF/native-image/docker-java/reflect-config.json b/graal-resources/META-INF/native-image/docker-java/reflect-config.json similarity index 100% rename from src/main/resources/META-INF/native-image/docker-java/reflect-config.json rename to graal-resources/META-INF/native-image/docker-java/reflect-config.json diff --git a/src/main/resources/META-INF/native-image/leaderboard/native-image.properties b/graal-resources/META-INF/native-image/leaderboard/native-image.properties similarity index 100% rename from src/main/resources/META-INF/native-image/leaderboard/native-image.properties rename to graal-resources/META-INF/native-image/leaderboard/native-image.properties diff --git a/src/main/resources/META-INF/native-image/leaderboard/resource-config.json b/graal-resources/META-INF/native-image/leaderboard/resource-config.json similarity index 100% rename from src/main/resources/META-INF/native-image/leaderboard/resource-config.json rename to graal-resources/META-INF/native-image/leaderboard/resource-config.json diff --git a/launcher b/launcher index 16ef065..6855538 100755 --- a/launcher +++ b/launcher @@ -1,4 +1,5 @@ #!/usr/bin/env bash ARGS="$@" -sbt "runMain leaderboard.GenericLauncher $ARGS" + +sbt "project leaderboard-bifunctor-tf; runMain leaderboard.GenericLauncher $ARGS"