diff --git a/.scalafmt.conf b/.scalafmt.conf index 85a2f090..5ab2398e 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,10 +1,13 @@ -version=2.7.5 +version = 3.8.2 +runner.dialect = Scala3 +runner.dialectOverride.allowSignificantIndentation = false +runner.dialectOverride.allowQuietSyntax = true maxColumn = 120 style = IntelliJ align = most align.openParenCallSite = true -align.tokens.add = ["=",":"] +align.tokens."+" = ["=", ":"] assumeStandardLibraryStripMargin = true newlines.sometimesBeforeColonInMethodReturnType = false optIn.breakChainOnFirstMethodDot = false diff --git a/.sdkmanrc b/.sdkmanrc index 79ffbd7b..df7563ff 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,3 +1,3 @@ # Enable auto-env through the sdkman_auto_env config # Add key=value pairs of SDKs to use below -java=22.1.0.r17-grl +java=22.0.2-graalce diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..1613ee57 --- /dev/null +++ b/Justfile @@ -0,0 +1,53 @@ +# common variables + +set shell := ["zsh", "--login", "-c"] + +network-prefix := `basename "$PWD"` +config-dir := justfile_directory() + "/docker-compose" +dev-configs := "--file " + config-dir + "/monolith-deps.yml --file " + config-dir + "/monolith-setup.yml" +local-configs := dev-configs + " --file " + config-dir + "/monolith-app.yml" + +# fetch dependencies +pull: + docker compose --project-directory . {{dev-configs}} pull + +# dev environment to use with sbt +dev-bg: + docker compose --project-directory . {{dev-configs}} up --detach --quiet-pull +dev-up: + docker compose --project-directory . {{dev-configs}} up +dev-stop: + docker compose --project-directory . {{dev-configs}} stop +dev-down: + docker-compose --project-directory . {{dev-configs}} down --remove-orphans || \ + (docker container rm {{network-prefix}}_kafka_1 {{network-prefix}}_postgres_1 -f && \ + docker network disconnect {{network-prefix}}_branchtalk-monolith {{network-prefix}}_kafka_1 -f && \ + docker network disconnect {{network-prefix}}_branchtalk-monolith {{network-prefix}}_postgres_1 -f && \ + docker network rm {{network-prefix}}_branchtalk-monolith) +dev-ps: + docker compose --project-directory . {{dev-configs}} ps +dev-logs: + docker compose --project-directory . {{dev-configs}} logs -f ${LOGS} + +# whole monolithic app setup for e.g. local frontend development +local-bg: + (docker compose --project-directory . {{local-configs}} up --detach --quiet-pull) || (echo "publish application with sbt application/docker:publishLocal!") +local-up: + (docker compose --project-directory . {{local-configs}} up) || (echo "publish application with sbt application/docker:publishLocal!") +local-stop: + docker compose --project-directory . {{local-configs}} stop +local-down: + docker compose --project-directory . {{local-configs}} down --remove-orphans || \ + (docker container rm {{network-prefix}}_kafka_1 {{network-prefix}}_postgres_1 -f && \ + docker network disconnect {{network-prefix}}_branchtalk-monolith {{network-prefix}}_kafka_1 -f && \ + docker network disconnect {{network-prefix}}_branchtalk-monolith {{network-prefix}}_postgres_1 -f && \ + docker network rm {{network-prefix}}_branchtalk-monolith) +local-ps: + docker compose --project-directory . {{local-configs}} ps +local-logs: + docker compose --project-directory . {{local-configs}} logs -f ${LOGS} + +clean-volumes: + docker volume rm {{network-prefix}}_postgres_data -f + docker volume rm {{network-prefix}}_kafka_data -f + docker volume rm {{network-prefix}}_zookeeper_data -f diff --git a/branchtalk.sbt b/branchtalk.sbt index 848fc05a..a1d2c332 100644 --- a/branchtalk.sbt +++ b/branchtalk.sbt @@ -1,301 +1,461 @@ -import sbt._ -import Settings._ -import sbtcrossproject.CrossPlugin.autoImport.{ CrossType, crossProject } +import sbt.* +import commandmatrix.extra.* import com.typesafe.sbt.SbtNativePackager.Docker +import org.scalafmt.sbt.ScalafmtPlugin.scalafmtConfigSettings -Global / excludeLintKeys ++= Set(scalacOptions, trapExit) +Global / excludeLintKeys ++= Set( + dockerExposedPorts, + dockerUpdateLatest, + fork, + ideSkipProject, + libraryDependencies, + packageName, + scalacOptions, + trapExit +) -lazy val root = project.root - .setName("branchtalk") - .setDescription("branchtalk build") - .configureRoot - .aggregate( - commonJVM, - commonInfrastructure, - commonApiJVM, - discussionsJVM, - discussionsApiJVM, - discussionsImpl, - usersJVM, - usersApiJVM, - usersImpl, - server, - application +// Common settings: + +val only1VersionInIDE = Seq( + MatrixAction.ForPlatform(VirtualAxis.jvm).Configure(_.settings(ideSkipProject := false, bspEnabled := true)), + MatrixAction.ForPlatform(VirtualAxis.js).Configure(_.settings(ideSkipProject := true, bspEnabled := false)) +) + +val settings = Seq( + organization := "io.branchtalk", + scalaVersion := Dependencies.scalaVersion, + scalacOptions ++= Seq( + // standard settings + // format: off + "-encoding", "UTF-8", + "-rewrite", + "-source", "3.3-migration", + // format: on + "-unchecked", + "-deprecation", + // "-explaintypes", + "-feature", + "-no-indent", + // format: off + "-Xmax-inlines", "64", + // format: on + "-Wconf:msg=The method `apply` is inserted:s", + "-Wnonunit-statement", + "-Wvalue-discard", + // "-Xfatal-warnings", + "-Ykind-projector:underscores" + ), + console / scalacOptions --= Seq( + // warnings + "-Ywarn-unused", + // "-Wunused:imports", // import x.Underlying as X is marked as unused even though it is! probably one of https://github.com/scala/scala3/issues/: #18564, #19252, #19657, #19912 + "-Wunused:privates", + "-Wunused:locals", + "-Wunused:explicits", + "-Wunused:implicits", + "-Wunused:params", + "-Wvalue-discard", + "-Xfatal-warnings", + "-Xcheck-macros", + // advanced options + "-Xfatal-warnings", + // linting + "-Xlint" + ), + Global / cancelable := true, + Compile / trapExit := false, + Compile / connectInput := true, + Compile / outputStrategy := Some(StdoutOutput), + libraryDependencies ++= Seq( + Dependencies.cats, + Dependencies.catsFree, + Dependencies.catsEffect, + Dependencies.alleycats, + Dependencies.kittens, + Dependencies.chimney, + Dependencies.enumeratum, + Dependencies.fastuuid, + Dependencies.uuidGenerator, + Dependencies.log4cats, + Dependencies.log4catsSlf4j, + Dependencies.magnolia, + Dependencies.neotype, + Dependencies.scalaLogging, + Dependencies.quicklens, + Dependencies.logback + ), + Compile / scalafmtOnCompile := true, + Compile / compile / wartremoverWarnings ++= Warts.allBut( + Wart.Any, + Wart.DefaultArguments, + Wart.ExplicitImplicitTypes, + Wart.ImplicitConversion, + Wart.ImplicitParameter, + Wart.Overloading, + Wart.PublicInference, + Wart.NonUnitStatements, + Wart.Nothing + ), + // used A LOT in Specs2 + Test / scalacOptions ++= Seq( + "-Wconf:msg=unused value of type:s", + "-language:implicitConversions" + ), + // don't publish + publish / skip := true, + publishArtifact := false +) + +val tests = Seq( + libraryDependencies ++= Seq( + Dependencies.catsLaws % Test, + Dependencies.spec2Core % Test, + Dependencies.spec2Scalacheck % Test + ) +) ++ inConfig(Test)( + Seq( + scalafmtOnCompile := true, + testFrameworks := Seq(TestFrameworks.Specs2), + libraryDependencies ++= Seq( + Dependencies.catsLaws % Test, + Dependencies.spec2Core % Test, + Dependencies.spec2Scalacheck % Test + ) ) +) ++ inConfig(Test)(scalafmtConfigSettings) -lazy val scalaJsArtifacts = project.in(file("scala-js")) - .setName("scala-js-artifacts") - .setDescription("aggregates all Scala.js modules to publish") - .configureRoot - .aggregate( - commonMacrosJS, - commonJS, - commonApiMacrosJS, - commonApiJS, - discussionsJS, - discussionsApiJS, - usersJS, - usersApiJS +val integrationTests = tests ++ Seq( + Test / fork := true +) + +def customPredef(imports: String*): Def.Setting[Task[Seq[String]]] = + scalacOptions += s"-Yimports:${(Seq("java.lang", "scala", "scala.Predef") ++ imports).mkString(",")}" + +// modules + +lazy val root = project + .in(file(".")) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "branchtalk", + description := "branchtalk build" ) + .settings(settings *) + .aggregate(common.projectRefs *) + .aggregate(commonApi.projectRefs *) + .aggregate(commonInfrastructure) + .aggregate(discussions.projectRefs *) + .aggregate(discussionsApi.projectRefs *) + .aggregate(users.projectRefs *) + .aggregate(usersApi.projectRefs *) + .aggregate(usersImpl) + .aggregate(server, application) -addCommandAlias("fmt", ";scalafmt;Test/scalafmt;It/scalafmt") -addCommandAlias("fullTest", ";test;It/test") -addCommandAlias("fullCoverageTest", ";coverage;test;It/test;coverageReport;coverageAggregate") +lazy val scalaJsArtifacts = project + .in(file("scala-js")) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "scala-js-artifacts", + description := "aggregates all Scala.js modules to publish" + ) + .settings(settings *) + .aggregate( + common.js(Dependencies.scalaVersion), + commonApi.js(Dependencies.scalaVersion), + discussions.js(Dependencies.scalaVersion), + discussionsApi.js(Dependencies.scalaVersion), + users.js(Dependencies.scalaVersion), + usersApi.js(Dependencies.scalaVersion) + ) // commons -val commonMacros = - crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("common-macros") - .setName("common-macros") - .setDescription("Common macro definitions") - .configureModule -val commonMacrosJVM = commonMacros.jvm -val commonMacrosJS = commonMacros.js - -val common = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("common") - .setName("common") - .setDescription("Common utilities") - .configureModule +val common = projectMatrix + .in(file("modules/common")) + .someVariations(List(Dependencies.scalaVersion), List(VirtualAxis.jvm, VirtualAxis.js))(only1VersionInIDE *) + .defaultAxes(VirtualAxis.scalaABIVersion(Dependencies.scalaVersion), VirtualAxis.jvm) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "common", + description := "Common utilities" + ) + .settings(settings *) + .settings(tests *) .settings( libraryDependencies ++= Seq( Dependencies.avro4s, - Dependencies.avro4sRefined, - Dependencies.catnip, + Dependencies.avro4sCats, Dependencies.sourcecode, Dependencies.jfairy % Test, Dependencies.guice % Test, // required by jfairy on JDK 15+ Dependencies.guiceAssisted % Test // required by jfairy on JDK 15+ ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits") ) + +val commonApi = projectMatrix + .someVariations(List(Dependencies.scalaVersion), List(VirtualAxis.jvm, VirtualAxis.js))(only1VersionInIDE *) + .defaultAxes(VirtualAxis.scalaABIVersion(Dependencies.scalaVersion), VirtualAxis.jvm) + .enablePlugins(GitVersioning, GitBranchPrompt) + .in(file("modules/common-api")) .settings( - Compile / resourceGenerators += task[Seq[File]] { - val file = (Compile / resourceManaged).value / "branchtalk-version.conf" - IO.write( - file, - s"""# Populated by the build tool, used by e.g. OpenAPI to display version. - |branchtalk-build { - | version = "${version.value}" - | commit = "${git.gitHeadCommit.value.getOrElse("null")}" - | date = "${git.gitHeadCommitDate.value.getOrElse("null")}" - |}""".stripMargin - ) - Seq(file) - } + name := "common-api", + description := "Infrastructure-dependent implementations" + ) + .settings(settings *) + .settings(tests *) + .settings( + libraryDependencies ++= Seq( + Dependencies.jsoniter, + Dependencies.jsoniterMacro, + Dependencies.neotypeTapir, + Dependencies.neotypeJsoniter, + Dependencies.tapir, + Dependencies.tapirJsoniter + ), + customPredef("scala.util.chaining", "cats.implicits", "neotype") ) - .dependsOn(commonMacros) -val commonJVM = common.jvm -val commonJS = common.js + .dependsOn(common) val commonInfrastructure = project - .from("common-infrastructure") - .setName("common-infrastructure") - .setDescription("Infrastructure-dependent implementations") - .configureModule - .configureIntegrationTests() + .in(file("modules/common-infrastructure")) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "common-infrastructure", + description := "Infrastructure-dependent implementations" + ) + .settings(settings *) + .settings(integrationTests *) .settings( libraryDependencies ++= Seq( Dependencies.doobie, Dependencies.doobieHikari, Dependencies.doobiePostgres, - Dependencies.doobieRefined, + Dependencies.enumeratumDoobie, Dependencies.flyway, + Dependencies.flywayPostgres, Dependencies.fs2, Dependencies.fs2IO, Dependencies.fs2Kafka, + Dependencies.neotypeDoobie, Dependencies.prometheus, Dependencies.pureConfig, Dependencies.pureConfigCats, Dependencies.pureConfigEnumeratum, - Dependencies.redis4cats, - Dependencies.refinedPureConfig + Dependencies.redis4cats ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype") ) - .dependsOn(commonJVM) + .dependsOn(common.jvm(Dependencies.scalaVersion)) -val commonApiMacros = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("common-api-macros") - .setName("common-api-macros") - .setDescription("Common API macro definitions") - .configureModule -val commonApiMacrosJVM = commonApiMacros.jvm -val commonApiMacrosJS = commonApiMacros.js +// discussions -val commonApi = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("common-api") - .setName("common-api") - .setDescription("Infrastructure-dependent implementations") - .configureModule - .configureTests() +val discussions = projectMatrix + .someVariations(List(Dependencies.scalaVersion), List(VirtualAxis.jvm, VirtualAxis.js))(only1VersionInIDE *) + .defaultAxes(VirtualAxis.scalaABIVersion(Dependencies.scalaVersion), VirtualAxis.jvm) + .enablePlugins(GitVersioning, GitBranchPrompt) + .in(file("modules/discussions")) .settings( - libraryDependencies ++= Seq( - Dependencies.jsoniter, - Dependencies.jsoniterMacro, - Dependencies.tapir, - Dependencies.tapirJsoniter, - Dependencies.tapirRefined - ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + name := "discussions", + description := "Discussions' published language" ) - .dependsOn(common, commonApiMacros) -val commonApiJVM = commonApi.jvm -val commonApiJS = commonApi.js - -// discussions - -val discussions = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("discussions") - .setName("discussions") - .setDescription("Discussions' published language") - .configureModule - .configureTests() + .settings(settings) + .settings(tests) .settings( - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype") ) .dependsOn(common) -val discussionsJVM = discussions.jvm -val discussionsJS = discussions.js -val discussionsApi = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("discussions-api") - .setName("discussions-api") - .setDescription("Discussions' HTTP API") - .configureModule - .configureTests() +val discussionsApi = projectMatrix + .someVariations(List(Dependencies.scalaVersion), List(VirtualAxis.jvm, VirtualAxis.js))(only1VersionInIDE *) + .defaultAxes(VirtualAxis.scalaABIVersion(Dependencies.scalaVersion), VirtualAxis.jvm) + .enablePlugins(GitVersioning, GitBranchPrompt) + .in(file("modules/discussions-api")) + .settings( + name := "discussions-api", + description := "Discussions' HTTP API" + ) + .settings(settings) + .settings(tests) .settings( libraryDependencies ++= Seq( Dependencies.jsoniterMacro ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype") ) .dependsOn(commonApi, discussions) -val discussionsApiJVM = discussionsApi.jvm -val discussionsApiJS = discussionsApi.js val discussionsImpl = project - .from("discussions-impl") - .setName("discussions-impl") - .setDescription("Discussions' Reads, Writes and Services' implementations") - .configureModule - .configureIntegrationTests(requiresFork = true) + .in(file("modules/discussions-impl")) + .enablePlugins(GitVersioning, GitBranchPrompt) .settings( - libraryDependencies += Dependencies.macwire, - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + name := "discussions-impl", + description := "Discussions' Reads, Writes and Services' implementations" + ) + .settings(settings) + .settings(integrationTests) + .settings( + customPredef("scala.util.chaining", "cats.implicits", "neotype") + ) + .dependsOn( + common.jvm(Dependencies.scalaVersion) % s"$Compile->$Compile ; $Test->$Test", + commonInfrastructure % s"$Compile->$Compile ; $Test->$Test", + discussions.jvm(Dependencies.scalaVersion) ) - .compileAndTestDependsOn(commonInfrastructure) - .dependsOn(discussionsJVM, commonJVM % "compile->compile;it->test") // users -val users = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("users") - .setName("users") - .setDescription("Users' published language") - .configureModule - .configureTests() +val users = projectMatrix + .in(file("modules/users")) + .someVariations(List(Dependencies.scalaVersion), List(VirtualAxis.jvm, VirtualAxis.js))(only1VersionInIDE *) + .defaultAxes(VirtualAxis.scalaABIVersion(Dependencies.scalaVersion), VirtualAxis.jvm) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "users", + description := "Users' published language" + ) + .settings(settings) + .settings(tests) .settings( libraryDependencies ++= Seq( Dependencies.bcrypt ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype") ) .dependsOn(common) -val usersJVM = users.jvm -val usersJS = users.js -val usersApi = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .build - .from("users-api") - .setName("users-api") - .setDescription("Users' HTTP API") - .configureModule - .configureTests() +val usersApi = projectMatrix + .in(file("modules/users-api")) + .someVariations(List(Dependencies.scalaVersion), List(VirtualAxis.jvm, VirtualAxis.js))(only1VersionInIDE *) + .defaultAxes(VirtualAxis.scalaABIVersion(Dependencies.scalaVersion), VirtualAxis.jvm) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "users-api", + description := "Users' HTTP API" + ) + .settings(settings) + .settings(tests) .settings( libraryDependencies ++= Seq( Dependencies.jsoniterMacro ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype") ) .dependsOn(commonApi, users) -val usersApiJVM = usersApi.jvm -val usersApiJS = usersApi.js val usersImpl = project - .from("users-impl") - .setName("users-impl") - .setDescription("Users' Reads, Writes and Services' implementations") - .configureModule - .configureIntegrationTests(requiresFork = true) + .in(file("modules/users-impl")) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "users-impl", + description := "Users' Reads, Writes and Services' implementations" + ) + .settings(settings) + .settings(integrationTests) .settings( libraryDependencies ++= Seq( Dependencies.jsoniter, - Dependencies.jsoniterMacro, - Dependencies.macwire + Dependencies.jsoniterMacro ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype") + ) + .dependsOn( + common.jvm(Dependencies.scalaVersion) % s"$Compile->$Compile ; $Test->$Test", + commonInfrastructure % s"$Compile->$Compile ; $Test->$Test", + discussions.jvm(Dependencies.scalaVersion), + users.jvm(Dependencies.scalaVersion) ) - .compileAndTestDependsOn(commonInfrastructure) - .dependsOn(usersJVM, discussionsJVM, commonJVM % "compile->compile;it->test") // application val server = project - .from("server") - .setName("server") - .setDescription("Branchtalk backend business logic") - .configureModule - .configureIntegrationTests(requiresFork = true) + .in(file("modules/server")) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "server", + description := "Branchtalk backend business logic" + ) + .settings(settings) + .settings(integrationTests) .settings( libraryDependencies ++= Seq( Dependencies.decline, - Dependencies.refinedDecline, Dependencies.jsoniterMacro, - Dependencies.sttpCats % IntegrationTest, + Dependencies.sttpCats % Test, Dependencies.http4sBlaze, Dependencies.http4sPrometheus, Dependencies.tapirHttp4s, Dependencies.tapirOpenAPI, Dependencies.tapirSwaggerUI, - Dependencies.tapirSTTP % IntegrationTest, - Dependencies.macwire + Dependencies.tapirSTTP % Test ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype"), + Compile / resourceGenerators += task[Seq[File]] { + val file = (Compile / resourceManaged).value / "branchtalk-version.conf" + IO.write( + file, + s"""# Populated by the build tool, used by e.g. OpenAPI to display version. + |branchtalk-build { + | version = "${version.value}" + | commit = "${git.gitHeadCommit.value.getOrElse("null")}" + | date = "${git.gitHeadCommitDate.value.getOrElse("null")}" + |}""".stripMargin + ) + Seq(file) + } + ) + .dependsOn( + commonInfrastructure, + discussions.jvm(Dependencies.scalaVersion), + discussionsApi.jvm(Dependencies.scalaVersion), + discussionsImpl % s"$Test->$Test", + users.jvm(Dependencies.scalaVersion), + usersApi.jvm(Dependencies.scalaVersion), + usersImpl % s"$Test->$Test" ) - .dependsOn(commonInfrastructure, discussionsJVM, usersJVM, discussionsApiJVM, usersApiJVM) - .dependsOn(discussionsImpl % "it->it", usersImpl % "it->it") val application = project - .from("app") - .setName("app") - .setDescription("Branchtalk backend application") - .configureModule - .configureRun("io.branchtalk.Main") + .in(file("modules/app")) + .enablePlugins(GitVersioning, GitBranchPrompt) + .settings( + name := "app", + description := "Branchtalk backend application" + ) + .settings(settings) .settings( + Compile / run / mainClass := Some("io.branchtalk.Main"), + Compile / run / fork := true, + Compile / runMain / fork := true, dockerUpdateLatest := true, Docker / packageName := "branchtalk-server", Docker / dockerExposedPorts := Seq(8080), libraryDependencies ++= Seq( Dependencies.logbackJackson, - Dependencies.logbackJsonClassic, + Dependencies.logbackJsonClassic ), - customPredef("scala.util.chaining", "cats.implicits", "eu.timepit.refined.auto") + customPredef("scala.util.chaining", "cats.implicits", "neotype") + ) + .settings( + inTask(assembly)( + Seq( + assemblyJarName := s"${name.value}.jar", + assemblyMergeStrategy := { + // required for OpenAPIServer to work + case PathList("META-INF", "maven", "org.webjars", "swagger-ui", "pom.properties") => + MergeStrategy.singleOrError + // conflicts on random crap + case "module-info.class" => MergeStrategy.discard + // otherwise + case strategy => MergeStrategy.defaultMergeStrategy(strategy) + }, + mainClass := Some("io.branchtalk.Main") + ) + ) ) .dependsOn(server, discussionsImpl, usersImpl) + +// aliases + +addCommandAlias("fmt", "scalafmt ; Test/scalafmt") +addCommandAlias("fullTest", "test") +addCommandAlias("fullCoverageTest", "coverage ; test ; coverageReport ; coverageAggregate") diff --git a/modules/app/src/main/scala/cats/effect/IOLocalHack.scala b/modules/app/src/main/scala/cats/effect/IOLocalHack.scala index 627895cf..efc466ec 100644 --- a/modules/app/src/main/scala/cats/effect/IOLocalHack.scala +++ b/modules/app/src/main/scala/cats/effect/IOLocalHack.scala @@ -3,5 +3,5 @@ package cats.effect /** Hack allowing us to access the whole content of cats.effect.IOLocalState */ object IOLocalHack { - def get: IO[scala.collection.immutable.Map[IOLocal[_], Any]] = IO.Local(state => (state, state)) + def get: IO[scala.collection.immutable.Map[IOLocal[?], Any]] = IO.Local(state => (state, state)) } diff --git a/modules/app/src/main/scala/io/branchtalk/Program.scala b/modules/app/src/main/scala/io/branchtalk/Program.scala index 614329ba..c7c2feb1 100644 --- a/modules/app/src/main/scala/io/branchtalk/Program.scala +++ b/modules/app/src/main/scala/io/branchtalk/Program.scala @@ -2,34 +2,34 @@ package io.branchtalk import cats.{ Functor, Monad } import cats.effect.{ Async, ExitCode, Resource, Sync } -import cats.effect.implicits._ +import cats.effect.implicits.* import cats.effect.std.Dispatcher import com.typesafe.config.ConfigFactory import io.branchtalk.api.AppServer import io.branchtalk.configs.{ APIConfig, AppArguments, Configuration } import io.branchtalk.discussions.events.DiscussionEvent import io.branchtalk.discussions.{ DiscussionsModule, DiscussionsReads, DiscussionsWrites } -import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure.{ ConsumerStream, DomainConfig, StreamRunner } -import io.branchtalk.shared.model.{ Logger, UUIDGenerator } +import io.branchtalk.logging.* +import io.branchtalk.shared.infrastructure.* +import io.branchtalk.shared.model.UUID import io.branchtalk.users.{ UsersModule, UsersReads, UsersWrites } import io.prometheus.client.CollectorRegistry import org.http4s.metrics.prometheus.Prometheus -import sun.misc.Signal // scalastyle:ignore illegal.imports +import sun.misc.Signal object Program { - implicit protected val uuidGenerator: UUIDGenerator = UUIDGenerator.FastUUIDGenerator + protected given UUID.Generator = UUID.FastGenerator def runApplication[F[_]: Async: MDC](args: List[String]): F[ExitCode] = (for { - implicit0(logger: Logger[F]) <- Logger.create[F] + given Logger[F] <- Logger.create[F] env <- Configuration.getEnv[F] appArguments <- AppArguments.parse[F](args, env) - _ <- logger.info(show"Arguments passed: $appArguments") + _ <- Logger[F].info(show"Arguments passed: $appArguments") _ <- if (appArguments.isAnythingRun) initializeAndRunModules[F](appArguments) - else logger.warn("Nothing to run, see --help for information how to turn on API server and projections") + else Logger[F].warn("Nothing to run, see --help for information how to turn on API server and projections") } yield ExitCode.Success).handleError { case noConfig @ AppArguments.NoConfig(help) => if (help.errors.nonEmpty) noConfig.printError() @@ -39,23 +39,22 @@ object Program { ExitCode.Error } - def resolveConfigs[F[_]: Sync](implicit logger: Logger[F]): F[(APIConfig, DomainConfig, DomainConfig)] = for { - apiConfig <- Configuration.readConfig[F, APIConfig]("api") - _ <- logger.info(show"App configs resolved to: ${apiConfig}") - usersConfig <- Configuration.readConfig[F, DomainConfig]("users") - _ <- logger.info(show"Users configs resolved to: ${usersConfig}") - discussionsConfig <- Configuration.readConfig[F, DomainConfig]("discussions") - _ <- logger.info(show"Discussions configs resolved to: ${discussionsConfig}") - } yield (apiConfig, usersConfig, discussionsConfig) + def resolveConfigs[F[_]: Sync: Logger]: F[(APIConfig, DomainModule.Config, DomainModule.Config)] = + for { + apiConfig <- Configuration.readConfig[F, APIConfig]("api") + _ <- Logger[F].info(show"App configs resolved to: ${apiConfig}") + usersConfig <- Configuration.readConfig[F, DomainModule.Config]("users") + _ <- Logger[F].info(show"Users configs resolved to: ${usersConfig}") + discussionsConfig <- Configuration.readConfig[F, DomainModule.Config]("discussions") + _ <- Logger[F].info(show"Discussions configs resolved to: ${discussionsConfig}") + } yield (apiConfig, usersConfig, discussionsConfig) - def initializeAndRunModules[F[_]: Async: MDC]( - appArguments: AppArguments - )(implicit logger: Logger[F]): F[Unit] = { + def initializeAndRunModules[F[_]: Async: MDC: Logger](appArguments: AppArguments): F[Unit] = { for { - implicit0(dispacher: Dispatcher[F]) <- Dispatcher[F] + given Dispatcher[F] <- Dispatcher.parallel[F] (apiConfig, usersConfig, discussionsConfig) <- Resource.eval(resolveConfigs[F]) registry <- Prometheus.collectorRegistry[F] - modules <- Resource.make(logger.info("Initializing services"))(_ => logger.info("Services shut down")) >> + modules <- Resource.make(Logger[F].info("Initializing services"))(_ => Logger[F].info("Services shut down")) >> ( registry.pure[Resource[F, *]], UsersModule.reads[F](usersConfig, registry), @@ -70,8 +69,7 @@ object Program { run.tupled(modules) } - // scalastyle:off method.length parameter.number - def runModules[F[_]: Async: MDC]( + def runModules[F[_]: Async: MDC: Logger]( appArguments: AppArguments, apiConfig: APIConfig, terminationSignal: F[Unit], @@ -85,7 +83,7 @@ object Program { usersWrites: UsersWrites[F], discussionsReads: DiscussionsReads[F], discussionsWrites: DiscussionsWrites[F] - )(implicit logger: Logger[F]): F[Unit] = { + ): F[Unit] = { ( AppServer .asResource( @@ -121,7 +119,6 @@ object Program { ) ).tupled >> logBeforeAfter[F]("Services initialized", "Received exit signal") }.use(_ => terminationSignal) // here we are blocking until e.g. user press Ctrl+C - // scalastyle:on parameter.number method.length // kudos to Łukasz Byczyński private def awaitTerminationSignal[F[_]: Async]: F[Unit] = { @@ -132,16 +129,16 @@ object Program { handleSignal("INT").race(handleSignal("TERM")).void } - private def logBeforeAfter[F[_]: Functor](before: String, after: String)(implicit logger: Logger[F]) = - Resource.make(logger.info(before))(_ => logger.info(after)) + private def logBeforeAfter[F[_]: Functor](before: String, after: String)(using logger: Logger[F]) = + Resource.make(Logger[F].info(before))(_ => logger.info(after)) - implicit class ResourceOps[F[_]](private val resource: Resource[F, Unit]) extends AnyVal { + extension [F[_]: Monad: Logger](resource: Resource[F, Unit]) { - def conditionally(name: String, condition: Boolean)(implicit F: Monad[F], logger: Logger[F]): Resource[F, Unit] = + def conditionally(name: String, condition: Boolean): Resource[F, Unit] = if (condition) { logBeforeAfter[F](s"Starting $name", s"$name shutdown completed") >> resource >> logBeforeAfter[F](s"$name start completed", s"Shutting down $name") - } else Resource.eval(logger.info(s"$name disabled - omitting")) + } else Resource.eval(Logger[F].info(s"$name disabled - omitting")) } } diff --git a/modules/app/src/main/scala/io/branchtalk/logging/IOGlobal.scala b/modules/app/src/main/scala/io/branchtalk/logging/IOGlobal.scala index 810e48f5..155a5cbd 100644 --- a/modules/app/src/main/scala/io/branchtalk/logging/IOGlobal.scala +++ b/modules/app/src/main/scala/io/branchtalk/logging/IOGlobal.scala @@ -7,16 +7,16 @@ import scala.concurrent.ExecutionContext import scala.concurrent.duration.FiniteDuration /** Hack allowing us to: - * - read whole content from `IO.Local` on every call that process user provided function through IOLocalHack - * - put that content into `IOGlobal.threadLocal` so that unsafe functions integrating with no-FP code could - * read it through `IOGlobal.getCurrent(ioLocal)` + * - read whole content from `IO.Local` on every call that process user provided function through IOLocalHack + * - put that content into `IOGlobal.threadLocal` so that unsafe functions integrating with no-FP code could read it + * through `IOGlobal.getCurrent(ioLocal)` * - * Requires passing `IOGlobal.configuredStatePropagation` instead of `IO.asyncForIO` into tagless final code. + * Requires passing `IOGlobal.configuredStatePropagation` instead of `IO.asyncForIO` into tagless final code. */ object IOGlobal { - private val threadLocal: ThreadLocal[scala.collection.immutable.Map[IOLocal[_], Any]] = - ThreadLocal.withInitial(() => scala.collection.immutable.Map.empty[IOLocal[_], Any]) + private val threadLocal: ThreadLocal[scala.collection.immutable.Map[IOLocal[?], Any]] = + ThreadLocal.withInitial(() => scala.collection.immutable.Map.empty[IOLocal[?], Any]) private def propagateState[A](thunk: => IO[A]): IO[A] = IOLocalHack.get.flatMap { state => threadLocal.set(state); thunk } diff --git a/modules/app/src/main/scala/io/branchtalk/logging/IOMDCAdapter.scala b/modules/app/src/main/scala/io/branchtalk/logging/IOMDCAdapter.scala index 297a17f0..d5d5f9ee 100644 --- a/modules/app/src/main/scala/io/branchtalk/logging/IOMDCAdapter.scala +++ b/modules/app/src/main/scala/io/branchtalk/logging/IOMDCAdapter.scala @@ -2,30 +2,33 @@ package io.branchtalk.logging import cats.effect.{ IO, IOLocal } -import java.{ util => ju } -import ch.qos.logback.classic.util.LogbackMDCAdapter +import java.util as ju +import org.slf4j.spi.MDCAdapter -import scala.jdk.CollectionConverters._ +import scala.jdk.CollectionConverters.* // Based on solution described by OlegPy in https://olegpy.com/better-logging-monix-1/ // Using experimental hack: https://gist.github.com/MateuszKubuszok/d506706ee3c9b4c2291d47279f619523 -final class IOMDCAdapter(local: IOLocal[MDC.Ctx]) extends LogbackMDCAdapter { +final class IOMDCAdapter(local: IOLocal[MDC.Ctx]) extends MDCAdapter { + // TODO: IOLocal[Map[String, List]] - private def getMDC: MDC.Ctx = IOGlobal.getCurrent(local).getOrElse(Map.empty[String, String]) - private def setMDC(mdc: MDC.Ctx): Unit = IOGlobal.setTemporarily(local, mdc) - private def update(f: MDC.Ctx => MDC.Ctx): Unit = setMDC(f(getMDC)) + private def getMDC: MDC.Ctx = IOGlobal.getCurrent(local).getOrElse(Map.empty[String, String]) + private def setMDC(mdc: MDC.Ctx): Unit = IOGlobal.setTemporarily(local, mdc) + private def update(f: MDC.Ctx => MDC.Ctx): Unit = setMDC(f(getMDC)) + override def put(key: String, `val`: String): Unit = update(_.updated(key, `val`)) @SuppressWarnings(Array("org.wartremover.warts.Null")) // talking to Java interface override def get(key: String): String = getMDC.get(key).orNull - override def put(key: String, `val`: String): Unit = update(_.updated(key, `val`)) - override def remove(key: String): Unit = update(_.removed(key)) + override def remove(key: String): Unit = update(_.removed(key)) + override def clear(): Unit = setMDC(Map.empty) - override def clear(): Unit = setMDC(Map.empty) override def getCopyOfContextMap: ju.Map[String, String] = getMDC.asJava - override def setContextMap(contextMap: ju.Map[String, String]): Unit = setMDC(contextMap.asScala.toMap) + override def setContextMap(contextMap: ju.Map[String, String] @unchecked): Unit = setMDC(contextMap.asScala.toMap) - override def getPropertyMap: ju.Map[String, String] = getMDC.asJava - override def getKeys: ju.Set[String] = getMDC.asJava.keySet() + override def pushByKey(key: String, value: String): Unit = ??? + override def popByKey(key: String): String = ??? + override def getCopyOfDequeByKey(key: String): ju.Deque[String] = ??? + override def clearDequeByKey(key: String): Unit = ??? } object IOMDCAdapter { @@ -38,7 +41,7 @@ object IOMDCAdapter { classOf[org.slf4j.MDC] .getDeclaredField("mdcAdapter") .tap(_.setAccessible(true)) - .set(null, new IOMDCAdapter(local)) // scalastyle:ignore null + .set(null, new IOMDCAdapter(local)) } } yield new IOMDC(local) } diff --git a/modules/common-api-macros/src/main/resources/derive.semi.conf b/modules/common-api-macros/src/main/resources/derive.semi.conf deleted file mode 100644 index aaef4d08..00000000 --- a/modules/common-api-macros/src/main/resources/derive.semi.conf +++ /dev/null @@ -1,4 +0,0 @@ -# wire Jsoniter Scala type classes for Catnip @Semi -com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec=com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker.make -# wire Tapir type classes for Catnip @Semi -sttp.tapir.Schema=sttp.tapir.Schema.derived diff --git a/modules/common-api-macros/src/main/resources/derive.stub.conf b/modules/common-api-macros/src/main/resources/derive.stub.conf deleted file mode 100644 index a0973cb9..00000000 --- a/modules/common-api-macros/src/main/resources/derive.stub.conf +++ /dev/null @@ -1,3 +0,0 @@ -# allows JsCodec object as param of Catnip @Semi for Jsoniter Scala -io.branchtalk.api.JsoniterSupport.JsCodec=com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec - diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/Authentication.scala b/modules/common-api/src/main/scala/io/branchtalk/api/Authentication.scala index 4c8dd593..4022a09e 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/Authentication.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/Authentication.scala @@ -1,16 +1,11 @@ package io.branchtalk.api -import io.branchtalk.ADT - -sealed trait Authentication extends ADT { +enum Authentication { + case Session(sessionID: SessionID) + case Credentials(username: Username, password: Password) def fold[B](session: SessionID => B, credentials: (Username, Password) => B): B = this match { case Authentication.Session(sessionID) => session(sessionID) case Authentication.Credentials(username, password) => credentials(username, password) } } -object Authentication { - - final case class Session(sessionID: SessionID) extends Authentication - final case class Credentials(username: Username, password: Password) extends Authentication -} diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/AuthenticationSupport.scala b/modules/common-api/src/main/scala/io/branchtalk/api/AuthenticationSupport.scala index b460713d..0bb33893 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/AuthenticationSupport.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/AuthenticationSupport.scala @@ -1,54 +1,44 @@ package io.branchtalk.api import java.util.Base64 -import cats.effect.SyncIO -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty +import cats.effect.{ GenSpawn, SyncIO } import io.branchtalk.api.Authentication.{ Credentials, Session } -import io.branchtalk.shared.model.{ ParseRefined, UUIDGenerator, branchtalkCharset } -import io.branchtalk.shared.model.UUIDGenerator.FastUUIDGenerator -import sttp.tapir._ +import io.branchtalk.shared.model.* +import sttp.tapir.* import scala.util.Try // Authentication-related definitions for Tapir. object AuthenticationSupport { - private object base64 { // scalastyle:ignore object.name - def apply(string: String): String = Base64.getEncoder.encodeToString(string.getBytes(branchtalkCharset)) + private object base64 { + def apply(string: String): String = Base64.getEncoder.encodeToString(string.getBytes(branchtalkCharset)) def unapply(string: String): Option[String] = Try(new String(Base64.getDecoder.decode(string), branchtalkCharset)).toOption } - private val basicR = raw"Basic (.+)".r - private val upR = raw"([^:]+):(.+)".r - private object basic { // scalastyle:ignore object.name - def apply(username: String, password: Array[Byte] Refined NonEmpty): String = - s"""Basic ${base64(s"${username}:${new String(password.value, branchtalkCharset)}")}""" - def unapply(string: String): Option[(String, Array[Byte] Refined NonEmpty)] = string match { - case basicR(base64(upR(username, password))) => - ParseRefined[SyncIO] - .parse[NonEmpty](password.getBytes(branchtalkCharset)) - .attempt - .unsafeRunSync() - .toOption - .map(username -> _) - case _ => None + private object basic { + private val basicR = raw"Basic (.+)".r + private val upR = raw"([^:]+):(.+)".r + def apply(username: String, password: Array[Byte]): String = + s"""Basic ${base64(s"${username}:${new String(password, branchtalkCharset)}")}""" + def unapply(string: String): Option[(String, Array[Byte])] = string match { + case basicR(base64(upR(username, password))) => Some(username -> password.getBytes(branchtalkCharset)) + case _ => None } } - private val bearerR = raw"Bearer (.+)".r - private object bearer { // scalastyle:ignore object.name + private object bearer { + private val bearerR = raw"Bearer (.+)".r def apply(sessionID: String): String = s"""Bearer ${sessionID}""" - def unapply(string: String): Option[String] = string match { + def unapply(string: String): Option[String] = string match { case bearerR(sessionID) => Some(sessionID.trim) case _ => None } } - implicit private class ResultOps[A](private val io: SyncIO[A]) extends AnyVal { - - def asResult(original: String): DecodeResult[A] = io.attempt.unsafeRunSync() match { + extension [A](io: SyncIO[A]) { + private def asResult(original: String): DecodeResult[A] = io.attempt.unsafeRunSync() match { case Left(value) => DecodeResult.Error(original, value) case Right(value) => DecodeResult.Value(value) } @@ -60,15 +50,15 @@ object AuthenticationSupport { case original if original.startsWith("Basic") => DecodeResult.Error(original, new Exception("Expected base64-encoded username:password")) case original @ bearer(sessionID) => - implicit val uuidGenerator: UUIDGenerator.FastUUIDGenerator.type = FastUUIDGenerator // passing it down it PITA + given UUID.Generator = UUID.FastGenerator // passing it is a PITA SessionID.parse[SyncIO](sessionID).map(Session.apply).asResult(original) case original if original.startsWith("Bearer") => DecodeResult.Error(original, new Exception("Expected session ID")) case original => DecodeResult.Error(original, new Exception("Unknown authentication type")) } { - case Session(sessionID) => bearer(sessionID.uuid.show) - case Credentials(username, password) => basic(username.nonEmptyString.value, password.nonEmptyBytes) + case Session(sessionID) => bearer(sessionID.show) + case Credentials(username, password) => basic(username.unwrap, password.unwrap) } val authHeader: EndpointIO.Header[Authentication] = header[String]("Authentication") .map(authHeaderMapping) diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/JsoniterSupport.scala b/modules/common-api/src/main/scala/io/branchtalk/api/JsoniterSupport.scala index 9c37831b..87953e4f 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/JsoniterSupport.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/JsoniterSupport.scala @@ -2,106 +2,110 @@ package io.branchtalk.api import cats.{ Id, Order } import cats.data.{ Chain, NonEmptyChain, NonEmptyList, NonEmptySet } -import com.github.plokhotnyuk.jsoniter_scala.core._ -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import eu.timepit.refined.api.{ Refined, Validate } -import eu.timepit.refined.refineV -import io.branchtalk.shared.model.{ ID, OptionUpdatable, UUID, Updatable, discriminatorNameMapper } -import io.estatico.newtype.Coercible - -// Provides (missing :/) support for .map, .mapDecode, .refine, .asNewtype for Jsoniter Scala codecs. -// TODO: consider moving to some external library -@SuppressWarnings(Array("org.wartremover.warts.All")) // handling valid null values, macros -object JsoniterSupport { - - // shortcut with object allowing consistent usage of @Semi(JsCodec) - type JsCodec[A] = JsonValueCodec[A] - object JsCodec +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* +import io.branchtalk.shared.model.* + +// Provides (missing :/) support for .map, .mapDecode,.asNewtype for Jsoniter Scala codecs. +object JsoniterSupport extends JsoniterSupportImplicits { + + // for shortening - @inline def summonCodec[T](implicit codec: JsCodec[T]): JsCodec[T] = codec + type JsCodec[A] = JsonValueCodec[A] + export com.github.plokhotnyuk.jsoniter_scala.macros.{ + CodecMakerConfig as JsCodecConfig, + ConfiguredJsonValueCodec as DefaultJsCodec + } // utilities - implicit class RefineCodec[T](private val codec: JsCodec[T]) extends AnyVal { + inline def summonCodec[T](using codec: JsCodec[T]): JsCodec[T] = codec - def mapDecode[U](f: T => Either[String, U])(g: U => T): JsCodec[U] = new JsCodec[U] { - override def decodeValue(in: JsonReader, default: U): U = - codec.decodeValue(in, if (default != null) g(default) else null.asInstanceOf[T]) match { // scalastyle:ignore - case null => null.asInstanceOf[U] // scalastyle:ignore + extension [A](codec: JsCodec[A]) { + @SuppressWarnings(Array("org.wartremover.warts.All")) + def mapDecode[B](f: A => Either[String, B])(g: B => A): JsCodec[B] = new JsCodec[B] { + override def decodeValue(in: JsonReader, default: B): B = + codec.decodeValue(in, if (default != null) g(default) else null.asInstanceOf[A]) match { + case null => null.asInstanceOf[B] case t => f(t) match { - case null => null.asInstanceOf[U] // scalastyle:ignore + case null => null.asInstanceOf[B] case Left(error) => in.decodeError(error) case Right(value) => value } } - override def encodeValue(x: U, out: JsonWriter): Unit = codec.encodeValue(g(x), out) + override def encodeValue(x: B, out: JsonWriter): Unit = codec.encodeValue(g(x), out) - override def nullValue: U = codec.nullValue match { - case null => null.asInstanceOf[U] // scalastyle:ignore - case u => f(u).getOrElse(null.asInstanceOf[U]) // scalastyle:ignore + override def nullValue: B = codec.nullValue match { + case null => null.asInstanceOf[B] + case u => f(u).getOrElse(null.asInstanceOf[B]) } } - def map[U](f: T => U)(g: U => T): JsCodec[U] = new JsCodec[U] { - override def decodeValue(in: JsonReader, default: U): U = - codec.decodeValue(in, if (default != null) g(default) else null.asInstanceOf[T]) match { // scalastyle:ignore - case null => null.asInstanceOf[U] // scalastyle:ignore + @SuppressWarnings(Array("org.wartremover.warts.All")) + def map[B](f: A => B)(g: B => A): JsCodec[B] = new JsCodec[B] { + override def decodeValue(in: JsonReader, default: B): B = + codec.decodeValue(in, if (default != null) g(default) else null.asInstanceOf[A]) match { + case null => null.asInstanceOf[B] case t => f(t) } - override def encodeValue(x: U, out: JsonWriter): Unit = codec.encodeValue(g(x), out) + override def encodeValue(x: B, out: JsonWriter): Unit = codec.encodeValue(g(x), out) - override def nullValue: U = codec.nullValue match { - case null => null.asInstanceOf[U] // scalastyle:ignore + override def nullValue: B = codec.nullValue match { + case null => null.asInstanceOf[B] case u => f(u) } } - def refine[P: Validate[T, *]]: JsCodec[T Refined P] = mapDecode(refineV[P](_: T))(_.value) - - def asNewtype[N: Coercible[T, *]]: JsCodec[N] = Coercible.unsafeWrapMM[JsCodec, Id, T, N].apply(codec) + def asNewtypeCodec[B](using newtype: Newtype.WithType[A, B]): JsCodec[B] = + newtype.unsafeMakeF[JsCodec](codec) } // domain instances - implicit def idCodec[A]: JsCodec[ID[A]] = summonCodec[UUID](JsonCodecMaker.make).asNewtype[ID[A]] + given [A]: JsCodec[ID[A]] = DefaultJsCodec.derived[UUID].asNewtypeCodec[ID[A]] - implicit def updatableCodec[A: JsCodec]: JsCodec[Updatable[A]] = summonCodec[Updatable[A]]( - JsonCodecMaker.make( - CodecMakerConfig - .withAdtLeafClassNameMapper(discriminatorNameMapper(".")) - .withDiscriminatorFieldName(Some("action")) - ) - ) + given [A](using JsCodec[A]): JsCodec[Updatable[A]] = { + inline given CodecMakerConfig = + CodecMakerConfig.withAdtLeafClassNameMapper(adtDiscriminatorNameMapper).withDiscriminatorFieldName(Some("action")) + DefaultJsCodec.derived[Updatable[A]] + } - implicit def optionUpdatableCodec[A: JsCodec]: JsCodec[OptionUpdatable[A]] = summonCodec[OptionUpdatable[A]]( - JsonCodecMaker.make( - CodecMakerConfig - .withAdtLeafClassNameMapper(discriminatorNameMapper(".")) - .withDiscriminatorFieldName(Some("action")) - ) - ) + given [A](using JsCodec[A]): JsCodec[OptionUpdatable[A]] = { + inline given CodecMakerConfig = + CodecMakerConfig.withAdtLeafClassNameMapper(adtDiscriminatorNameMapper).withDiscriminatorFieldName(Some("action")) + DefaultJsCodec.derived[OptionUpdatable[A]] + } // Cats instances - implicit def chainCodec[A: JsCodec]: JsCodec[Chain[A]] = - summonCodec[List[A]](JsonCodecMaker.make).map(Chain.fromSeq)(_.toList) - - implicit def necCodec[A: JsCodec]: JsCodec[NonEmptyChain[A]] = summonCodec[List[A]](JsonCodecMaker.make).mapDecode { - case head :: tail => NonEmptyChain(head, tail: _*).asRight[String] - case _ => "Expected non-empty list".asLeft[NonEmptyChain[A]] - }(_.toList) - - implicit def nelCodec[A: JsCodec]: JsCodec[NonEmptyList[A]] = summonCodec[List[A]](JsonCodecMaker.make).mapDecode { - case head :: tail => NonEmptyList(head, tail).asRight[String] - case _ => "Expected non-empty list".asLeft[NonEmptyList[A]] - }(_.toList) - - implicit def nesCodec[A: JsCodec: Order]: JsCodec[NonEmptySet[A]] = - summonCodec[List[A]](JsonCodecMaker.make).mapDecode { + given [A: JsCodec]: JsCodec[Chain[A]] = DefaultJsCodec.derived[List[A]].map(Chain.fromSeq)(_.toList) + given [A: JsCodec]: JsCodec[NonEmptyChain[A]] = DefaultJsCodec + .derived[List[A]] + .mapDecode { + case head :: tail => NonEmptyChain(head, tail: _*).asRight[String] + case _ => "Expected non-empty list".asLeft[NonEmptyChain[A]] + }(_.toList) + given [A: JsCodec]: JsCodec[NonEmptyList[A]] = DefaultJsCodec + .derived[List[A]] + .mapDecode { + case head :: tail => NonEmptyList(head, tail).asRight[String] + case _ => "Expected non-empty list".asLeft[NonEmptyList[A]] + }(_.toList) + given [A: JsCodec: Order]: JsCodec[NonEmptySet[A]] = DefaultJsCodec + .derived[List[A]] + .mapDecode { case head :: tail => NonEmptySet.of(head, tail: _*).asRight[String] case _ => "Expected non-empty list".asLeft[NonEmptySet[A]] }(_.toList) + + // Neotype + + export neotype.interop.jsoniter.{ newtypeCodec, subtypeCodec } +} +private[api] trait JsoniterSupportImplicits { self: JsoniterSupport.type => + + inline given CodecMakerConfig = CodecMakerConfig } diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/Pagination.scala b/modules/common-api/src/main/scala/io/branchtalk/api/Pagination.scala index 11ddb378..bca63712 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/Pagination.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/Pagination.scala @@ -1,30 +1,65 @@ package io.branchtalk.api -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.api.TapirSupport.JsSchema -import io.scalaland.chimney.dsl._ -import io.branchtalk.shared.model.Paginated -import io.scalaland.catnip.Semi +import cats.effect.Sync +import io.branchtalk.api.JsoniterSupport.* +import io.branchtalk.api.TapirSupport.* +import io.scalaland.chimney.dsl.* +import io.branchtalk.shared.model.* -@Semi(JsCodec, JsSchema) final case class Pagination[A]( +final case class Pagination[A]( entities: List[A], - offset: PaginationOffset, - limit: PaginationLimit, - nextOffset: Option[PaginationOffset] -) + offset: Pagination.Offset, + limit: Pagination.Limit, + nextOffset: Option[Pagination.Offset] +) derives DefaultJsCodec, + JsSchema -@SuppressWarnings(Array("org.wartremover.warts.All")) // for macros object Pagination { def fromPaginated[Entity]( paginated: Paginated[Entity], - offset: PaginationOffset, - limit: PaginationLimit + offset: Paginated.Offset, + limit: Paginated.Limit ): Pagination[Entity] = paginated .into[Pagination[Entity]] - .withFieldConst(_.offset, offset) - .withFieldConst(_.limit, limit) - .withFieldComputed(_.nextOffset, p => p.nextOffset.map(PaginationOffset(_))) + .withFieldConst(_.offset, Offset.unsafeMake(offset.unwrap)) + .withFieldConst(_.limit, Limit.unsafeMake(limit.unwrap)) + .withFieldComputed(_.nextOffset, _.nextOffset.map(o => Offset.unsafeMake(o.unwrap))) .transform + + type Offset = Offset.Type + object Offset extends Newtype[Long] { + + override inline def validate(input: Long): Boolean = input >= 0L + + def unapply(offset: Offset): Some[Long] = Some(offset.unwrap) + def parse[F[_]: Sync](long: Long): F[Offset] = ParseNewtype[F].parse[Offset](long) + + given JsCodec[Offset] = DefaultJsCodec.derived[Long].asNewtypeCodec[Offset] + given Param[Offset] = summonParam[Long].mapDecode(l => DecodeResult.fromEitherString(l.toString, make(l)))(_.unwrap) + given JsSchema[Offset] = summonSchema[Long].asNewtypeSchema[Offset] + } + + type Limit = Limit.Type + object Limit extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input > 0 + + def unapply(limit: Limit): Some[Int] = Some(limit.unwrap) + def parse[F[_]: Sync](long: Int): F[Limit] = ParseNewtype[F].parse[Limit](long) + + given JsCodec[Limit] = DefaultJsCodec.derived[Int].asNewtypeCodec[Limit] + given Param[Limit] = summonParam[Int].mapDecode(l => DecodeResult.fromEitherString(l.toString, make(l)))(_.unwrap) + given JsSchema[Limit] = summonSchema[Int].asNewtypeSchema[Limit] + } + + type HasNext = HasNext.Type + object HasNext extends Newtype[Boolean] { + + def unapply(hasNext: HasNext): Some[Boolean] = Some(hasNext.unwrap) + + given JsCodec[HasNext] = DefaultJsCodec.derived[Boolean].asNewtypeCodec[HasNext] + given JsSchema[HasNext] = summonSchema[Boolean].asNewtypeSchema[HasNext] + } } diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/Permission.scala b/modules/common-api/src/main/scala/io/branchtalk/api/Permission.scala index e574bed7..8111505a 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/Permission.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/Permission.scala @@ -1,31 +1,28 @@ package io.branchtalk.api import cats.Order -import io.branchtalk.ADT -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.shared.model.{ ShowPretty, UUID } -import io.scalaland.catnip.Semi +import io.branchtalk.api.JsoniterSupport.* +import io.branchtalk.shared.model.* -@Semi(ShowPretty, JsCodec) sealed trait Permission extends ADT -@SuppressWarnings(Array("org.wartremover.warts.All")) // macros +enum Permission derives ShowPretty, DefaultJsCodec { + case Administrate + case IsOwner + case ModerateUsers + case ModerateChannel(channelID: ChannelID) + case CanPublish(channelID: ChannelID) +} object Permission { - case object Administrate extends Permission - case object IsOwner extends Permission - case object ModerateUsers extends Permission - final case class ModerateChannel(channelID: ChannelID) extends Permission - final case class CanPublish(channelID: ChannelID) extends Permission - - implicit val order: Order[Permission] = { + given Order[Permission] = { case (Administrate, Administrate) => 0 case (Administrate, _) => 1 case (IsOwner, IsOwner) => 0 case (IsOwner, _) => 1 case (ModerateUsers, ModerateUsers) => 0 case (ModerateUsers, _) => 1 - case (ModerateChannel(c1), ModerateChannel(c2)) => Order[UUID].compare(c1.uuid, c2.uuid) + case (ModerateChannel(c1), ModerateChannel(c2)) => Order[UUID].compare(c1.unwrap, c2.unwrap) case (ModerateChannel(_), _) => 1 - case (CanPublish(c1), CanPublish(c2)) => Order[UUID].compare(c1.uuid, c2.uuid) + case (CanPublish(c1), CanPublish(c2)) => Order[UUID].compare(c1.unwrap, c2.unwrap) case (CanPublish(_), _) => -1 } } diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/RequiredPermissions.scala b/modules/common-api/src/main/scala/io/branchtalk/api/RequiredPermissions.scala index dc6fbccd..f293b318 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/RequiredPermissions.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/RequiredPermissions.scala @@ -2,57 +2,57 @@ package io.branchtalk.api import cats.Eq import cats.data.{ NonEmptyList, NonEmptySet } -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import io.branchtalk.ADT -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.shared.model.{ FastEq, ShowPretty } -import io.scalaland.catnip.Semi - -@Semi(FastEq, ShowPretty) sealed trait RequiredPermissions extends ADT { - - // scalastyle:off method.name - def &&(other: RequiredPermissions): RequiredPermissions = RequiredPermissions.And(this, other) - def ||(other: RequiredPermissions): RequiredPermissions = RequiredPermissions.Or(this, other) - def unary_! : RequiredPermissions = RequiredPermissions.Not(this) - // scalastyle:on method.name +import com.github.plokhotnyuk.jsoniter_scala.macros.* +import io.branchtalk.api.JsoniterSupport.{ *, given } +import io.branchtalk.shared.model.{ FastEq, ShowPretty, void } + +import scala.annotation.targetName + +enum RequiredPermissions derives FastEq, ShowPretty { + case Empty + + case AllOf(toSet: NonEmptySet[Permission]) + case AnyOf(toSet: NonEmptySet[Permission]) + + case And(x: RequiredPermissions, y: RequiredPermissions) + case Or(x: RequiredPermissions, y: RequiredPermissions) + case Not(x: RequiredPermissions) + + @targetName("and") def &&(other: RequiredPermissions): RequiredPermissions = And(this, other) + @targetName("or") def ||(other: RequiredPermissions): RequiredPermissions = Or(this, other) + @targetName("not") def unary_! : RequiredPermissions = Not(this) } object RequiredPermissions { - def empty: RequiredPermissions = Empty - def one(permission: Permission): RequiredPermissions = AllOf(NonEmptySet.one(permission)) + def empty: RequiredPermissions = Empty + def one(permission: Permission): RequiredPermissions = AllOf(NonEmptySet.one(permission)) def allOf(head: Permission, tail: Permission*): RequiredPermissions = AllOf(NonEmptySet.of(head, tail: _*)) def anyOf(head: Permission, tail: Permission*): RequiredPermissions = AnyOf(NonEmptySet.of(head, tail: _*)) - case object Empty extends RequiredPermissions + given JsCodec[RequiredPermissions] = { + transparent inline given CodecMakerConfig = CodecMakerConfig.withAllowRecursiveTypes(true) + DefaultJsCodec.derived[RequiredPermissions] + } - final case class AllOf(toSet: NonEmptySet[Permission]) extends RequiredPermissions - final case class AnyOf(toSet: NonEmptySet[Permission]) extends RequiredPermissions - - final case class And(x: RequiredPermissions, y: RequiredPermissions) extends RequiredPermissions - final case class Or(x: RequiredPermissions, y: RequiredPermissions) extends RequiredPermissions - final case class Not(x: RequiredPermissions) extends RequiredPermissions - - @SuppressWarnings(Array("org.wartremover.warts.All")) // handling valid null values - implicit val jsCodec: JsCodec[RequiredPermissions] = - summonCodec[RequiredPermissions](JsonCodecMaker.make(CodecMakerConfig.withAllowRecursiveTypes(true))) - - implicit val nesEq: Eq[NonEmptySet[Permission]] = (x: NonEmptySet[Permission], y: NonEmptySet[Permission]) => + given Eq[NonEmptySet[Permission]] = (x: NonEmptySet[Permission], y: NonEmptySet[Permission]) => x.toSortedSet === y.toSortedSet - implicit val nesShow: ShowPretty[NonEmptySet[Permission]] = - (t: NonEmptySet[Permission], sb: StringBuilder, indentWith: String, indentLevel: Int) => { + + @SuppressWarnings(Array("org.wartremover.warts.MutableDataStructures")) + given ShowPretty[NonEmptySet[Permission]] = { + (t: NonEmptySet[Permission], sb: StringBuilder, indentWith: String, indentLevel: Int) => val nextIndent = indentLevel + 1 - sb.append(indentWith * indentLevel).append("NonEmptySet(\n") + void(sb.append(indentWith * indentLevel).append("NonEmptySet(\n")) t.toNonEmptyList match { case NonEmptyList(head, tail) => sb.append(indentWith * nextIndent) - implicitly[ShowPretty[Permission]].showPretty(head, sb, indentWith, nextIndent) + void(summon[ShowPretty[Permission]].showPretty(head, sb, indentWith, nextIndent)) tail.foreach { elem => sb.append(",\n") sb.append(indentWith * nextIndent) - implicitly[ShowPretty[Permission]].showPretty(elem, sb, indentWith, nextIndent) + summon[ShowPretty[Permission]].showPretty(elem, sb, indentWith, nextIndent) } sb.append("\n)") } sb - } + } } diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/ServerErrorHandler.scala b/modules/common-api/src/main/scala/io/branchtalk/api/ServerErrorHandler.scala index 0e9bd56e..1118651a 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/ServerErrorHandler.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/ServerErrorHandler.scala @@ -10,8 +10,7 @@ trait ServerErrorHandler[F[_], E] { } object ServerErrorHandler { - @inline def apply[F[_], E](implicit serverErrorHandler: ServerErrorHandler[F, E]): ServerErrorHandler[F, E] = - serverErrorHandler + inline def apply[F[_], E](using handler: ServerErrorHandler[F, E]): ServerErrorHandler[F, E] = handler def handleCommonErrors[F[_]: Sync, E](mapping: CommonError => E)(logger: Logger): ServerErrorHandler[F, E] = new ServerErrorHandler[F, E] { diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/TapirSupport.scala b/modules/common-api/src/main/scala/io/branchtalk/api/TapirSupport.scala index 9d214ff5..66e34e30 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/TapirSupport.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/TapirSupport.scala @@ -1,23 +1,16 @@ package io.branchtalk.api import java.net.URI - import cats.Id import cats.data.{ Chain, NonEmptyChain, NonEmptyList, NonEmptySet } -import io.branchtalk.shared.model.{ ID, OptionUpdatable, UUID, Updatable, discriminatorNameMapper } -import io.estatico.newtype.Coercible +import io.branchtalk.shared.model.* import sttp.tapir.CodecFormat.TextPlain import sttp.tapir.generic.Configuration import scala.annotation.nowarn -// Allows `import TapirSupport._` instead of `import sttp.tapir._, sttp.tapir.codec.refined._, ...`. -@nowarn("cat=unused") -object TapirSupport - extends sttp.tapir.Tapir - with sttp.tapir.TapirAliases - with sttp.tapir.codec.refined.TapirCodecRefined - with sttp.tapir.json.jsoniter.TapirJsonJsoniter { +// Allows `import TapirSupport._` instead of `import sttp.tapir._, sttp.tapir.json.jsoniter._, ...`. +object TapirSupport extends sttp.tapir.Tapir, sttp.tapir.TapirAliases, sttp.tapir.json.jsoniter.TapirJsonJsoniter { // shortcuts type Param[A] = sttp.tapir.Codec[String, A, TextPlain] @@ -26,67 +19,54 @@ object TapirSupport type JsSchema[A] = sttp.tapir.Schema[A] val JsSchema = sttp.tapir.Schema - @inline def summonParam[T](implicit param: Param[T]): Param[T] = param - @inline def summonSchema[T](implicit schema: JsSchema[T]): JsSchema[T] = schema + inline def summonParam[T](using param: Param[T]): Param[T] = param + inline def summonSchema[T](using schema: JsSchema[T]): JsSchema[T] = schema // utilities - implicit class EndpointOps[A, I, E, O, R](private val endpoint: Endpoint[A, I, E, O, R]) extends AnyVal { - + extension [A, I, E, O, R](endpoint: Endpoint[A, I, E, O, R]) { def notRequiringPermissions: AuthedEndpoint[A, I, E, O, R] = AuthedEndpoint(endpoint, _ => RequiredPermissions.empty) - def requiringPermissions(permissions: I => RequiredPermissions): AuthedEndpoint[A, I, E, O, R] = AuthedEndpoint(endpoint, permissions) } - implicit class TapirResultOps[A](private val decodeResult: DecodeResult[A]) extends AnyVal { - + extension [A](decodeResult: DecodeResult[A]) { def toOption: Option[A] = decodeResult match { case DecodeResult.Value(v) => v.some case _ => none[A] } } - implicit class RefineSchema[T](private val schema: JsSchema[T]) extends AnyVal { - - def asNewtype[N: Coercible[T, *]]: JsSchema[N] = Coercible.unsafeWrapMM[JsSchema, Id, T, N].apply(schema) + extension [A](schema: JsSchema[A]) { + def asNewtypeSchema[B](using newtype: Newtype.WithType[A, B]): JsSchema[B] = + newtype.unsafeMakeF[JsSchema](schema) } - implicit val uriSchema: JsSchema[URI] = JsSchema.schemaForString.asInstanceOf[JsSchema[URI]] + given [A, B](using A: Newtype.WithType[B, A], B: JsSchema[B]): JsSchema[A] = B.asNewtypeSchema[A] + + given JsSchema[URI] = JsSchema.schemaForString.as[URI] // domain instances - implicit def idParam[A]: Param[ID[A]] = summonParam[UUID].map[ID[A]](ID[A](_))(_.uuid) - implicit def idSchema[A]: JsSchema[ID[A]] = summonSchema[UUID].asNewtype[ID[A]] + given [A]: Param[ID[A]] = summonParam[UUID].map[ID[A]](ID[A].apply)(_.unwrap) + given [A]: JsSchema[ID[A]] = summonSchema[UUID].asNewtypeSchema[ID[A]] - implicit def updatableSchema[A: JsSchema]: JsSchema[Updatable[A]] = { - implicit val customConfiguration: Configuration = - Configuration.default.copy(toEncodedName = discriminatorNameMapper(".")).withDiscriminator("action") + given [A: JsSchema]: JsSchema[Updatable[A]] = { + given Configuration = + Configuration.default.copy(toEncodedName = adtDiscriminatorNameMapper).withDiscriminator("action") JsSchema.derived[Updatable[A]] } - - implicit def optionUpdatableSchema[A: JsSchema]: JsSchema[OptionUpdatable[A]] = { - implicit val customConfiguration: Configuration = - Configuration.default.copy(toEncodedName = discriminatorNameMapper(".")).withDiscriminator("action") + given [A: JsSchema]: JsSchema[OptionUpdatable[A]] = { + given Configuration = + Configuration.default.copy(toEncodedName = adtDiscriminatorNameMapper).withDiscriminator("action") JsSchema.derived[OptionUpdatable[A]] } /// Cats codecs - @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) - implicit def chainSchema[A: JsSchema]: JsSchema[Chain[A]] = - summonSchema[List[A]].asInstanceOf[JsSchema[Chain[A]]] // scalastyle:ignore - - @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) - implicit def necSchema[A: JsSchema]: JsSchema[NonEmptyChain[A]] = - summonSchema[List[A]].asInstanceOf[JsSchema[NonEmptyChain[A]]] // scalastyle:ignore - - @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) - implicit def nelSchema[A: JsSchema]: JsSchema[NonEmptyList[A]] = - summonSchema[List[A]].asInstanceOf[JsSchema[NonEmptyList[A]]] // scalastyle:ignore - - @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) - implicit def nesSchema[A: JsSchema]: JsSchema[NonEmptySet[A]] = - summonSchema[List[A]].asInstanceOf[JsSchema[NonEmptySet[A]]] // scalastyle:ignore + given [A: JsSchema]: JsSchema[Chain[A]] = summonSchema[List[A]].as[Chain[A]] + given [A: JsSchema]: JsSchema[NonEmptyChain[A]] = summonSchema[List[A]].as[NonEmptyChain[A]] + given [A: JsSchema]: JsSchema[NonEmptyList[A]] = summonSchema[List[A]].as[NonEmptyList[A]] + given [A: JsSchema]: JsSchema[NonEmptySet[A]] = summonSchema[List[A]].as[NonEmptySet[A]] } diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/authedendpoints.scala b/modules/common-api/src/main/scala/io/branchtalk/api/authedendpoints.scala index 6cde5bf4..6abb7eb6 100644 --- a/modules/common-api/src/main/scala/io/branchtalk/api/authedendpoints.scala +++ b/modules/common-api/src/main/scala/io/branchtalk/api/authedendpoints.scala @@ -2,7 +2,7 @@ package io.branchtalk.api import cats.{ Functor, Monad, MonadError } import io.branchtalk.shared.model.{ CodePosition, CommonError } -import sttp.tapir._ +import sttp.tapir.* import sttp.tapir.server.ServerEndpoint trait Authorize[F[_], Auth, Out] { @@ -11,7 +11,7 @@ trait Authorize[F[_], Auth, Out] { } object Authorize { - implicit def functor[F[_]: Functor, Auth]: Functor[Authorize[F, Auth, *]] = new Functor[Authorize[F, Auth, *]] { + given functor[F[_]: Functor, Auth]: Functor[Authorize[F, Auth, *]] = new Functor[Authorize[F, Auth, *]] { override def map[A, B](fa: Authorize[F, Auth, A])(f: A => B): Authorize[F, Auth, B] = (auth: Auth, requiredPermissions: RequiredPermissions) => fa.authorize(auth, requiredPermissions).map(f) } @@ -23,11 +23,11 @@ trait AuthorizeWithOwnership[F[_], Auth, Owner, Out] { } object AuthorizeWithOwnership { - implicit def functor[F[_]: Functor, Auth, Owner]: Functor[AuthorizeWithOwnership[F, Auth, Owner, *]] = + given functor[F[_]: Functor, Auth, Owner]: Functor[AuthorizeWithOwnership[F, Auth, Owner, *]] = new Functor[AuthorizeWithOwnership[F, Auth, Owner, *]] { override def map[A, B]( fa: AuthorizeWithOwnership[F, Auth, Owner, A] - )(f: A => B): AuthorizeWithOwnership[F, Auth, Owner, B] = + )(f: A => B): AuthorizeWithOwnership[F, Auth, Owner, B] = (auth: Auth, requiredPermissions: RequiredPermissions, owner: Owner) => fa.authorize(auth, requiredPermissions, owner).map(f) } @@ -55,41 +55,33 @@ object AuthedEndpoint { ) { def apply( logic: I => F[O] - )(implicit - F: Monad[F], - errorHandler: ServerErrorHandler[F, E], - authorize: Authorize[F, A, U] - ): ServerEndpoint.Full[A, A, I, E, O, R, F] = + )(using Monad[F], ServerErrorHandler[F, E], Authorize[F, A, U]): ServerEndpoint.Full[A, A, I, E, O, R, F] = buildServerEndpoint((i, _, _) => i, logic) def withUser( logic: (U, I) => F[O] - )(implicit - F: Monad[F], - errorHandler: ServerErrorHandler[F, E], - authorize: Authorize[F, A, U] - ): ServerEndpoint.Full[A, A, I, E, O, R, F] = + )(using Monad[F], ServerErrorHandler[F, E], Authorize[F, A, U]): ServerEndpoint.Full[A, A, I, E, O, R, F] = buildServerEndpoint((i, _, u) => (u, i), logic.tupled) def justUser( logic: U => F[O] - )(implicit - F: Monad[F], - errorHandler: ServerErrorHandler[F, E], - authorize: Authorize[F, A, U], - ev: I =:= Unit + )(using + Monad[F], + ServerErrorHandler[F, E], + Authorize[F, A, U], + I =:= Unit ): ServerEndpoint.Full[A, A, I, E, O, R, F] = buildServerEndpoint((_, _, u) => u, logic) private def buildServerEndpoint[In]( input: (I, A, U) => In, logic: In => F[O] - )(implicit + )(using F: Monad[F], errorHandler: ServerErrorHandler[F, E], authorize: Authorize[F, A, U] ): ServerEndpoint.Full[A, A, I, E, O, R, F] = - endpoint.serverSecurityLogicPure(_.asRight[E]).serverLogic { auth: A => i: I => + endpoint.serverSecurityLogicPure(_.asRight[E]).serverLogic { (auth: A) => (i: I) => for { u <- authorize.authorize(auth, makePermissions(i)) in = input(i, auth, u) @@ -105,50 +97,47 @@ object AuthedEndpoint { ) { def apply( logic: I => F[O] - )(implicit - F: MonadError[F, Throwable], - errorHandler: ServerErrorHandler[F, E], - authorize: AuthorizeWithOwnership[F, A, Owner, U], - codePosition: CodePosition + )(using + MonadError[F, Throwable], + ServerErrorHandler[F, E], + AuthorizeWithOwnership[F, A, Owner, U], + CodePosition ): ServerEndpoint.Full[A, A, I, E, O, R, F] = buildServerEndpoint((i, _, _) => i, logic) def withUser( logic: (U, I) => F[O] - )(implicit - F: MonadError[F, Throwable], - errorHandler: ServerErrorHandler[F, E], - authorize: AuthorizeWithOwnership[F, A, Owner, U], - codePosition: CodePosition + )(using + MonadError[F, Throwable], + ServerErrorHandler[F, E], + AuthorizeWithOwnership[F, A, Owner, U], + CodePosition ): ServerEndpoint.Full[A, A, I, E, O, R, F] = buildServerEndpoint((i, _, u) => (u, i), logic.tupled) def justUser( logic: U => F[O] - )(implicit - F: MonadError[F, Throwable], - errorHandler: ServerErrorHandler[F, E], - authorize: AuthorizeWithOwnership[F, A, Owner, U], - codePosition: CodePosition, - ev: I =:= Unit + )(using + MonadError[F, Throwable], + ServerErrorHandler[F, E], + AuthorizeWithOwnership[F, A, Owner, U], + CodePosition, + I =:= Unit ): ServerEndpoint.Full[A, A, I, E, O, R, F] = buildServerEndpoint((_, _, u) => u, logic) private def buildServerEndpoint[In]( input: (I, A, U) => In, logic: In => F[O] - )(implicit + )(using F: MonadError[F, Throwable], errorHandler: ServerErrorHandler[F, E], authorize: AuthorizeWithOwnership[F, A, Owner, U], codePosition: CodePosition ): ServerEndpoint.Full[A, A, I, E, O, R, F] = - endpoint.serverSecurityLogicPure(_.asRight[E]).serverLogic { auth: A => i: I => + endpoint.serverSecurityLogicPure(_.asRight[E]).serverLogic { (auth: A) => (i: I) => for { - owner <- ownership(i).handleErrorWith { _ => - (CommonError.InsufficientPermissions("Ownership was not confirmed", codePosition): Throwable) - .raiseError[F, Owner] - } + owner <- ownership(i).orRaise(CommonError.insufficientPermissions("Ownership was not confirmed")) u <- authorize.authorize(auth, makePermissions(i), owner) in = input(i, auth, u) out <- errorHandler(logic(in)) diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/credentials.scala b/modules/common-api/src/main/scala/io/branchtalk/api/credentials.scala new file mode 100644 index 00000000..22f52afb --- /dev/null +++ b/modules/common-api/src/main/scala/io/branchtalk/api/credentials.scala @@ -0,0 +1,33 @@ +package io.branchtalk.api + +import cats.{ Eq, Show } +import cats.effect.Sync +import io.branchtalk.shared.model.ParseNewtype +import io.branchtalk.api.JsoniterSupport.* +import io.branchtalk.api.TapirSupport.* + +type Username = Username.Type +object Username extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(username: Username): Some[String] = Some(username.unwrap) + def parse[F[_]: Sync](string: String): F[Username] = ParseNewtype[F].parse[Username](string) + + given Eq[Username] = unsafeMakeF[Eq](Eq[String]) + given Show[Username] = unsafeMakeF[Show](Show[String]) + given JsCodec[Username] = DefaultJsCodec.derived[String].asNewtypeCodec[Username] + given JsSchema[Username] = summonSchema[String].asNewtypeSchema[Username] +} + +type Password = Password.Type +object Password extends Newtype[Array[Byte]] { + + override inline def validate(input: Array[Byte]): Boolean = input.nonEmpty + + def unapply(password: Password): Some[Array[Byte]] = Some(password.unwrap) + def parse[F[_]: Sync](array: Array[Byte]): F[Password] = ParseNewtype[F].parse[Password](array) + + given JsCodec[Password] = DefaultJsCodec.derived[Array[Byte]].asNewtypeCodec[Password] + given JsSchema[Password] = summonSchema[Array[Byte]].asNewtypeSchema[Password] +} diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/ids.scala b/modules/common-api/src/main/scala/io/branchtalk/api/ids.scala new file mode 100644 index 00000000..5f6e2b00 --- /dev/null +++ b/modules/common-api/src/main/scala/io/branchtalk/api/ids.scala @@ -0,0 +1,47 @@ +package io.branchtalk.api + +import cats.{ Eq, Show } +import cats.effect.Sync +import io.branchtalk.shared.model.{ ParseNewtype, UUID } +import io.branchtalk.api.JsoniterSupport.* +import io.branchtalk.api.TapirSupport.* + +type SessionID = SessionID.Type +object SessionID extends Newtype[UUID] { + + def unapply(sessionID: SessionID): Some[UUID] = Some(sessionID.unwrap) + def parse[F[_]: Sync](string: String)(using UUID.Generator): F[SessionID] = UUID.parse[F](string).map(unsafeMake) + + given Eq[SessionID] = unsafeMakeF[Eq](Eq.fromUniversalEquals[UUID]) + given Show[SessionID] = unsafeMakeF[Show](Show[UUID]) + given JsCodec[SessionID] = DefaultJsCodec.derived[UUID].asNewtypeCodec[SessionID] + given JsSchema[SessionID] = summonSchema[UUID].asNewtypeSchema[SessionID] +} + +type UserID = UserID.Type +object UserID extends Newtype[UUID] { + + val empty: UserID = unsafeMake(UUID.empty) + + def unapply(userID: UserID): Some[UUID] = Some(userID.unwrap) + def parse[F[_]: Sync](string: String)(using UUID.Generator): F[UserID] = UUID.parse[F](string).map(unsafeMake) + + given Eq[UserID] = unsafeMakeF[Eq](Eq.fromUniversalEquals[UUID]) + given Show[UserID] = unsafeMakeF[Show](Show[UUID]) + given JsCodec[UserID] = DefaultJsCodec.derived[UUID].asNewtypeCodec[UserID] + given JsSchema[UserID] = summonSchema[UUID].asNewtypeSchema[UserID] +} + +type ChannelID = ChannelID.Type +object ChannelID extends Newtype[UUID] { + + val empty: ChannelID = unsafeMake(UUID.empty) + + def unapply(channelID: ChannelID): Some[UUID] = Some(channelID.unwrap) + def parse[F[_]: Sync](string: String)(using UUID.Generator): F[ChannelID] = UUID.parse[F](string).map(unsafeMake) + + given Eq[ChannelID] = unsafeMakeF[Eq](Eq.fromUniversalEquals[UUID]) + given Show[ChannelID] = unsafeMakeF[Show](Show[UUID]) + given JsCodec[ChannelID] = DefaultJsCodec.derived[UUID].asNewtypeCodec[ChannelID] + given JsSchema[ChannelID] = summonSchema[UUID].asNewtypeSchema[ChannelID] +} diff --git a/modules/common-api/src/main/scala/io/branchtalk/api/package.scala b/modules/common-api/src/main/scala/io/branchtalk/api/package.scala deleted file mode 100644 index b97cd15d..00000000 --- a/modules/common-api/src/main/scala/io/branchtalk/api/package.scala +++ /dev/null @@ -1,125 +0,0 @@ -package io.branchtalk - -import cats.{ Eq, Show } -import cats.effect.Sync -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.numeric.{ NonNegative, Positive } -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.shared.model._ -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.api.TapirSupport._ -import io.estatico.newtype.macros.newtype -import io.estatico.newtype.ops._ - -// scalastyle:off number.of.methods -package object api { - - // API definitions and instances - - @newtype final case class SessionID(uuid: UUID) - object SessionID { - def unapply(sessionID: SessionID): Some[UUID] = Some(sessionID.uuid) - def parse[F[_]: Sync](string: String)(implicit uuidGenerator: UUIDGenerator): F[SessionID] = - UUID.parse[F](string).map(SessionID(_)) - - implicit val eq: Eq[SessionID] = Eq[UUID].coerce - implicit val show: Show[SessionID] = Show[UUID].coerce - @SuppressWarnings(Array("org.wartremover.warts.Null")) - implicit val codec: JsCodec[SessionID] = summonCodec[UUID](JsonCodecMaker.make).asNewtype[SessionID] - implicit val schema: JsSchema[SessionID] = summonSchema[UUID].asNewtype[SessionID] - } - - @newtype final case class UserID(uuid: UUID) - object UserID { - def unapply(userID: UserID): Some[UUID] = Some(userID.uuid) - def parse[F[_]: Sync](string: String)(implicit uuidGenerator: UUIDGenerator): F[UserID] = - UUID.parse[F](string).map(UserID(_)) - - val empty: UserID = UserID(java.util.UUID.fromString("00000000-0000-0000-0000-000000000000")) - - implicit val eq: Eq[UserID] = Eq[UUID].coerce - implicit val show: Show[UserID] = Show[UUID].coerce - @SuppressWarnings(Array("org.wartremover.warts.Null")) - implicit val codec: JsCodec[UserID] = summonCodec[UUID](JsonCodecMaker.make).asNewtype[UserID] - implicit val schema: JsSchema[UserID] = summonSchema[UUID].asNewtype[UserID] - } - - @newtype final case class ChannelID(uuid: UUID) - object ChannelID { - def unapply(channelID: ChannelID): Some[UUID] = Some(channelID.uuid) - def parse[F[_]: Sync](string: String)(implicit uuidGenerator: UUIDGenerator): F[ChannelID] = - UUID.parse[F](string).map(ChannelID(_)) - - implicit val eq: Eq[ChannelID] = Eq[UUID].coerce - implicit val show: Show[ChannelID] = Show[UUID].coerce - @SuppressWarnings(Array("org.wartremover.warts.Null")) - implicit val codec: JsCodec[ChannelID] = summonCodec[UUID](JsonCodecMaker.make).asNewtype[ChannelID] - implicit val schema: JsSchema[ChannelID] = summonSchema[UUID].asNewtype[ChannelID] - } - - @newtype final case class Username(nonEmptyString: NonEmptyString) - object Username { - def unapply(username: Username): Some[NonEmptyString] = Some(username.nonEmptyString) - def parse[F[_]: Sync](string: String): F[Username] = - ParseRefined[F].parse[NonEmpty](string).map(Username(_)) - - @SuppressWarnings(Array("org.wartremover.warts.Null")) - implicit val codec: JsCodec[Username] = - summonCodec[String](JsonCodecMaker.make).refine[NonEmpty].asNewtype[Username] - implicit val schema: JsSchema[Username] = - summonSchema[String Refined NonEmpty].asNewtype[Username] - } - - @newtype final case class Password(nonEmptyBytes: Array[Byte] Refined NonEmpty) - object Password { - def unapply(password: Password): Some[Array[Byte] Refined NonEmpty] = Some(password.nonEmptyBytes) - def parse[F[_]: Sync](bytes: Array[Byte]): F[Password] = - ParseRefined[F].parse[NonEmpty](bytes).map(Password(_)) - - @SuppressWarnings(Array("org.wartremover.warts.All")) // macros - implicit val codec: JsCodec[Password] = - summonCodec[String](JsonCodecMaker.make).map(_.getBytes)(new String(_)).refine[NonEmpty].asNewtype[Password] - implicit val schema: JsSchema[Password] = - summonSchema[Array[Byte] Refined NonEmpty].asNewtype[Password] - } - - @newtype final case class PaginationOffset(nonNegativeLong: Long Refined NonNegative) - object PaginationOffset { - def unapply(offset: PaginationOffset): Some[Long Refined NonNegative] = Some(offset.nonNegativeLong) - def parse[F[_]: Sync](long: Long): F[PaginationOffset] = - ParseRefined[F].parse[NonNegative](long).map(PaginationOffset(_)) - - implicit val codec: JsCodec[PaginationOffset] = - summonCodec[Long](JsonCodecMaker.make).refine[NonNegative].asNewtype[PaginationOffset] - implicit val param: Param[PaginationOffset] = - summonParam[Long Refined NonNegative].map(PaginationOffset(_))(_.nonNegativeLong) - implicit val schema: JsSchema[PaginationOffset] = - summonSchema[Long Refined NonNegative].asNewtype[PaginationOffset] - } - - @newtype final case class PaginationLimit(positiveInt: Int Refined Positive) - object PaginationLimit { - def unapply(limit: PaginationLimit): Some[Int Refined Positive] = Some(limit.positiveInt) - def parse[F[_]: Sync](int: Int): F[PaginationLimit] = - ParseRefined[F].parse[Positive](int).map(PaginationLimit(_)) - - implicit val codec: JsCodec[PaginationLimit] = - summonCodec[Int](JsonCodecMaker.make).refine[Positive].asNewtype[PaginationLimit] - implicit val param: Param[PaginationLimit] = - summonParam[Int Refined Positive].map(PaginationLimit(_))(_.positiveInt) - implicit val schema: JsSchema[PaginationLimit] = - summonSchema[Int Refined Positive].asNewtype[PaginationLimit] - } - - @newtype final case class PaginationHasNext(bool: Boolean) - object PaginationHasNext { - def unapply(hasNext: PaginationHasNext): Some[Boolean] = Some(hasNext.bool) - - implicit val codec: JsCodec[PaginationHasNext] = - summonCodec[Boolean](JsonCodecMaker.make).asNewtype[PaginationHasNext] - implicit val schema: JsSchema[PaginationHasNext] = - summonSchema[Boolean].asNewtype[PaginationHasNext] - } -} diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestKafkaEventBusConfig.scala b/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestKafkaEventBusConfig.scala deleted file mode 100644 index 68f02462..00000000 --- a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestKafkaEventBusConfig.scala +++ /dev/null @@ -1,21 +0,0 @@ -package io.branchtalk.shared.infrastructure - -import cats.data.NonEmptyList -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.refineV -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ - -@Semi(ConfigReader) final case class TestKafkaEventBusConfig( - servers: NonEmptyList[Server], - topicPrefix: Topic, - cache: Server -) { - - def topic(generatedSuffix: String): Topic = - Topic(refineV[NonEmpty](topicPrefix.nonEmptyString.value + generatedSuffix).getOrElse(???)) - - def toKafkaEventBusConfig(generatedSuffix: String): KafkaEventBusConfig = - this.into[KafkaEventBusConfig].withFieldConst(_.topic, topic(generatedSuffix)).transform -} diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestPostgresConfig.scala b/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestPostgresConfig.scala deleted file mode 100644 index f801b9ea..00000000 --- a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestPostgresConfig.scala +++ /dev/null @@ -1,32 +0,0 @@ -package io.branchtalk.shared.infrastructure - -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.refineV -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ -import pureconfig.ConfigReader - -@Semi(ConfigReader) final case class TestPostgresConfig( - url: DatabaseURL, - rootPassword: DatabasePassword, - usernamePrefix: DatabaseUsername, - password: DatabasePassword, - schemaPrefix: DatabaseSchema, - domain: DatabaseDomain, - connectionPool: DatabaseConnectionPool -) { - - def username(generatedSuffix: String): DatabaseUsername = - DatabaseUsername(refineV[NonEmpty](usernamePrefix.nonEmptyString.value + generatedSuffix).getOrElse(???)) - def schema(generatedSuffix: String): DatabaseSchema = - DatabaseSchema(refineV[NonEmpty](schemaPrefix.nonEmptyString.value + generatedSuffix).getOrElse(???)) - def migrationOnStart: DatabaseMigrationOnStart = DatabaseMigrationOnStart(true) - - def toPostgresConfig(generatedSuffix: String): PostgresConfig = - this - .into[PostgresConfig] - .withFieldConst(_.username, username(generatedSuffix)) - .withFieldConst(_.schema, schema(generatedSuffix)) - .withFieldConst(_.migrationOnStart, migrationOnStart) - .transform -} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Cache.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Cache.scala index 10aa1607..6252618f 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Cache.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Cache.scala @@ -8,7 +8,8 @@ import dev.profunktor.redis4cats.effect.Log import dev.profunktor.redis4cats.{ Redis, RedisCommands } import fs2.kafka.{ Headers, Serializer } import fs2.{ Pipe, Stream } -import io.branchtalk.shared.model.{ Logger, branchtalkCharset } +import io.branchtalk.logging.Logger +import io.branchtalk.shared.model.branchtalkCharset import io.lettuce.core.codec.{ RedisCodec => JRedisCodec } import scala.util.control.NoStackTrace @@ -18,11 +19,10 @@ abstract class Cache[F[_]: Sync, K, V] { def apply(key: K)(value: F[V]): F[(K, V)] - final private case object EmptyStream extends Exception with NoStackTrace + private case object EmptyStream extends Exception, NoStackTrace - @SuppressWarnings(Array("org.wartremover.warts.Throw")) // will be handled just after use private def unliftPipe[I](pipe: Pipe[F, I, V]): I => F[V] = - i => Stream(i).through(pipe).compile.last.map(_.getOrElse(throw EmptyStream)) + i => Stream(i).through(pipe).compile.last.flatMap(_.fold((EmptyStream: Throwable).raiseError[F, V])(_.pure[F])) def piped[I](key: I => K, pipe: Pipe[F, I, V]): Pipe[F, I, V] = (_: Stream[F, I]).flatMap(i => Stream.eval(apply(key(i))(unliftPipe(pipe)(i)))).map(_._2).handleErrorWith { @@ -32,7 +32,7 @@ abstract class Cache[F[_]: Sync, K, V] { } object Cache { - def fromRedis[F[_]: Sync, K, V](redis: RedisCommands[F, K, V]): Cache[F, K, V] = new Cache[F, K, V] { + def fromRedis[F[_]: Sync, K, V](redis: RedisCommands[F, K, V]): Cache[F, K, V] = new { override def apply(key: K)(valueF: F[V]): F[(K, V)] = redis.get(key).flatMap { case Some(value) => (key -> value).pure[F] @@ -41,23 +41,20 @@ object Cache { } def fromConfigs[F[_]: Async: Dispatcher, Event: Serializer[F, *]: SafeDeserializer[F, *]]( - busConfig: KafkaEventBusConfig + busConfig: KafkaEventBus.BusConfig ): Resource[F, Cache[F, String, Event]] = for { logger <- Resource.eval(Logger.fromClass[F](classOf[Cache[F, String, Event]])) - implicit0(log: Log[F]) = new Log[F] { + given Log[F] = new Log[F] { override def debug(msg: => String): F[Unit] = logger.debug(msg) override def error(msg: => String): F[Unit] = logger.error(msg) override def info(msg: => String): F[Unit] = logger.info(msg) } - redis <- Redis[F].simple( - show"redis://${busConfig.cache.host.value}:${busConfig.cache.port.value}", - prepareCodec[F, Event](busConfig.topic.nonEmptyString.value) - ) + redis <- Redis[F].simple(show"redis://${busConfig.cache}", prepareCodec[F, Event](busConfig.topic)) } yield fromRedis(redis) private def prepareCodec[F[_]: Sync: Dispatcher, Event: Serializer[F, *]: SafeDeserializer[F, *]]( - topic: String + topic: KafkaEventBus.Topic ): RedisCodec[String, Event] = RedisCodec( new JRedisCodec[String, Event] { override def decodeKey(bytes: ByteBuffer): String = new String(bytes.array(), branchtalkCharset) @@ -68,21 +65,21 @@ object Cache { override def decodeValue(bytes: ByteBuffer): Event = if (bytes.hasArray) { SafeDeserializer[F, Event] - .deserialize(topic, Headers.empty, bytes.array()) + .deserialize(topic.unwrap, Headers.empty, bytes.array()) .flatMap { case Left(error) => new Exception(error.toString).raiseError[F, Event] case Right(value) => value.pure[F] } - .pipe(implicitly[Dispatcher[F]].unsafeRunSync(_)) - } else null.asInstanceOf[Event] // scalastyle:ignore null + .pipe(summon[Dispatcher[F]].unsafeRunSync(_)) + } else null.asInstanceOf[Event] override def encodeKey(key: String): ByteBuffer = ByteBuffer.wrap(key.getBytes(branchtalkCharset)) override def encodeValue(value: Event): ByteBuffer = Serializer[F, Event] - .serialize(topic, Headers.empty, value) + .serialize(topic.unwrap, Headers.empty, value) .map(ByteBuffer.wrap) - .pipe(implicitly[Dispatcher[F]].unsafeRunSync(_)) + .pipe(summon[Dispatcher[F]].unsafeRunSync(_)) } ) } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/ConsumerStream.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/ConsumerStream.scala index acd6efb0..2b7e44d3 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/ConsumerStream.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/ConsumerStream.scala @@ -1,19 +1,21 @@ package io.branchtalk.shared.infrastructure import cats.effect.Async -import fs2.{ io => _, _ } +import fs2.{ io => _, * } import fs2.kafka.{ ProducerResult, Serializer } -import io.branchtalk.shared.model.{ Logger, UUID } +import io.branchtalk.logging.Logger +import io.branchtalk.shared.model.UUID +// TODO: move to KafkaEventBus final class ConsumerStream[F[_], Event]( - consumer: EventBusConsumer[F, Event], - committer: EventBusCommitter[F] + consumer: KafkaEventBus.Consumer[F, Event], + committer: KafkaEventBus.Committer[F] ) { // Runs pipe (projections) on events and commit them once they are processed. def runThrough[B]( logger: Logger[F] - )(f: Pipe[F, (String, Event), B])(implicit F: Async[F]): StreamRunner[F] = StreamRunner[F, Unit] { + )(f: Pipe[F, (String, Event), B])(using Async[F]): StreamRunner[F] = StreamRunner[F, Unit] { consumer .flatMap { event => Stream(s"${event.record.topic}:${event.record.offset.toString}" -> event.record.value) @@ -28,15 +30,15 @@ final class ConsumerStream[F[_], Event]( def runCachedThrough[B]( logger: Logger[F], cache: Cache[F, String, B] - )(f: Pipe[F, (String, Event), B])(implicit F: Async[F]): StreamRunner[F] = + )(f: Pipe[F, (String, Event), B])(using Async[F]): StreamRunner[F] = runThrough(logger)(cache.piped(_._1, f)) } object ConsumerStream { - type Factory[F[_], Event] = KafkaEventConsumerConfig => ConsumerStream[F, Event] + type Factory[F[_], Event] = KafkaEventBus.ConsumerConfig => ConsumerStream[F, Event] def fromConfigs[F[_]: Async, Event: Serializer[F, *]: SafeDeserializer[F, *]]( - busConfig: KafkaEventBusConfig + busConfig: KafkaEventBus.BusConfig ): Factory[F, Event] = consumerCfg => new ConsumerStream( @@ -46,6 +48,5 @@ object ConsumerStream { def noID[F[_], A, B]: Pipe[F, (A, B), B] = _.map(_._2) - def produced[F[_], A]: Pipe[F, ProducerResult[Unit, UUID, A], A] = - _.flatMap(pr => Stream(pr.records.map(_._1.value).toList: _*)) + def produced[F[_], A]: Pipe[F, ProducerResult[UUID, A], A] = _.flatMap(pr => Stream(pr.map(_._1.value).toList: _*)) } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DatabasePassword.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DatabasePassword.scala deleted file mode 100644 index 4eabdb12..00000000 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DatabasePassword.scala +++ /dev/null @@ -1,17 +0,0 @@ -package io.branchtalk.shared.infrastructure - -import cats.Show -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.branchtalk.shared.model._ - -// not @newtype to allow overriding toString -final case class DatabasePassword(nonEmptyString: NonEmptyString) { - override def toString: String = "[PASSWORD]" -} -object DatabasePassword { - def unapply(databasePassword: DatabasePassword): Some[NonEmptyString] = Some(databasePassword.nonEmptyString) - - implicit val configReader: ConfigReader[DatabasePassword] = ConfigReader[NonEmptyString].map(DatabasePassword(_)) - implicit val show: Show[DatabasePassword] = Show.wrap(_.toString) -} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DomainConfig.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DomainConfig.scala deleted file mode 100644 index f1182f18..00000000 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DomainConfig.scala +++ /dev/null @@ -1,18 +0,0 @@ -package io.branchtalk.shared.infrastructure - -import io.scalaland.catnip.Semi -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.branchtalk.shared.model.ShowPretty - -@Semi(ConfigReader, ShowPretty) final case class DomainConfig( - name: DomainName, - databaseReads: PostgresConfig, - databaseWrites: PostgresConfig, - publishedEventBus: KafkaEventBusConfig, - internalEventBus: KafkaEventBusConfig, - consumers: Map[String, KafkaEventConsumerConfig] -) { - - // assumes that each config has to have this field - def internalConsumer: KafkaEventConsumerConfig = consumers("internal") -} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DomainModule.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DomainModule.scala index edf771b2..4e43befa 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DomainModule.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DomainModule.scala @@ -1,50 +1,43 @@ package io.branchtalk.shared.infrastructure +import cats.Show import cats.effect.std.Dispatcher import cats.effect.{ Async, Resource } import com.sksamuel.avro4s.{ Decoder, Encoder, SchemaFor } import doobie.util.transactor.Transactor -import io.branchtalk.shared.infrastructure.KafkaSerialization._ +import io.branchtalk.logging.Logger +import io.branchtalk.shared.infrastructure.KafkaSerialization.{ *, given } +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } +import io.branchtalk.shared.model.ShowPretty import io.prometheus.client.CollectorRegistry // Utilities for connecting to database and events buses through Resources. -final case class ReadsInfrastructure[F[_], Event]( - transactor: Transactor[F], - consumer: KafkaEventConsumerConfig => ConsumerStream[F, Event] -) - -final case class WritesInfrastructure[F[_], Event, InternalEvent]( - transactor: Transactor[F], - internalProducer: EventBusProducer[F, InternalEvent], - internalConsumerStream: ConsumerStream[F, InternalEvent], - producer: EventBusProducer[F, Event], - consumerStream: ConsumerStream.Factory[F, Event], - cache: Cache[F, String, Event] -) final class DomainModule[Event: Encoder: Decoder: SchemaFor, InternalEvent: Encoder: Decoder: SchemaFor] { def setupReads[F[_]: Async]( - domainConfig: DomainConfig, + domainConfig: DomainModule.Config, + logger: Logger[F], registry: CollectorRegistry - ): Resource[F, ReadsInfrastructure[F, Event]] = + ): Resource[F, Reads.Infrastructure[F, Event]] = for { - transactor <- new PostgresDatabase(domainConfig.databaseReads).transactor(registry) + transactor <- new PostgresDatabase(domainConfig.databaseReads).transactor(logger, registry) consumerStreamBuilder = ConsumerStream.fromConfigs[F, Event](domainConfig.publishedEventBus) - } yield ReadsInfrastructure(transactor, consumerStreamBuilder) + } yield Reads.Infrastructure(transactor, consumerStreamBuilder) def setupWrites[F[_]: Async: Dispatcher]( - domainConfig: DomainConfig, + domainConfig: DomainModule.Config, + logger: Logger[F], registry: CollectorRegistry - ): Resource[F, WritesInfrastructure[F, Event, InternalEvent]] = + ): Resource[F, Writes.Infrastructure[F, Event, InternalEvent]] = for { - transactor <- new PostgresDatabase(domainConfig.databaseWrites).transactor(registry) + transactor <- new PostgresDatabase(domainConfig.databaseWrites).transactor(logger, registry) internalProducer = KafkaEventBus.producer[F, InternalEvent](domainConfig.internalEventBus) internalConsumerStream = ConsumerStream.fromConfigs[F, InternalEvent](domainConfig.internalEventBus) producer = KafkaEventBus.producer[F, Event](domainConfig.publishedEventBus) consumerStream = ConsumerStream.fromConfigs[F, Event](domainConfig.publishedEventBus) cache <- Cache.fromConfigs[F, Event](domainConfig.internalEventBus) - } yield WritesInfrastructure( + } yield Writes.Infrastructure( transactor, internalProducer, internalConsumerStream(domainConfig.internalConsumer), @@ -59,4 +52,29 @@ object DomainModule { Event, InternalEvent ] = new DomainModule[Event, InternalEvent] + + type Name = Name.Type + object Name extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(domainName: Name): Some[String] = Some(domainName.unwrap) + + given ConfigReader[Name] = ConfigReader[String].emapString("Name")(make) + given Show[Name] = unsafeMakeF[Show](Show[String]) + } + + final case class Config( + name: Name, + databaseReads: PostgresDatabase.Config, + databaseWrites: PostgresDatabase.Config, + publishedEventBus: KafkaEventBus.BusConfig, + internalEventBus: KafkaEventBus.BusConfig, + consumers: Map[String, KafkaEventBus.ConsumerConfig] + ) derives ConfigReader, + ShowPretty { + + // assumes that each config has to have this field + def internalConsumer: KafkaEventBus.ConsumerConfig = consumers("internal") + } } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DoobieSupport.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DoobieSupport.scala index 4d1fd815..c2c6d787 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DoobieSupport.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/DoobieSupport.scala @@ -1,82 +1,88 @@ package io.branchtalk.shared.infrastructure import cats.effect.{ Sync, SyncIO } -import com.typesafe.scalalogging.Logger -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } -import io.branchtalk.shared.model._ -import io.estatico.newtype.Coercible +import doobie.util.log +import io.branchtalk.logging.Logger +import io.branchtalk.shared.model.* import org.tpolecat.typename.TypeName -// Allows `import DoobieSupport._` instead of... a lot of imports. -// Additionally provides support for a few useful but missing features. +// Allows `import DoobieSupport.*` instead of... a lot of imports. +// Additionally, provides support for a few useful but missing features. object DoobieSupport - extends doobie.Aliases // basic functionalities - with doobie.hi.Modules - with doobie.syntax.AllSyntax - with doobie.free.Modules - with doobie.free.Types - with doobie.free.Instances - with doobie.postgres.Instances // postgres extensions (without postgis) - with doobie.postgres.hi.Modules - with doobie.postgres.free.Modules - with doobie.postgres.free.Types - with doobie.postgres.free.Instances - with doobie.postgres.syntax.ToPostgresMonadErrorOps - with doobie.postgres.syntax.ToFragmentOps - with doobie.postgres.syntax.ToPostgresExplainOps - with doobie.refined.Instances // refined types - with doobie.util.meta.MetaConstructors // Java Time extensions - with doobie.util.meta.TimeMetaInstances { +// basic functionalities + extends doobie.Aliases, + doobie.hi.Modules, + doobie.syntax.AllSyntax, + doobie.free.Modules, + doobie.free.Types, + doobie.free.Instances, + // postgres extensions (without postgis) + doobie.postgres.Instances, + doobie.postgres.hi.Modules, + doobie.postgres.free.Modules, + doobie.postgres.free.Types, + doobie.postgres.free.Instances, + doobie.postgres.syntax.ToPostgresMonadErrorOps, + doobie.postgres.syntax.ToFragmentOps, + doobie.postgres.syntax.ToPostgresExplainOps, + // Java Time extensions + doobie.util.meta.MetaConstructors, + doobie.util.meta.TimeMetaInstances { // enumeratum automatic support - implicit def enumeratumMeta[A <: enumeratum.EnumEntry](implicit - `enum`: enumeratum.Enum[A], - typeTag: TypeName[A] - ): Meta[A] = - Meta[String].timap(`enum`.withNameInsensitive)(_.entryName) + export enumeratum.Doobie.meta as enumeraturmMeta // newtype automatic support - implicit def coercibleMeta[R, N](implicit ev: Coercible[Meta[R], Meta[N]], R: Meta[R]): Meta[N] = ev(R) + export neotype.interop.doobie.{ + newtypeArrayGet, + newtypeArrayPut, + newtypeGet, + newtypePut, + subtypeArrayGet, + subtypeArrayPut, + subtypeGet, + subtypePut + } - implicit def idArrayMeta[E](implicit - to: Coercible[Set[UUID], Set[ID[E]]], - from: Coercible[Set[ID[E]], Set[UUID]] - ): Meta[Set[ID[E]]] = - unliftedUUIDArrayType.imap[Set[ID[E]]](arr => to(arr.toSet))(set => from(set).toArray) + given [E]: Meta[ID[E]] = + ID.unsafeMakeF[Meta, E](Meta[UUID]) - // handle updateable + given [E]: Meta[Set[ID[E]]] = + ID.unsafeMakeF[[A] =>> Meta[Set[A]], E](unliftedUUIDArrayType.imap[Set[UUID]](_.toSet)(_.toArray)) + + given Meta[CreationTime] = CreationTime.unsafeMakeF[Meta](JavaOffsetDateTimeMeta) - implicit class DoobieUpdatableOps[A](private val updatable: Updatable[A]) extends AnyVal { + given Meta[ModificationTime] = ModificationTime.unsafeMakeF[Meta](JavaOffsetDateTimeMeta) + + // handle updateable - def toUpdateFragment(columnName: Fragment)(implicit meta: Put[A]): Option[Fragment] = + extension [A](updatable: Updatable[A]) { + def toUpdateFragment(columnName: Fragment)(using Put[A]): Option[Fragment] = updatable.fold(value => (columnName ++ fr" = ${value}").some, none[Fragment]) } - implicit class DoobieOptionUpdatableOps[A](private val updatable: OptionUpdatable[A]) extends AnyVal { - - def toUpdateFragment(columnName: Fragment)(implicit meta: Put[A]): Option[Fragment] = + extension [A](updatable: OptionUpdatable[A]) { + def toUpdateFragment(columnName: Fragment)(using Put[A]): Option[Fragment] = updatable.fold(value => (columnName ++ fr"= ${value}").some, (columnName ++ fr"= null").some, none[Fragment]) } - implicit class FragmentOps(private val fragment: Fragment) extends AnyVal { - - def exists(implicit logHandler: LogHandler): ConnectionIO[Boolean] = - (fr"SELECT EXISTS(" ++ fragment ++ fr")").query[Boolean].unique + extension (fragment: Fragment) { + def exists(label: String): ConnectionIO[Boolean] = + (fr"SELECT EXISTS(" ++ fragment ++ fr")").queryWithLabel[Boolean](label).unique - def paginate[Entity: Read](offset: Long Refined NonNegative, limit: Int Refined Positive)(implicit - logHandler: LogHandler + def paginate[Entity: Read]( + offset: Paginated.Offset, + limit: Paginated.Limit, + label: String ): ConnectionIO[Paginated[Entity]] = { - val o = offset.value - val l = limit.value + val o: Long = offset.unwrap + val l: Int = limit.unwrap // limit 1 entity more than returned to check if there is a next page in pagination - (fragment ++ fr"LIMIT ${l + 1} OFFSET ${o}").query[Entity].to[List].map { entities => - val result = entities.take(l) - val nextOffset = - if (entities.sizeCompare(l) <= 0) None - else ParseRefined[SyncIO].parse[NonNegative](o + l).attempt.unsafeRunSync().toOption + (fragment ++ fr"LIMIT ${l + 1} OFFSET ${o}").queryWithLabel[Entity](label).to[List].map { entities => + val result = entities.take(l) + val nextOffset = if (entities.sizeIs <= l) None else Paginated.Offset.make(o + l).toOption Paginated(result, nextOffset) } } @@ -84,47 +90,43 @@ object DoobieSupport // handle errors - implicit class QueryOps[A](private val query: Query0[A]) extends AnyVal { - - def failNotFound(entity: String, id: ID[_])(implicit codePosition: CodePosition): ConnectionIO[A] = - query.unique.handleErrorWith { _ => - Sync[ConnectionIO].raiseError(CommonError.NotFound(entity, id, codePosition)) - } + extension [A](query: Query0[A]) { + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def failNotFound[Entity](entity: String, id: ID[Entity])(using CodePosition): ConnectionIO[A] = + query.unique.orRaise(CommonError.notFound(entity, id.asInstanceOf[ID[Any]])) } // log results - def doobieLogger(clazz: Class[_]): LogHandler = { - val logger = Logger(clazz) - LogHandler { - case doobie.util.log.Success(sql, _, exec, processing) => - logger.trace( - s"""SQL succeeded: - |${sql} - |execution: ${exec.toMillis.toString} ms - |processing: ${processing.toMillis.toString} ms - |total: ${(exec + processing).toMillis.toString} ms""".stripMargin - ) - case doobie.util.log.ExecFailure(sql, _, exec, failure) => - logger.error( - s"""SQL failed at execution: - |${sql} - |failure cause: - |${failure.getMessage} ms - |execution: ${exec.toMillis.toString} ms""".stripMargin, - failure - ) - case doobie.util.log.ProcessingFailure(sql, _, exec, processing, failure) => - logger.error( - s"""SQL failed at processing: - |${sql} - |failure cause: - |${failure.getMessage} - |execution: ${exec.toMillis.toString} ms - |processing: ${processing.toMillis.toString} ms - |total: ${(exec + processing).toMillis.toString} ms""".stripMargin, - failure - ) - } + def doobieLogger[F[_]](logger: Logger[F]): LogHandler[F] = { + case doobie.util.log.Success(sql, _, label, exec, processing) => + logger.trace( + s"""SQL succeeded: + |${label} + |${sql} + |execution: ${exec.toMillis.toString} ms + |processing: ${processing.toMillis.toString} ms + |total: ${(exec + processing).toMillis.toString} ms""".stripMargin + ) + case doobie.util.log.ExecFailure(sql, _, label, exec, failure) => + logger.error(failure)( + s"""SQL failed at execution: + |${label} + |${sql} + |failure cause: + |${failure.getMessage} ms + |execution: ${exec.toMillis.toString} ms""".stripMargin + ) + case doobie.util.log.ProcessingFailure(sql, _, label, exec, processing, failure) => + logger.error(failure)( + s"""SQL failed at processing: + |${label} + |${sql} + |failure cause: + |${failure.getMessage} + |execution: ${exec.toMillis.toString} ms + |processing: ${processing.toMillis.toString} ms + |total: ${(exec + processing).toMillis.toString} ms""".stripMargin + ) } } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventBus.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventBus.scala index 1d5bd727..0f7974a1 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventBus.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventBus.scala @@ -1,35 +1,41 @@ package io.branchtalk.shared.infrastructure -import cats.effect.Async +import cats.Show +import cats.data.NonEmptyList +import cats.effect.{ Async, Concurrent, Sync, Temporal } import com.typesafe.scalalogging.Logger -import fs2.Stream -import fs2.kafka._ -import io.branchtalk.shared.model.UUID +import fs2.{ Pipe, Stream } +import fs2.kafka.* +import io.branchtalk.shared.model.{ ShowPretty, UUID } +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } +import io.branchtalk.shared.model.AvroSerialization.DeserializationResult + +import scala.concurrent.duration.FiniteDuration object KafkaEventBus { + type Producer[F[_], Event] = Pipe[F, (UUID, Event), ProducerResult[UUID, Event]] + type Consumer[F[_], Event] = Stream[F, CommittableConsumerRecord[F, UUID, Event]] + type Committer[F[_]] = Pipe[F, CommittableOffset[F], Unit] + private val logger = Logger(getClass) - def producer[F[_]: Async, Event: Serializer[F, *]]( - settings: KafkaEventBusConfig - ): EventBusProducer[F, Event] = (events: Stream[F, (UUID, Event)]) => { - events - .map { case (key, value) => - ProducerRecords.one(ProducerRecord(settings.topic.nonEmptyString.value, key, value)) - } - .through(KafkaProducer.pipe(settings.toProducerConfig[F, Event])) - .evalTap(e => - Async[F].delay(logger.info(show"${e.records.size} events published to ${settings.topic.nonEmptyString.value}")) - ) - } + def producer[F[_]: Async, Event: Serializer[F, *]](busConfig: BusConfig): Producer[F, Event] = + (events: Stream[F, (UUID, Event)]) => + events + .map { case (key, value) => + ProducerRecords.one(ProducerRecord(busConfig.topic.unwrap, key, value)) + } + .through(KafkaProducer.pipe(busConfig.toProducerConfig[F, Event])) + .evalTap(e => Async[F].delay(logger.info(show"${e.size} events published to ${busConfig.topic}"))) def consumer[F[_]: Async, Event: SafeDeserializer[F, *]]( - busConfig: KafkaEventBusConfig, - consumerConfig: KafkaEventConsumerConfig - ): EventBusConsumer[F, Event] = + busConfig: BusConfig, + consumerConfig: ConsumerConfig + ): Consumer[F, Event] = KafkaConsumer .stream(busConfig.toConsumerConfig[F, Event](consumerConfig)) - .evalTap(_.subscribeTo(busConfig.topic.nonEmptyString.value)) + .evalTap(_.subscribeTo(busConfig.topic.unwrap)) .flatMap(_.stream) .flatMap { commitable => commitable.record.value match { @@ -48,4 +54,78 @@ object KafkaEventBus { val CommittableConsumerRecord(record, offset) = commitable CommittableConsumerRecord(ConsumerRecord(record.topic, record.partition, record.offset, record.key, value), offset) } + + type Topic = Topic.Type + object Topic extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(topic: Topic): Some[String] = Some(topic.unwrap) + + given ConfigReader[Topic] = ConfigReader[String].emapString("Topic")(make) + given Show[Topic] = unsafeMakeF[Show](Show[String]) + } + + type ConsumerGroup = ConsumerGroup.Type + object ConsumerGroup extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(consumerGroup: ConsumerGroup): Some[String] = Some(consumerGroup.unwrap) + + given ConfigReader[ConsumerGroup] = ConfigReader[String].emapString("ConsumerGroup")(make) + given Show[ConsumerGroup] = unsafeMakeF[Show](Show[String]) + } + + type MaxCommitSize = MaxCommitSize.Type + object MaxCommitSize extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input > 0 + + def unapply(domainName: MaxCommitSize): Some[Int] = Some(domainName.unwrap) + + given ConfigReader[MaxCommitSize] = ConfigReader[Int].emapString("MaxCommitSize")(make) + given Show[MaxCommitSize] = unsafeMakeF[Show](Show[Int]) + } + + type MaxCommitTime = MaxCommitTime.Type + object MaxCommitTime extends Newtype[FiniteDuration] { + + def unapply(maxCommitTime: MaxCommitTime): Some[FiniteDuration] = Some(maxCommitTime.unwrap) + + given ConfigReader[MaxCommitTime] = unsafeMakeF[ConfigReader](ConfigReader[FiniteDuration]) + given Show[MaxCommitTime] = unsafeMakeF[Show](Show[FiniteDuration]) + } + + final case class BusConfig( + servers: NonEmptyList[Server], + topic: Topic, + cache: Server + ) derives ConfigReader, + ShowPretty { + + def toConsumerConfig[F[_]: Sync, Event: SafeDeserializer[F, *]]( + consumerConfig: ConsumerConfig + ): ConsumerSettings[F, UUID, DeserializationResult[Event]] = + ConsumerSettings(Deserializer.uuid[F], SafeDeserializer[F, Event]) + .withAutoOffsetReset(AutoOffsetReset.Earliest) + .withBootstrapServers(servers.map(_.show).intercalate(",")) + .withGroupId(consumerConfig.consumerGroup.unwrap) + + def toProducerConfig[F[_]: Sync, Event: Serializer[F, *]]: ProducerSettings[F, UUID, Event] = + ProducerSettings(Serializer.uuid[F], Serializer[F, Event]) + .withBootstrapServers(servers.map(_.show).intercalate(",")) + + def toCommitBatch[F[_]: Concurrent: Temporal]( + consumerConfig: ConsumerConfig + ): Pipe[F, CommittableOffset[F], Unit] = + commitBatchWithin[F](consumerConfig.maxCommitSize.unwrap, consumerConfig.maxCommitTime.unwrap) + } + + final case class ConsumerConfig( + consumerGroup: ConsumerGroup, + maxCommitSize: MaxCommitSize, + maxCommitTime: MaxCommitTime + ) derives ConfigReader, + ShowPretty } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventBusConfig.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventBusConfig.scala deleted file mode 100644 index 661fb411..00000000 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventBusConfig.scala +++ /dev/null @@ -1,34 +0,0 @@ -package io.branchtalk.shared.infrastructure - -import cats.data.NonEmptyList -import cats.effect.{ Concurrent, Sync, Temporal } -import fs2.kafka._ -import fs2.Pipe -import io.branchtalk.shared.model.{ ShowPretty, UUID } -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.branchtalk.shared.model.AvroSerialization.DeserializationResult -import io.scalaland.catnip.Semi - -@Semi(ConfigReader, ShowPretty) final case class KafkaEventBusConfig( - servers: NonEmptyList[Server], - topic: Topic, - cache: Server -) { - - def toConsumerConfig[F[_]: Sync, Event: SafeDeserializer[F, *]]( - consumerConfig: KafkaEventConsumerConfig - ): ConsumerSettings[F, UUID, DeserializationResult[Event]] = - ConsumerSettings(Deserializer.uuid[F], SafeDeserializer[F, Event]) - .withAutoOffsetReset(AutoOffsetReset.Earliest) - .withBootstrapServers(servers.map(_.show).intercalate(",")) - .withGroupId(consumerConfig.consumerGroup.nonEmptyString.value) - - def toProducerConfig[F[_]: Sync, Event: Serializer[F, *]]: ProducerSettings[F, UUID, Event] = - ProducerSettings(Serializer.uuid[F], Serializer[F, Event]) - .withBootstrapServers(servers.map(_.show).intercalate(",")) - - def toCommitBatch[F[_]: Concurrent: Temporal]( - consumerConfig: KafkaEventConsumerConfig - ): Pipe[F, CommittableOffset[F], Unit] = - commitBatchWithin[F](consumerConfig.maxCommitSize.positiveInt.value, consumerConfig.maxCommitTime.finiteDuration) -} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventConsumerConfig.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventConsumerConfig.scala deleted file mode 100644 index 6df56806..00000000 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaEventConsumerConfig.scala +++ /dev/null @@ -1,11 +0,0 @@ -package io.branchtalk.shared.infrastructure - -import io.branchtalk.shared.model.ShowPretty -import io.scalaland.catnip.Semi -import pureconfig.ConfigReader - -@Semi(ConfigReader, ShowPretty) final case class KafkaEventConsumerConfig( - consumerGroup: ConsumerGroup, - maxCommitSize: MaxCommitSize, - maxCommitTime: MaxCommitTime -) diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaSerialization.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaSerialization.scala index 34db81d3..80998a33 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaSerialization.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/KafkaSerialization.scala @@ -8,9 +8,9 @@ import io.branchtalk.shared.model.AvroSerialization.DeserializationResult object KafkaSerialization { - implicit def kafkaSerializer[F[_]: Sync, A: Encoder]: Serializer[F, A] = + given [F[_]: Sync, A: Encoder: SchemaFor]: Serializer[F, A] = Serializer.lift[F, A](AvroSerialization.serialize(_)) - implicit def kafkaDeserializer[F[_]: Sync, A: Decoder: SchemaFor]: SafeDeserializer[F, A] = + given [F[_]: Sync, A: Decoder: SchemaFor]: SafeDeserializer[F, A] = Deserializer.lift[F, DeserializationResult[A]](AvroSerialization.deserialize(_)) } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PostgresConfig.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PostgresConfig.scala deleted file mode 100644 index d084fea1..00000000 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PostgresConfig.scala +++ /dev/null @@ -1,15 +0,0 @@ -package io.branchtalk.shared.infrastructure - -import io.scalaland.catnip.Semi -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.branchtalk.shared.model.ShowPretty - -@Semi(ConfigReader, ShowPretty) final case class PostgresConfig( - url: DatabaseURL, - username: DatabaseUsername, - password: DatabasePassword, - schema: DatabaseSchema, - domain: DatabaseDomain, - connectionPool: DatabaseConnectionPool, - migrationOnStart: DatabaseMigrationOnStart -) diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PostgresDatabase.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PostgresDatabase.scala index c408aab7..103a2a44 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PostgresDatabase.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PostgresDatabase.scala @@ -1,55 +1,57 @@ package io.branchtalk.shared.infrastructure +import cats.Show import cats.effect.{ Async, Resource, Sync } import com.zaxxer.hikari.metrics.{ IMetricsTracker, MetricsTrackerFactory, PoolStats } import com.zaxxer.hikari.metrics.prometheus.PrometheusHistogramMetricsTrackerFactory -import doobie._ -import doobie.implicits._ +import doobie.* +import doobie.implicits.* import doobie.hikari.HikariTransactor -import io.branchtalk.shared.infrastructure.PostgresDatabase.PrefixedMetricsTrackerFactory +import io.branchtalk.logging.Logger +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } +import io.branchtalk.shared.model.* import io.prometheus.client.{ Collector, CollectorRegistry } import org.flywaydb.core.Flyway import scala.util.Random -final class PostgresDatabase(config: PostgresConfig) { +final class PostgresDatabase(config: PostgresDatabase.Config) { private val randomPrefixLength = 6 private def flyway[F[_]: Sync] = Sync[F].delay( Flyway .configure() - .dataSource(config.url.nonEmptyString.value, - config.username.nonEmptyString.value, - config.password.nonEmptyString.value - ) - .schemas(config.schema.nonEmptyString.value) - .table(s"flyway_${config.domain.nonEmptyString.value}_schema_history") - .locations(s"db/${config.domain.nonEmptyString.value}/migrations") + .dataSource(config.url.unwrap, config.username.unwrap, config.password.unwrap) + .schemas(config.schema.unwrap) + .table(s"flyway_${config.domain.unwrap}_schema_history") + .locations(s"db/${config.domain.unwrap}/migrations") .load() ) - def transactor[F[_]: Async](registry: CollectorRegistry): Resource[F, HikariTransactor[F]] = + def transactor[F[_]: Async](logger: Logger[F], registry: CollectorRegistry): Resource[F, HikariTransactor[F]] = for { - connectEC <- doobie.util.ExecutionContexts.fixedThreadPool[F](config.connectionPool.positiveInt.value) - xa <- HikariTransactor.initial[F](connectEC) + connectEC <- doobie.util.ExecutionContexts.fixedThreadPool[F](config.connectionPool.unwrap) + xa <- HikariTransactor.initial[F](connectEC, logHandler = Some(doobieLogger(logger))) _ <- Resource.eval { xa.configure { ds => Async[F].delay { ds.setMetricsTrackerFactory( - new PrefixedMetricsTrackerFactory(config.domain.nonEmptyString.value + "_" + LazyList - .continually(Random.nextPrintableChar()) - .filter(_.isLetter) - .take(randomPrefixLength) - .mkString, - registry + new PostgresDatabase.PrefixedMetricsTrackerFactory( + config.domain.unwrap + "_" + LazyList + .continually(Random.nextPrintableChar()) + .filter(_.isLetter) + .take(randomPrefixLength) + .mkString, + registry ) ) - ds.setJdbcUrl(config.url.nonEmptyString.value) - ds.setUsername(config.username.nonEmptyString.value) - ds.setPassword(config.password.nonEmptyString.value) + ds.setJdbcUrl(config.url.unwrap) + ds.setUsername(config.username.unwrap) + ds.setPassword(config.password.unwrap) ds.setMaxLifetime(5 * 60 * 1000) - ds.setSchema(config.schema.nonEmptyString.value) + ds.setSchema(config.schema.unwrap) } } } @@ -58,16 +60,104 @@ final class PostgresDatabase(config: PostgresConfig) { def migrate[F[_]: Sync]: F[Unit] = flyway[F].map(_.migrate()).void - def healthCheck[F[_]: Sync](xa: Transactor[F]): F[String] = sql"select now()".query[String].unique.transact(xa) + def healthCheck[F[_]: Sync](xa: Transactor[F]): F[String] = + sql"select now()".queryWithLabel[String]("DB Health Check").unique.transact(xa) } object PostgresDatabase { + type URL = URL.Type + object URL extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(url: URL): Some[String] = Some(url.unwrap) + + given ConfigReader[URL] = ConfigReader[String].emapString("URL")(make) + given Show[URL] = unsafeMakeF[Show](Show[String]) + } + + type Username = Username.Type + object Username extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(username: Username): Some[String] = Some(username.unwrap) + + given ConfigReader[Username] = ConfigReader[String].emapString("Username")(make) + given Show[Username] = unsafeMakeF[Show](Show[String]) + } + + // not a newtype to override toString + final case class Password private (unwrap: String) { + override def toString: String = "[PASSWORD]" + } + object Password { + def parse(string: String): Either[String, Password] = + if string.nonEmpty then Right(Password(string)) else Left("Password cannot be empty") + + given ConfigReader[Password] = ConfigReader[String].emapString("Password")(parse) + given Show[Password] = _ => "PASSWORD" + } + + type Schema = Schema.Type + object Schema extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(domainName: Schema): Some[String] = Some(domainName.unwrap) + + given ConfigReader[Schema] = ConfigReader[String].emapString("Schema")(make) + given Show[Schema] = unsafeMakeF[Show](Show[String]) + } + + type Domain = Domain.Type + object Domain extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(domain: Domain): Some[String] = Some(domain.unwrap) + + given ConfigReader[Domain] = ConfigReader[String].emapString("Domain")(make) + given Show[Domain] = unsafeMakeF[Show](Show[String]) + } + + type ConnectionPool = ConnectionPool.Type + object ConnectionPool extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input > 0 + + def unapply(connectionPool: ConnectionPool): Some[Int] = Some(connectionPool.unwrap) + + given ConfigReader[ConnectionPool] = ConfigReader[Int].emapString("ConnectionPool")(make) + given Show[ConnectionPool] = unsafeMakeF[Show](Show[Int]) + } + + type MigrationOnStart = MigrationOnStart.Type + object MigrationOnStart extends Newtype[Boolean] { + + def unapply(migrationOnStart: MigrationOnStart): Some[Boolean] = Some(migrationOnStart.unwrap) + + given ConfigReader[MigrationOnStart] = unsafeMakeF[ConfigReader](ConfigReader[Boolean]) + given Show[MigrationOnStart] = unsafeMakeF[Show](Show[Boolean]) + } + + final case class Config( + url: URL, + username: Username, + password: Password, + schema: Schema, + domain: Domain, + connectionPool: ConnectionPool, + migrationOnStart: MigrationOnStart + ) derives ConfigReader, + ShowPretty + // suppress "Collector already registered that provides name: hikaricp_" - final class NonComplainingCollectorRegistry(impl: CollectorRegistry) extends CollectorRegistry { + final private class NonComplainingCollectorRegistry(impl: CollectorRegistry) extends CollectorRegistry { override def register(m: Collector): Unit = try impl.register(m) catch { case _: IllegalArgumentException => /* suppressed */ } override def unregister(m: Collector): Unit = impl.unregister(m) - override def clear(): Unit = impl.clear() + override def clear(): Unit = impl.clear() override def metricFamilySamples(): java.util.Enumeration[Collector.MetricFamilySamples] = impl.metricFamilySamples() override def filteredMetricFamilySamples( diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Projector.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Projector.scala index 07f99015..87d831f8 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Projector.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Projector.scala @@ -1,9 +1,9 @@ package io.branchtalk.shared.infrastructure import cats.Monoid -import fs2._ -import _root_.io.branchtalk.logging.{ CorrelationID, MDC } import cats.effect.Sync +import fs2.{ io => _, * } +import io.branchtalk.logging.{ CorrelationID, MDC } trait Projector[F[_], -I, +O] extends Pipe[F, I, O] { def contramap[I2](f: I2 => I): Projector[F, I2, O] = (_: Stream[F, I2]).map(f).through(apply) @@ -14,14 +14,14 @@ trait Projector[F[_], -I, +O] extends Pipe[F, I, O] { def mapStream[O2](f: O => Stream[F, O2]): Projector[F, I, O2] = (_: Stream[F, I]).through(apply).flatMap(f) - def withCorrelationID[A](correlationID: CorrelationID)(fa: F[A])(implicit F: Sync[F], mdc: MDC[F]): F[A] = + def withCorrelationID[A](correlationID: CorrelationID)(fa: F[A])(using Sync[F], MDC[F]): F[A] = correlationID.updateMDC[F] >> fa } object Projector { def lift[F[_], I, O](pipe: Pipe[F, I, O]): Projector[F, I, O] = (i: Stream[F, I]) => pipe(i) - implicit def monoid[F[_], I, O]: Monoid[Projector[F, I, O]] = new Monoid[Projector[F, I, O]] { + given [F[_], I, O]: Monoid[Projector[F, I, O]] = new { override def empty: Projector[F, I, O] = lift(Monoid[Pipe[F, I, O]].empty) override def combine(x: Projector[F, I, O], y: Projector[F, I, O]): Projector[F, I, O] = lift(Monoid[Pipe[F, I, O]].combine(x, y)) diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PureconfigSupport.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PureconfigSupport.scala index b437c445..aabb60b8 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PureconfigSupport.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/PureconfigSupport.scala @@ -1,100 +1,45 @@ package io.branchtalk.shared.infrastructure -import cats.{ Alternative, Foldable } -import cats.data.{ Chain, NonEmptyChain, NonEmptyList, NonEmptyMap, NonEmptySet, NonEmptyVector } -import cats.kernel.Order -import enumeratum._ -import enumeratum.values._ -import eu.timepit.refined.api._ - -import scala.collection.immutable.SortedSet -import scala.reflect.ClassTag -import scala.reflect.runtime.universe.WeakTypeTag - // Allows `import PureconfigSupport._` instead of `import pureconfig._, pureconfig.module.cats._, ...`. -// `pureconfig` modules are objects, there are no traits so if we want to have everything in one place we have to... object PureconfigSupport extends LowPriorityPureconfigImplicit { - type ConfigReader[A] = pureconfig.ConfigReader[A] - val ConfigReader = pureconfig.ConfigReader + export pureconfig.{ ConfigReader, ConfigSource, ConfigWriter } + export pureconfig.generic.derivation.default.derived - type ConfigWriter[A] = pureconfig.ConfigWriter[A] - val ConfigWriter = pureconfig.ConfigWriter - - type ConfigSource = pureconfig.ConfigSource - val ConfigSource = pureconfig.ConfigSource + extension [A](reader: ConfigReader[A]) { + @SuppressWarnings(Array("org.wartremover.warts.ToString")) + def emapString[B](tpe: String)(f: A => Either[String, B]): ConfigReader[B] = + reader.emap(a => f(a).left.map(err => pureconfig.error.CannotConvert(a.toString, tpe, err))) + } // Cats - implicit def nonEmptyListReader[A](implicit reader: ConfigReader[List[A]]): ConfigReader[NonEmptyList[A]] = - pureconfig.module.cats.nonEmptyListReader[A] - implicit def nonEmptyListWriter[A](implicit writer: ConfigWriter[List[A]]): ConfigWriter[NonEmptyList[A]] = - pureconfig.module.cats.nonEmptyListWriter[A] - - implicit def nonEmptyVectorReader[A](implicit reader: ConfigReader[Vector[A]]): ConfigReader[NonEmptyVector[A]] = - pureconfig.module.cats.nonEmptyVectorReader[A] - implicit def nonEmptyVectorWriter[A](implicit writer: ConfigWriter[Vector[A]]): ConfigWriter[NonEmptyVector[A]] = - pureconfig.module.cats.nonEmptyVectorWriter[A] - - implicit def nonEmptySetReader[A](implicit reader: ConfigReader[SortedSet[A]]): ConfigReader[NonEmptySet[A]] = - pureconfig.module.cats.nonEmptySetReader[A] - implicit def nonEmptySetWriter[A](implicit writer: ConfigWriter[SortedSet[A]]): ConfigWriter[NonEmptySet[A]] = - pureconfig.module.cats.nonEmptySetWriter[A] - - implicit def nonEmptyMapReader[A, B](implicit - reader: ConfigReader[Map[A, B]], - ord: Order[A] - ): ConfigReader[NonEmptyMap[A, B]] = - pureconfig.module.cats.nonEmptyMapReader[A, B] - implicit def nonEmptyMapWriter[A, B](implicit writer: ConfigWriter[Map[A, B]]): ConfigWriter[NonEmptyMap[A, B]] = - pureconfig.module.cats.nonEmptyMapWriter[A, B] - - // For emptiable foldables not covered by TraversableOnce reader/writer, e.g. Chain. - implicit def lowPriorityNonReducibleReader[A, F[_]: Foldable: Alternative](implicit - reader: ConfigReader[List[A]] - ): pureconfig.Exported[ConfigReader[F[A]]] = - pureconfig.module.cats.lowPriorityNonReducibleReader[A, F] - implicit def lowPriorityNonReducibleWriter[A, F[_]: Foldable: Alternative](implicit - writer: ConfigWriter[List[A]] - ): pureconfig.Exported[ConfigWriter[F[A]]] = - pureconfig.module.cats.lowPriorityNonReducibleWriter[A, F] - - implicit def nonEmptyChainReader[A](implicit reader: ConfigReader[Chain[A]]): ConfigReader[NonEmptyChain[A]] = - pureconfig.module.cats.nonEmptyChainReader[A] - implicit def nonEmptyChainWriter[A](implicit writer: ConfigWriter[Chain[A]]): ConfigWriter[NonEmptyChain[A]] = - pureconfig.module.cats.nonEmptyChainWriter[A] + export pureconfig.module.cats.{ + lowPriorityNonReducibleReader, + lowPriorityNonReducibleWriter, + nonEmptyChainReader, + nonEmptyChainWriter, + nonEmptyListReader, + nonEmptyListWriter, + nonEmptyMapReader, + nonEmptyMapWriter, + nonEmptySetReader, + nonEmptySetWriter, + nonEmptyVectorReader, + nonEmptyVectorWriter + } // enumeratum - implicit def enumeratumConfigConvert[A <: EnumEntry: Enum: ClassTag]: pureconfig.ConfigConvert[A] = - pureconfig.module.enumeratum.enumeratumConfigConvert[A] - - implicit def enumeratumIntConfigConvert[A <: IntEnumEntry: IntEnum: ClassTag]: pureconfig.ConfigConvert[A] = - pureconfig.module.enumeratum.enumeratumIntConfigConvert[A] - - implicit def enumeratumLongConfigConvert[A <: LongEnumEntry: LongEnum: ClassTag]: pureconfig.ConfigConvert[A] = - pureconfig.module.enumeratum.enumeratumLongConfigConvert[A] - - implicit def enumeratumShortConfigConvert[A <: ShortEnumEntry: ShortEnum: ClassTag]: pureconfig.ConfigConvert[A] = - pureconfig.module.enumeratum.enumeratumShortConfigConvert[A] - - implicit def enumeratumStringConfigConvert[A <: StringEnumEntry: StringEnum: ClassTag]: pureconfig.ConfigConvert[A] = - pureconfig.module.enumeratum.enumeratumStringConfigConvert[A] - - implicit def enumeratumByteConfigConvert[A <: ByteEnumEntry: ByteEnum: ClassTag]: pureconfig.ConfigConvert[A] = - pureconfig.module.enumeratum.enumeratumByteConfigConvert[A] - - implicit def enumeratumCharConfigConvert[A <: CharEnumEntry: CharEnum: ClassTag]: pureconfig.ConfigConvert[A] = - pureconfig.module.enumeratum.enumeratumCharConfigConvert[A] - - // refined - - implicit def refTypeConfigConvert[F[_, _], T, P](implicit - configConvert: pureconfig.ConfigConvert[T], - refType: RefType[F], - validate: Validate[T, P], - typeTag: WeakTypeTag[F[T, P]] - ): pureconfig.ConfigConvert[F[T, P]] = eu.timepit.refined.pureconfig.refTypeConfigConvert[F, T, P] + export pureconfig.module.enumeratum.{ + enumeratumByteConfigConvert, + enumeratumCharConfigConvert, + enumeratumConfigConvert, + enumeratumIntConfigConvert, + enumeratumLongConfigConvert, + enumeratumShortConfigConvert, + enumeratumStringConfigConvert + } } // for some reason original traversableReader is not seen, maybe because author forgot to annotate it? diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Reads.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Reads.scala index 5dac51a8..92bd6ed6 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Reads.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Reads.scala @@ -1,8 +1,8 @@ package io.branchtalk.shared.infrastructure import cats.effect.Sync -import doobie._ -import doobie.implicits._ +import doobie.* +import doobie.implicits.* import fs2.Stream // Utilities for reads services. @@ -16,3 +16,10 @@ abstract class Reads[F[_]: Sync, Entity](transactor: Transactor[F]) { final protected def queryStream(query: Query0[Entity]): Stream[F, Entity] = query.stream.transact(transactor) } +object Reads { + + final case class Infrastructure[F[_], Event]( + transactor: Transactor[F], + consumer: KafkaEventBus.ConsumerConfig => ConsumerStream[F, Event] + ) +} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/SafeDeserializer.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/SafeDeserializer.scala new file mode 100644 index 00000000..b892a9bd --- /dev/null +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/SafeDeserializer.scala @@ -0,0 +1,10 @@ +package io.branchtalk.shared.infrastructure + +import fs2.kafka.Deserializer +import io.branchtalk.shared.model.AvroSerialization.DeserializationResult + +type SafeDeserializer[F[_], Event] = Deserializer[F, DeserializationResult[Event]] +object SafeDeserializer { + + inline def apply[F[_], Event](using sd: SafeDeserializer[F, Event]): SafeDeserializer[F, Event] = sd +} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Server.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Server.scala index a6d0d928..e7c87431 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Server.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Server.scala @@ -1,16 +1,35 @@ package io.branchtalk.shared.infrastructure import cats.Show -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.Positive -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.scalaland.catnip.Semi - -@Semi(ConfigReader) final case class Server( - host: NonEmptyString, - port: Int Refined Positive -) +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } + +final case class Server( + host: Server.Host, + port: Server.Port +) derives ConfigReader object Server { - implicit def show: Show[Server] = (s: Server) => show"${s.host.value}:${s.port.value}" + + type Host = Host.Type + object Host extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(host: Host): Some[String] = Some(host.unwrap) + + given ConfigReader[Host] = ConfigReader[String].emapString("Host")(make) + given Show[Host] = unsafeMakeF[Show](Show[String]) + } + + type Port = Port.Type + object Port extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input > 0 + + def unapply(port: Port): Some[Int] = Some(port.unwrap) + + given ConfigReader[Port] = ConfigReader[Int].emapString("Port")(make) + given Show[Port] = unsafeMakeF[Show](Show[Int]) + } + + given Show[Server] = (s: Server) => show"${s.host}:${s.port}" } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/StreamRunner.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/StreamRunner.scala index da8eea17..ac777b09 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/StreamRunner.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/StreamRunner.scala @@ -2,9 +2,9 @@ package io.branchtalk.shared.infrastructure import cats.{ Monad, Semigroup } import cats.effect.{ Async, Deferred, Resource } -import cats.effect.implicits._ +import cats.effect.implicits.* import fs2.Stream -import io.branchtalk.shared.model.Logger +import io.branchtalk.logging.Logger // Start Stream as a Fiber, close it gracefully when releasing the resource. final case class StreamRunner[F[_]](asResource: Resource[F, Unit]) @@ -30,13 +30,12 @@ object StreamRunner { ) } .void - .handleErrorWith { error: Throwable => + .handleErrorWith { (error: Throwable) => Resource.eval( logger.error(error)("Error occurred before kill-switch was triggered") >> error.raiseError[F, Unit] ) } } - implicit def semigroup[F[_]: Monad]: Semigroup[StreamRunner[F]] = - (a, b) => StreamRunner(a.asResource >> b.asResource) + given [F[_]: Monad]: Semigroup[StreamRunner[F]] = (a, b) => StreamRunner(a.asResource >> b.asResource) } diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Writes.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Writes.scala index f6788d5d..1e125f50 100644 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Writes.scala +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/Writes.scala @@ -1,39 +1,52 @@ package io.branchtalk.shared.infrastructure import cats.effect.Sync -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.model.{ CodePosition, CommonError, ID, UUID } -import fs2._ +import fs2.* // Utilities for writes services. -abstract class Writes[F[_]: Sync, Entity, Event](producer: EventBusProducer[F, Event]) { - - implicit private val logHandler: LogHandler = doobieLogger(getClass) +abstract class Writes[F[_]: Sync, Entity, Event](producer: KafkaEventBus.Producer[F, Event]) { // sending event to internal bus as a part of a write model final protected def postEvent(id: ID[Entity], event: Event): F[Unit] = - producer(Stream[F, (UUID, Event)](id.uuid -> event)).compile.drain + producer(Stream[F, (UUID, Event)](id.unwrap -> event)).compile.drain protected class EntityCheck(entity: String, transactor: Transactor[F]) { - def apply(entityID: ID[Entity], fragment: Fragment)(implicit codePosition: CodePosition): F[Unit] = - fragment.exists.transact(transactor).flatMap { + def apply(entityID: ID[Entity], fragment: Fragment)(using CodePosition): F[Unit] = + fragment.exists(show"Check that $entity ID=$entityID exists").transact(transactor).flatMap { case true => Sync[F].unit - case false => (CommonError.NotFound(entity, entityID, codePosition): Throwable).raiseError[F, Unit] + case false => (CommonError.notFound(entity, entityID): Throwable).raiseError[F, Unit] } } protected class ParentCheck[Parent](entity: String, transactor: Transactor[F]) { - def apply(parentID: ID[Parent], fragment: Fragment)(implicit codePosition: CodePosition): F[Unit] = - fragment.exists.transact(transactor).flatMap { + def apply(parentID: ID[Parent], fragment: Fragment)(using CodePosition): F[Unit] = + fragment.exists(show"Check that parental $entity ID=$parentID exists").transact(transactor).flatMap { case true => Sync[F].unit - case false => (CommonError.ParentNotExist(entity, parentID, codePosition): Throwable).raiseError[F, Unit] + case false => (CommonError.parentNotExist(entity, parentID): Throwable).raiseError[F, Unit] } - def withValue[T: Meta](parentID: ID[Parent], fragment: Fragment)(implicit codePosition: CodePosition): F[T] = - fragment.query[T].option.transact(transactor).flatMap { - case Some(t) => Sync[F].pure(t) - case None => (CommonError.ParentNotExist(entity, parentID, codePosition): Throwable).raiseError[F, T] - } + def withValue[T: Meta](parentID: ID[Parent], fragment: Fragment)(using CodePosition): F[T] = + fragment + .queryWithLabel[T](show"Check that parental $entity ID=$parentID exists") + .option + .transact(transactor) + .flatMap { + case Some(t) => Sync[F].pure(t) + case None => (CommonError.parentNotExist(entity, parentID): Throwable).raiseError[F, T] + } } } +object Writes { + + final case class Infrastructure[F[_], Event, InternalEvent]( + transactor: Transactor[F], + internalProducer: KafkaEventBus.Producer[F, InternalEvent], + internalConsumerStream: ConsumerStream[F, InternalEvent], + producer: KafkaEventBus.Producer[F, Event], + consumerStream: ConsumerStream.Factory[F, Event], + cache: Cache[F, String, Event] + ) +} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/filters.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/filters.scala new file mode 100644 index 00000000..1edcbd8d --- /dev/null +++ b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/filters.scala @@ -0,0 +1,11 @@ +package io.branchtalk.shared.infrastructure + +import cats.MonadThrow + +extension [F[_]: MonadThrow, A](fa: F[A]) { + // suppress complains from for-comprehension and case + def withFilter(f: A => Boolean) = fa.flatMap { a => + if (f(a)) a.pure[F] + else (new NoSuchElementException: Throwable).raiseError[F, A] + } +} diff --git a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/package.scala b/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/package.scala deleted file mode 100644 index a8d24adb..00000000 --- a/modules/common-infrastructure/src/main/scala/io/branchtalk/shared/infrastructure/package.scala +++ /dev/null @@ -1,107 +0,0 @@ -package io.branchtalk.shared - -import cats.Show -import eu.timepit.refined.numeric.Positive -import eu.timepit.refined.api.Refined -import eu.timepit.refined.types.string.NonEmptyString -import fs2.{ Pipe, Stream } -import fs2.kafka.{ CommittableConsumerRecord, CommittableOffset, Deserializer, ProducerResult } -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.branchtalk.shared.model.AvroSerialization.DeserializationResult -import io.branchtalk.shared.model._ -import io.estatico.newtype.macros.newtype -import io.estatico.newtype.ops._ - -import scala.concurrent.duration.FiniteDuration - -package object infrastructure { - - type EventBusProducer[F[_], Event] = Pipe[F, (UUID, Event), ProducerResult[Unit, UUID, Event]] - type EventBusConsumer[F[_], Event] = Stream[F, CommittableConsumerRecord[F, UUID, Event]] - type EventBusCommitter[F[_]] = Pipe[F, CommittableOffset[F], Unit] - - type SafeDeserializer[F[_], Event] = Deserializer[F, DeserializationResult[Event]] - object SafeDeserializer { - @inline def apply[F[_], Event](implicit sd: SafeDeserializer[F, Event]): SafeDeserializer[F, Event] = sd - } - - @newtype final case class DomainName(nonEmptyString: NonEmptyString) - object DomainName { - def unapply(domainName: DomainName): Some[NonEmptyString] = Some(domainName.nonEmptyString) - - implicit val configReader: ConfigReader[DomainName] = ConfigReader[NonEmptyString].coerce - implicit val show: Show[DomainName] = Show.wrap(_.nonEmptyString.value) - } - - @newtype final case class DatabaseURL(nonEmptyString: NonEmptyString) - object DatabaseURL { - def unapply(databaseURL: DatabaseURL): Some[NonEmptyString] = Some(databaseURL.nonEmptyString) - - implicit val configReader: ConfigReader[DatabaseURL] = ConfigReader[NonEmptyString].coerce - implicit val show: Show[DatabaseURL] = Show.wrap(_.nonEmptyString.value) - } - @newtype final case class DatabaseUsername(nonEmptyString: NonEmptyString) - object DatabaseUsername { - def unapply(databaseUsername: DatabaseUsername): Some[NonEmptyString] = Some(databaseUsername.nonEmptyString) - - implicit val configReader: ConfigReader[DatabaseUsername] = ConfigReader[NonEmptyString].coerce - implicit val show: Show[DatabaseUsername] = Show.wrap(_.nonEmptyString.value) - } - @newtype final case class DatabaseSchema(nonEmptyString: NonEmptyString) - object DatabaseSchema { - def unapply(databaseSchema: DatabaseSchema): Some[NonEmptyString] = Some(databaseSchema.nonEmptyString) - - implicit val configReader: ConfigReader[DatabaseSchema] = ConfigReader[NonEmptyString].coerce - implicit val show: Show[DatabaseSchema] = Show.wrap(_.nonEmptyString.value) - } - @newtype final case class DatabaseDomain(nonEmptyString: NonEmptyString) - object DatabaseDomain { - def unapply(databaseDomain: DatabaseDomain): Some[NonEmptyString] = Some(databaseDomain.nonEmptyString) - - implicit val configReader: ConfigReader[DatabaseDomain] = ConfigReader[NonEmptyString].coerce - implicit val show: Show[DatabaseDomain] = Show.wrap(_.nonEmptyString.value) - } - @newtype final case class DatabaseConnectionPool(positiveInt: Int Refined Positive) - object DatabaseConnectionPool { - def unapply(connectionPool: DatabaseConnectionPool): Some[Int Refined Positive] = Some(connectionPool.positiveInt) - - implicit val configReader: ConfigReader[DatabaseConnectionPool] = ConfigReader[Int Refined Positive].coerce - implicit val show: Show[DatabaseConnectionPool] = Show.wrap(_.positiveInt.value) - } - @newtype final case class DatabaseMigrationOnStart(bool: Boolean) - object DatabaseMigrationOnStart { - def unapply(migrationOnStart: DatabaseMigrationOnStart): Some[Boolean] = Some(migrationOnStart.bool) - - implicit val configReader: ConfigReader[DatabaseMigrationOnStart] = ConfigReader[Boolean].coerce - implicit val show: Show[DatabaseMigrationOnStart] = Show.wrap(_.bool) - } - - @newtype final case class Topic(nonEmptyString: NonEmptyString) - object Topic { - def unapply(topic: Topic): Some[NonEmptyString] = Some(topic.nonEmptyString) - - implicit val configReader: ConfigReader[Topic] = ConfigReader[NonEmptyString].coerce - implicit val show: Show[Topic] = Show.wrap(_.nonEmptyString.value) - } - @newtype final case class ConsumerGroup(nonEmptyString: NonEmptyString) - object ConsumerGroup { - def unapply(consumerGroup: ConsumerGroup): Some[NonEmptyString] = Some(consumerGroup.nonEmptyString) - - implicit val configReader: ConfigReader[ConsumerGroup] = ConfigReader[NonEmptyString].coerce - implicit val show: Show[ConsumerGroup] = Show.wrap(_.nonEmptyString.value) - } - @newtype final case class MaxCommitSize(positiveInt: Int Refined Positive) - object MaxCommitSize { - def unapply(maxCommitSize: MaxCommitSize): Some[Int Refined Positive] = Some(maxCommitSize.positiveInt) - - implicit val configReader: ConfigReader[MaxCommitSize] = ConfigReader[Int Refined Positive].coerce - implicit val show: Show[MaxCommitSize] = Show.wrap(_.positiveInt.value) - } - @newtype final case class MaxCommitTime(finiteDuration: FiniteDuration) - object MaxCommitTime { - def unapply(maxCommitTime: MaxCommitTime): Some[FiniteDuration] = Some(maxCommitTime.finiteDuration) - - implicit val configReader: ConfigReader[MaxCommitTime] = ConfigReader[FiniteDuration].coerce - implicit val show: Show[MaxCommitTime] = Show.wrap(_.finiteDuration) - } -} diff --git a/modules/common-infrastructure/src/it/resources/logback-test.xml b/modules/common-infrastructure/src/test/resources/logback-test.xml similarity index 100% rename from modules/common-infrastructure/src/it/resources/logback-test.xml rename to modules/common-infrastructure/src/test/resources/logback-test.xml diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/IOTest.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/IOTest.scala similarity index 70% rename from modules/common-infrastructure/src/it/scala/io/branchtalk/IOTest.scala rename to modules/common-infrastructure/src/test/scala/io/branchtalk/IOTest.scala index 25d282f9..4fed771d 100644 --- a/modules/common-infrastructure/src/it/scala/io/branchtalk/IOTest.scala +++ b/modules/common-infrastructure/src/test/scala/io/branchtalk/IOTest.scala @@ -5,19 +5,18 @@ import cats.effect.unsafe.implicits.global import io.branchtalk.logging.MDC import io.branchtalk.logging.MDC.Ctx import io.branchtalk.shared.model.CodePosition -import org.specs2.matcher.MatchResult +import org.specs2.execute.Result import org.specs2.specification.core.{ AsExecution, Execution } -import org.specs2.matcher.Matchers._ -import org.specs2.matcher.MustMatchers.theValue +import org.specs2.matcher.MustMatchers.{ *, given } -import scala.concurrent.duration._ +import scala.concurrent.duration.* trait IOTest { - val pass: MatchResult[Boolean] = true must beTrue + val pass: Result = true must beTrue // we don't rely on MDC in tests - implicit protected val noopMDC: MDC[IO] = new MDC[IO] { + protected given noopMDC: MDC[IO] = new MDC[IO] { override def ctx: IO[Ctx] = IO.pure(Map.empty) override def get(key: String): IO[Option[String]] = IO.pure(None) @@ -25,10 +24,10 @@ trait IOTest { override def set(key: String, value: String): IO[Unit] = IO.unit } - implicit class IOTestOps[T](private val io: IO[T]) { + extension [T](io: IO[T]) { - def eventually(retry: Int = 50, delay: FiniteDuration = 250.millis, timeout: FiniteDuration = 15.seconds)(implicit - codePosition: CodePosition + def eventually(retry: Int = 50, delay: FiniteDuration = 250.millis, timeout: FiniteDuration = 15.seconds)(using + codePosition: CodePosition ): IO[T] = { def withRetry(attemptsLeft: Int): PartialFunction[Throwable, IO[T]] = { case cause: Throwable => if (attemptsLeft <= 0) @@ -43,7 +42,7 @@ trait IOTest { io.flatTap(current => IO(scala.Predef.assert(condition(current), msg))) } - implicit protected def ioAsTest[T: AsExecution]: AsExecution[IO[T]] = new AsExecution[IO[T]] { + protected given ioAsTest[T: AsExecution]: AsExecution[IO[T]] = new AsExecution[IO[T]] { override def execute(t: => IO[T]): Execution = AsExecution[T].execute(t.unsafeRunSync()) } } diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/ResourcefulTest.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/ResourcefulTest.scala similarity index 52% rename from modules/common-infrastructure/src/it/scala/io/branchtalk/ResourcefulTest.scala rename to modules/common-infrastructure/src/test/scala/io/branchtalk/ResourcefulTest.scala index a4e42e51..afe0b270 100644 --- a/modules/common-infrastructure/src/it/scala/io/branchtalk/ResourcefulTest.scala +++ b/modules/common-infrastructure/src/test/scala/io/branchtalk/ResourcefulTest.scala @@ -4,9 +4,9 @@ import cats.effect.std.Dispatcher import cats.effect.{ IO, Resource } import cats.effect.unsafe.implicits.global import io.prometheus.client.CollectorRegistry -import org.specs2.specification.BeforeAfterAll +import org.specs2.specification.core.Execution -trait ResourcefulTest extends BeforeAfterAll { +trait ResourcefulTest extends org.specs2.specification.Resource[Unit] { // populated by resources protected var registry: CollectorRegistry = _ @@ -14,10 +14,11 @@ trait ResourcefulTest extends BeforeAfterAll { protected def testResource: Resource[IO, Unit] = { Resource.make(IO(new CollectorRegistry().tap(registry = _)))(cr => IO(cr.clear())) >> - Dispatcher[IO].map(dispatcher = _) + Dispatcher.parallel[IO].map(dispatcher = _) }.void - private var release: IO[Unit] = IO.unit - override def beforeAll(): Unit = release = testResource.allocated.unsafeRunSync()._2 - override def afterAll(): Unit = release.unsafeRunSync() + private var release: IO[Unit] = IO.unit + protected def acquire: scala.concurrent.Future[Unit] = + testResource.allocated.map((_, releaseIO) => release = releaseIO).unsafeToFuture() + protected def release(resource: Unit): Execution = release.unsafeRunSync() } diff --git a/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestKafkaEventBusConfig.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestKafkaEventBusConfig.scala new file mode 100644 index 00000000..c506b711 --- /dev/null +++ b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestKafkaEventBusConfig.scala @@ -0,0 +1,18 @@ +package io.branchtalk.shared.infrastructure + +import cats.data.NonEmptyList +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } +import io.scalaland.chimney.dsl.* + +final case class TestKafkaEventBusConfig( + servers: NonEmptyList[Server], + topicPrefix: KafkaEventBus.Topic, + cache: Server +) derives ConfigReader { + + def topic(generatedSuffix: String): KafkaEventBus.Topic = + KafkaEventBus.Topic.unsafeMake(topicPrefix.unwrap + generatedSuffix) + + def toKafkaEventBusConfig(generatedSuffix: String): KafkaEventBus.BusConfig = + this.into[KafkaEventBus.BusConfig].withFieldConst(_.topic, topic(generatedSuffix)).transform +} diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestKafkaResources.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestKafkaResources.scala similarity index 63% rename from modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestKafkaResources.scala rename to modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestKafkaResources.scala index 18175f1a..4a6931ed 100644 --- a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestKafkaResources.scala +++ b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestKafkaResources.scala @@ -3,17 +3,17 @@ package io.branchtalk.shared.infrastructure import cats.effect.{ Resource, Sync } import org.apache.kafka.clients.admin.{ AdminClient, AdminClientConfig } -import scala.jdk.CollectionConverters._ +import scala.jdk.CollectionConverters.* trait TestKafkaResources extends TestResourcesHelpers { def kafkaEventBusConfigResource[F[_]: Sync]( testKafkaEventBusConfig: TestKafkaEventBusConfig - ): Resource[F, KafkaEventBusConfig] = + ): Resource[F, KafkaEventBus.BusConfig] = Resource .eval(generateRandomSuffix[F]) .flatMap(randomSuffix => - Resource.pure[F, KafkaEventBusConfig](testKafkaEventBusConfig.toKafkaEventBusConfig(randomSuffix)) + Resource.pure[F, KafkaEventBus.BusConfig](testKafkaEventBusConfig.toKafkaEventBusConfig(randomSuffix)) ) .flatTap { cfg => Resource.make { @@ -26,10 +26,12 @@ trait TestKafkaResources extends TestResourcesHelpers { } } { client => Sync[F].delay { - try if (client.listTopics().names().get().asScala.contains(cfg.topic.nonEmptyString.value)) { - client.deleteTopics(List(cfg.topic.nonEmptyString.value).asJavaCollection) - () - } finally client.close() + try + if (client.listTopics().names().get().asScala.contains(cfg.topic.unwrap)) { + client.deleteTopics(List(cfg.topic.unwrap).asJavaCollection) + () + } + finally client.close() } } } diff --git a/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestPostgresConfig.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestPostgresConfig.scala new file mode 100644 index 00000000..8dd6917d --- /dev/null +++ b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestPostgresConfig.scala @@ -0,0 +1,29 @@ +package io.branchtalk.shared.infrastructure + +import io.scalaland.chimney.dsl.* +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } + +final case class TestPostgresConfig( + url: PostgresDatabase.URL, + rootPassword: PostgresDatabase.Password, + usernamePrefix: PostgresDatabase.Username, + password: PostgresDatabase.Password, + schemaPrefix: PostgresDatabase.Schema, + domain: PostgresDatabase.Domain, + connectionPool: PostgresDatabase.ConnectionPool +) derives ConfigReader { + + def username(generatedSuffix: String): PostgresDatabase.Username = + PostgresDatabase.Username.unsafeMake(usernamePrefix.unwrap + generatedSuffix) + def schema(generatedSuffix: String): PostgresDatabase.Schema = + PostgresDatabase.Schema.unsafeMake(schemaPrefix.unwrap + generatedSuffix) + def migrationOnStart: PostgresDatabase.MigrationOnStart = PostgresDatabase.MigrationOnStart(true) + + def toPostgresConfig(generatedSuffix: String): PostgresDatabase.Config = + this + .into[PostgresDatabase.Config] + .withFieldConst(_.username, username(generatedSuffix)) + .withFieldConst(_.schema, schema(generatedSuffix)) + .withFieldConst(_.migrationOnStart, migrationOnStart) + .transform +} diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestPostgresResources.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestPostgresResources.scala similarity index 50% rename from modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestPostgresResources.scala rename to modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestPostgresResources.scala index e2334ac6..fad4c028 100644 --- a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestPostgresResources.scala +++ b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestPostgresResources.scala @@ -1,33 +1,33 @@ package io.branchtalk.shared.infrastructure import cats.effect.{ Async, Resource } -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.logging.Logger +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } trait TestPostgresResources extends TestResourcesHelpers { - implicit val logger: DoobieSupport.LogHandler = doobieLogger(getClass) - def postgresConfigResource[F[_]: Async]( testPostgresConfig: TestPostgresConfig - ): Resource[F, PostgresConfig] = + ): Resource[F, PostgresDatabase.Config] = Resource.eval(generateRandomSuffix[F]).flatMap { randomSuffix => val schemaCreator = Transactor.fromDriverManager[F]( driver = classOf[org.postgresql.Driver].getName, // driver classname - url = testPostgresConfig.url.nonEmptyString.value, // connect URL (driver-specific) + url = testPostgresConfig.url.unwrap, // connect URL (driver-specific) user = "postgres", // user - pass = testPostgresConfig.rootPassword.nonEmptyString.value // password + password = testPostgresConfig.rootPassword.unwrap, // password + logHandler = Some(doobieLogger[F](Logger.getLoggerFromClass[F](getClass))) ) val cfg = testPostgresConfig.toPostgresConfig(randomSuffix.toLowerCase) - val username = Fragment.const(cfg.username.nonEmptyString.value) - val password = Fragment.const(s"""'${cfg.password.nonEmptyString.value}'""") - val schema = Fragment.const(cfg.schema.nonEmptyString.value) + val username = Fragment.const(cfg.username.unwrap) + val password = Fragment.const(s"""'${cfg.password.unwrap}'""") + val schema = Fragment.const(cfg.schema.unwrap) - val createUser = (fr"CREATE USER" ++ username ++ fr"WITH PASSWORD" ++ password).update.run - val createSchema = (fr"CREATE SCHEMA" ++ schema ++ fr"AUTHORIZATION" ++ username).update.run + val createUser = sql"CREATE USER $username WITH PASSWORD $password".update.run + val createSchema = sql"CREATE SCHEMA $schema AUTHORIZATION $username".update.run - val dropSchema = (fr"DROP SCHEMA IF EXISTS" ++ schema ++ fr"CASCADE").update.run - val dropUser = (fr"DROP ROLE IF EXISTS" ++ username).update.run + val dropSchema = sql"DROP SCHEMA IF EXISTS $schema CASCADE".update.run + val dropUser = sql"DROP ROLE IF EXISTS $username".update.run Resource.make { (createUser >> createSchema >> cfg.pure[ConnectionIO]).transact(schemaCreator) diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestResources.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestResources.scala similarity index 54% rename from modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestResources.scala rename to modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestResources.scala index d437e3c4..dfdb188f 100644 --- a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestResources.scala +++ b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestResources.scala @@ -1,4 +1,4 @@ package io.branchtalk.shared.infrastructure -trait TestResources extends TestPostgresResources with TestKafkaResources +trait TestResources extends TestPostgresResources, TestKafkaResources object TestResources extends TestResources diff --git a/modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestResourcesHelpers.scala b/modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestResourcesHelpers.scala similarity index 100% rename from modules/common-infrastructure/src/it/scala/io/branchtalk/shared/infrastructure/TestResourcesHelpers.scala rename to modules/common-infrastructure/src/test/scala/io/branchtalk/shared/infrastructure/TestResourcesHelpers.scala diff --git a/modules/common-macros/src/main/resources/derive.semi.conf b/modules/common-macros/src/main/resources/derive.semi.conf deleted file mode 100644 index 592df906..00000000 --- a/modules/common-macros/src/main/resources/derive.semi.conf +++ /dev/null @@ -1,10 +0,0 @@ -# wire our own type classes for Catnip @Semi -io.branchtalk.shared.model.ApplicativeFunctor=io.branchtalk.shared.model.ApplicativeFunctor.semi -io.branchtalk.shared.model.FastEq=io.branchtalk.shared.model.FastEq.semi -io.branchtalk.shared.model.ShowPretty=io.branchtalk.shared.model.ShowPretty.semi -# wire Avro4s type classes for Catnip @Semi -com.sksamuel.avro4s.Decoder=com.sksamuel.avro4s.Decoder.gen -com.sksamuel.avro4s.Encoder=com.sksamuel.avro4s.Encoder.gen -com.sksamuel.avro4s.SchemaFor=com.sksamuel.avro4s.SchemaFor.gen -# wire Pureconfig type classes for Catnip @Semi -pureconfig.ConfigReader=pureconfig.generic.semiauto.deriveReader diff --git a/modules/common/src/main/scala/io/branchtalk/ADT.scala b/modules/common/src/main/scala/io/branchtalk/ADT.scala deleted file mode 100644 index 28eacc78..00000000 --- a/modules/common/src/main/scala/io/branchtalk/ADT.scala +++ /dev/null @@ -1,4 +0,0 @@ -package io.branchtalk - -// Saves us writing everywhere `sealed trait X extends Product with Serializable`. -trait ADT extends Product with Serializable diff --git a/modules/common/src/main/scala/io/branchtalk/logging/CorrelationID.scala b/modules/common/src/main/scala/io/branchtalk/logging/CorrelationID.scala new file mode 100644 index 00000000..1c9aa286 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/logging/CorrelationID.scala @@ -0,0 +1,25 @@ +package io.branchtalk.logging + +import cats.{ Order, Show } +import cats.effect.Sync +import io.branchtalk.shared.model.UUID +import neotype.* + +type CorrelationID = CorrelationID.Type +object CorrelationID extends Newtype[String] { + + private val key = "correlation-id" + + def generate[F[_]: Sync](using UUID.Generator): F[CorrelationID] = + UUID.create[F].map(_.show).map(unsafeMake) + + def getCurrent[F[_]: MDC]: F[Option[CorrelationID]] = unsafeMakeF[[A] =>> F[Option[A]]](MDC[F].get(key)) + + def getCurrentOrGenerate[F[_]: Sync: MDC](using uuidGenerator: UUID.Generator): F[CorrelationID] = + getCurrent[F].flatMap(_.fold(generate[F])(_.pure[F])) + + extension (cid: CorrelationID) def updateMDC[F[_]: MDC]: F[Unit] = MDC[F].set(key, cid.unwrap) + + given Show[CorrelationID] = unsafeMakeF[Show](Show[String]) + given Order[CorrelationID] = unsafeMakeF[Order](Order[String]) +} diff --git a/modules/common/src/main/scala/io/branchtalk/logging/Logger.scala b/modules/common/src/main/scala/io/branchtalk/logging/Logger.scala new file mode 100644 index 00000000..6bbd5cb2 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/logging/Logger.scala @@ -0,0 +1,12 @@ +package io.branchtalk.logging + +import org.typelevel.log4cats.SelfAwareStructuredLogger +import org.typelevel.log4cats.slf4j.Slf4jLogger + +type Logger[F[_]] = SelfAwareStructuredLogger[F] +val Logger = Slf4jLogger + +extension (l: Logger.type) { + + def apply[F[_]](using logger: Logger[F]): Logger[F] = logger +} diff --git a/modules/common/src/main/scala/io/branchtalk/logging/MDC.scala b/modules/common/src/main/scala/io/branchtalk/logging/MDC.scala index 85bb9c5f..fff2b806 100644 --- a/modules/common/src/main/scala/io/branchtalk/logging/MDC.scala +++ b/modules/common/src/main/scala/io/branchtalk/logging/MDC.scala @@ -3,15 +3,13 @@ package io.branchtalk.logging // Abstracts away how we perform MCD from what effect F we use. trait MDC[F[_]] { - def ctx: F[MDC.Ctx] - - def get(key: String): F[Option[String]] - + def ctx: F[MDC.Ctx] + def get(key: String): F[Option[String]] def set(key: String, value: String): F[Unit] } object MDC { type Ctx = Map[String, String] - @inline def apply[F[_]](implicit mdc: MDC[F]): MDC[F] = mdc + inline def apply[F[_]](using mdc: MDC[F]): MDC[F] = mdc } diff --git a/modules/common/src/main/scala/io/branchtalk/logging/RequestID.scala b/modules/common/src/main/scala/io/branchtalk/logging/RequestID.scala new file mode 100644 index 00000000..9cac163e --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/logging/RequestID.scala @@ -0,0 +1,25 @@ +package io.branchtalk.logging + +import cats.{ Order, Show } +import cats.effect.Sync +import io.branchtalk.shared.model.UUID +import neotype.* + +type RequestID = RequestID.Type +object RequestID extends Newtype[String] { + + private val key = "request-id" + + def generate[F[_]: Sync](using UUID.Generator): F[RequestID] = + UUID.create[F].map(_.show).map(RequestID(_)) + + def getCurrent[F[_]: MDC]: F[Option[RequestID]] = unsafeMakeF[[A] =>> F[Option[A]]](MDC[F].get(key)) + + def getCurrentOrGenerate[F[_]: Sync: MDC](using UUID.Generator): F[RequestID] = + getCurrent[F].flatMap(_.fold(generate[F])(_.pure[F])) + + extension (rid: RequestID) def updateMDC[F[_]: MDC]: F[Unit] = MDC[F].set(key, rid.unwrap) + + given Show[RequestID] = unsafeMakeF[Show](Show[String]) + given Order[RequestID] = unsafeMakeF[Order](Order[String]) +} diff --git a/modules/common/src/main/scala/io/branchtalk/logging/package.scala b/modules/common/src/main/scala/io/branchtalk/logging/package.scala deleted file mode 100644 index 1ed98eb4..00000000 --- a/modules/common/src/main/scala/io/branchtalk/logging/package.scala +++ /dev/null @@ -1,52 +0,0 @@ -package io.branchtalk - -import cats.{ Order, Show } -import cats.effect.Sync -import io.branchtalk.shared.model.UUIDGenerator -import io.estatico.newtype.macros.newtype -import io.estatico.newtype.ops._ - -package object logging { - - // utilities for generating CorrelationID and RequestID - - @newtype final case class CorrelationID(show: String) { - - def updateMDC[F[_]: MDC]: F[Unit] = MDC[F].set(CorrelationID.key, show) - } - object CorrelationID { - - private val key = "correlation-id" - - def generate[F[_]: Sync](implicit uuidGenerator: UUIDGenerator): F[CorrelationID] = - uuidGenerator.create[F].map(_.toString).map(CorrelationID(_)) - - def getCurrent[F[_]: MDC]: F[Option[CorrelationID]] = MDC[F].get(key).coerce - - def getCurrentOrGenerate[F[_]: Sync: MDC](implicit uuidGenerator: UUIDGenerator): F[CorrelationID] = - getCurrent[F].flatMap(_.fold(generate[F])(_.pure[F])) - - implicit val show: Show[CorrelationID] = Show[String].coerce - implicit val order: Order[CorrelationID] = Order[String].coerce - } - - @newtype final case class RequestID(show: String) { - - def updateMDC[F[_]: MDC]: F[Unit] = MDC[F].set(RequestID.key, show) - } - object RequestID { - - private val key = "request-id" - - def generate[F[_]: Sync](implicit uuidGenerator: UUIDGenerator): F[RequestID] = - uuidGenerator.create[F].map(_.toString).map(RequestID(_)) - - def getCurrent[F[_]: MDC]: F[Option[RequestID]] = MDC[F].get(key).coerce - - def getCurrentOrGenerate[F[_]: Sync: MDC](implicit uuidGenerator: UUIDGenerator): F[RequestID] = - getCurrent[F].flatMap(_.fold(generate[F])(_.pure[F])) - - implicit val show: Show[RequestID] = Show[String].coerce - implicit val order: Order[RequestID] = Order[String].coerce - } -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/ApplicativeTraverse.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/ApplicativeTraverse.scala index 6ee1d732..7ed74dda 100644 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/ApplicativeTraverse.scala +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/ApplicativeTraverse.scala @@ -3,21 +3,17 @@ package io.branchtalk.shared.model import cats.{ Applicative, Eval, Traverse } // because, apparently, you cannot have both at once since both extends Functor -trait ApplicativeTraverse[F[_]] extends Applicative[F] with Traverse[F] +trait ApplicativeTraverse[F[_]] extends Applicative[F], Traverse[F] object ApplicativeTraverse { - def semi[F[_]: Applicative: Traverse]: ApplicativeTraverse[F] = { + def derived[F[_]: Applicative: Traverse]: ApplicativeTraverse[F] = { val a = Applicative[F] val t = Traverse[F] new ApplicativeTraverse[F] { - override def pure[A](x: A): F[A] = a.pure(x) - + override def pure[A](x: A): F[A] = a.pure(x) override def ap[A, B](ff: F[A => B])(fa: F[A]): F[B] = a.ap(ff)(fa) - override def traverse[G[_], A, B](fa: F[A])(f: A => G[B])(implicit G: Applicative[G]): G[F[B]] = t.traverse(fa)(f) - - override def foldLeft[A, B](fa: F[A], b: B)(f: (B, A) => B): B = t.foldLeft(fa, b)(f) - + override def foldLeft[A, B](fa: F[A], b: B)(f: (B, A) => B): B = t.foldLeft(fa, b)(f) override def foldRight[A, B](fa: F[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = t.foldRight(fa, lb)(f) } } diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/AvroSerialization.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/AvroSerialization.scala deleted file mode 100644 index 28d564e1..00000000 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/AvroSerialization.scala +++ /dev/null @@ -1,86 +0,0 @@ -package io.branchtalk.shared.model - -import java.io.{ ByteArrayInputStream, ByteArrayOutputStream } -import cats.{ Eq, Show } -import cats.effect.{ Resource, Sync, SyncIO } -import com.sksamuel.avro4s._ -import io.branchtalk.ADT -import io.branchtalk.shared.model.AvroSerialization.DeserializationResult -import io.scalaland.catnip.Semi -import io.scalaland.chimney.TransformerFSupport - -import scala.collection.compat.Factory -import scala.util.{ Failure, Success } - -// Missing helpers for serializations and deserialization with some API saner than byte array streams. -@Semi(FastEq, ShowPretty) sealed trait DeserializationError extends ADT -object DeserializationError { - final case class DecodingError(badValue: String, error: Throwable) extends DeserializationError - - implicit val eitherTransformerSupport: TransformerFSupport[DeserializationResult] = - new TransformerFSupport[DeserializationResult] { - - override def pure[A](value: A): DeserializationResult[A] = value.asRight - - override def product[A, B]( - fa: DeserializationResult[A], - fb: => DeserializationResult[B] - ): DeserializationResult[(A, B)] = for { a <- fa; b <- fb } yield (a, b) - - override def map[A, B](fa: DeserializationResult[A], f: A => B): DeserializationResult[B] = fa.map(f) - - override def traverse[M, A, B](it: Iterator[A], f: A => DeserializationResult[B])(implicit - fac: Factory[B, M] - ): DeserializationResult[M] = it.toList.traverse(f).map(fac.fromSpecific) - } - - implicit private def showThrowable: Show[Throwable] = _.getMessage - implicit private def eqThrowable: Eq[Throwable] = _.getMessage === _.getMessage -} - -object AvroSerialization { - - private val logger = com.typesafe.scalalogging.Logger(getClass) - - type DeserializationResult[+A] = Either[DeserializationError, A] - - def serialize[F[_]: Sync, A: Encoder](value: A): F[Array[Byte]] = - Resource.fromAutoCloseable(Sync[F].delay(new ByteArrayOutputStream())).use { baos => - Sync[F].delay { - val aos = AvroOutputStream.json[A].to(baos).build() - aos.write(value) - aos.close() - aos.flush() - baos.toByteArray - } - } - - @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) - def deserialize[F[_]: Sync, A: Decoder: SchemaFor](arr: Array[Byte]): F[DeserializationResult[A]] = - Resource.fromAutoCloseable(Sync[F].delay(new ByteArrayInputStream(arr))).use { bais => - Sync[F] - .delay { - AvroInputStream - .json[A] - .from(bais) - .build(SchemaFor[A].schema) - .asInstanceOf[AvroJsonInputStream[A]] - .singleEntity match { - case Success(value) => - value.asRight[DeserializationError] - case Failure(error) => - DeserializationError.DecodingError("Failed to extract Avro message", error).asLeft[A] - } - } - .handleError { error: Throwable => - logger.error(s"Avro deserialization error for '${new String(arr, branchtalkCharset)}'", error) - DeserializationError.DecodingError(new String(arr, branchtalkCharset), error).asLeft[A] - } - } - - def serializeUnsafe[A: Encoder](value: A): Array[Byte] = - serialize[SyncIO, A](value).unsafeRunSync() - - def deserializeUnsafe[A: Decoder: SchemaFor](arr: Array[Byte]): DeserializationResult[A] = - deserialize[SyncIO, A](arr).unsafeRunSync() -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/AvroSupport.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/AvroSupport.scala deleted file mode 100644 index bdc105a5..00000000 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/AvroSupport.scala +++ /dev/null @@ -1,106 +0,0 @@ -package io.branchtalk.shared.model - -import java.net.URI -import java.util - -import cats.Id -import cats.data.{ NonEmptyList, NonEmptyVector } -import com.sksamuel.avro4s._ -import eu.timepit.refined.api.{ RefType, Validate } -import io.estatico.newtype.Coercible -import org.apache.avro.Schema - -import scala.jdk.CollectionConverters._ - -object AvroSupport { - - // newtype - order of implicits is necessary (swapping them would break derivations, so we can't use typeclass syntax) - - implicit def coercibleDecoder[R, N](implicit ev: Coercible[R, N], R: Decoder[R]): Decoder[N] = - Coercible.unsafeWrapMM[Decoder, Id, R, N].apply(R) - implicit def coercibleEncoder[R, N](implicit ev: Coercible[R, N], R: Encoder[R]): Encoder[N] = - Coercible.unsafeWrapMM[Encoder, Id, R, N].apply(R) - implicit def coercibleSchemaFor[R, N](implicit ev: Coercible[R, N], R: SchemaFor[R]): SchemaFor[N] = - Coercible.unsafeWrapMM[SchemaFor, Id, R, N].apply(R) - - // refined (redirections) - - implicit def refinedSchemaFor[T: SchemaFor, P, F[_, _]: RefType]: SchemaFor[F[T, P]] = - refined.refinedSchemaFor[T, P, F] - - implicit def refinedEncoder[T: Encoder, P, F[_, _]: RefType]: Encoder[F[T, P]] = - refined.refinedEncoder[T, P, F] - - implicit def refinedDecoder[T: Decoder, P: Validate[T, *], F[_, _]: RefType]: Decoder[F[T, P]] = - refined.refinedDecoder[T, P, F] - - // cats (copy-paste as cats module isn't released) - - implicit def nonEmptyListSchemaFor[T: SchemaFor]: SchemaFor[NonEmptyList[T]] = - SchemaFor(Schema.createArray(SchemaFor[T].schema)) - - implicit def nonEmptyVectorSchemaFor[T: SchemaFor]: SchemaFor[NonEmptyVector[T]] = - SchemaFor(Schema.createArray(SchemaFor[T].schema)) - - implicit def nonEmptyListEncoder[T: Encoder]: Encoder[NonEmptyList[T]] = - new Encoder[NonEmptyList[T]] { - - val schemaFor: SchemaFor[NonEmptyList[T]] = nonEmptyListSchemaFor(Encoder[T].schemaFor) - - @SuppressWarnings(Array("org.wartremover.warts.Equals", "org.wartremover.warts.Null")) - override def encode(ts: NonEmptyList[T]): java.util.List[AnyRef] = { - require(schema != null) - ts.map(Encoder[T].encode).toList.asJava - } - } - - implicit def nonEmptyVectorEncoder[T: Encoder]: Encoder[NonEmptyVector[T]] = - new Encoder[NonEmptyVector[T]] { - - val schemaFor: SchemaFor[NonEmptyVector[T]] = nonEmptyVectorSchemaFor(Encoder[T].schemaFor) - - @SuppressWarnings(Array("org.wartremover.warts.Equals", "org.wartremover.warts.Null")) - override def encode(ts: NonEmptyVector[T]): java.util.List[AnyRef] = { - require(schema != null) - ts.map(Encoder[T].encode).toVector.asJava - } - } - - implicit def nonEmptyListDecoder[T: Decoder]: Decoder[NonEmptyList[T]] = - new Decoder[NonEmptyList[T]] { - - val schemaFor: SchemaFor[NonEmptyList[T]] = nonEmptyListSchemaFor(Decoder[T].schemaFor) - - @SuppressWarnings(Array("org.wartremover.warts.ToString")) - override def decode(value: Any): NonEmptyList[T] = value match { - case array: Array[_] => NonEmptyList.fromListUnsafe(array.toList.map(Decoder[T].decode)) - case list: util.Collection[_] => NonEmptyList.fromListUnsafe(list.asScala.map(Decoder[T].decode).toList) - case other => sys.error("Unsupported type " + other.toString) - } - } - - implicit def nonEmptyVectorDecoder[T: Decoder]: Decoder[NonEmptyVector[T]] = - new Decoder[NonEmptyVector[T]] { - - val schemaFor: SchemaFor[NonEmptyVector[T]] = nonEmptyVectorSchemaFor(Decoder[T].schemaFor) - - @SuppressWarnings(Array("org.wartremover.warts.ToString")) - override def decode(value: Any): NonEmptyVector[T] = value match { - case array: Array[_] => NonEmptyVector.fromVectorUnsafe(array.toVector.map(Decoder[T].decode)) - case list: util.Collection[_] => NonEmptyVector.fromVectorUnsafe(list.asScala.map(Decoder[T].decode).toVector) - case other => sys.error("Unsupported type " + other.toString) - } - } - - // custom types - - implicit val uriSchema: SchemaFor[URI] = SchemaFor[String].forType[URI] - implicit val uriEncoder: Encoder[URI] = new Encoder[URI] { - override def encode(value: URI): AnyRef = Encoder[String].encode(value.toString) - override def schemaFor: SchemaFor[URI] = uriSchema - } - implicit val uriDecoder: Decoder[URI] = new Decoder[URI] { - override def decode(value: Any): URI = URI.create(Decoder[String].decode(value)) - override def schemaFor: SchemaFor[URI] = uriSchema - } -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/CodePosition.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/CodePosition.scala index b81a9cf7..3cbebb7c 100644 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/CodePosition.scala +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/CodePosition.scala @@ -1,16 +1,15 @@ package io.branchtalk.shared.model -import io.scalaland.catnip.Semi - // Useful for generating the position in source code for debugging. -@Semi(FastEq, ShowPretty) final case class CodePosition( +final case class CodePosition( file: String, line: Int, context: String -) +) derives FastEq, + ShowPretty object CodePosition { - implicit def providePosition(implicit + given providePosition(using file: sourcecode.File, line: sourcecode.Line, enclosing: sourcecode.Enclosing.Machine diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/CommonError.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/CommonError.scala index fbf2afbf..d3bc68de 100644 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/CommonError.scala +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/CommonError.scala @@ -1,27 +1,39 @@ package io.branchtalk.shared.model import cats.data.NonEmptyList -import io.branchtalk.ADT -import io.scalaland.catnip.Semi +import neotype.* // Defines errors as ADT but also Throwable, so that we can extract during handling it and pattern-match all cases. -@Semi(FastEq, ShowPretty) sealed trait CommonError extends Exception with ADT { +enum CommonError extends Exception derives FastEq, ShowPretty { + case InvalidCredentials(codePosition: CodePosition) + case InsufficientPermissions(msg: String, codePosition: CodePosition) + case NotFound(entity: String, id: UUID, codePosition: CodePosition) + case ParentNotExist(entity: String, id: UUID, codePosition: CodePosition) + case ValidationFailed(errors: NonEmptyList[String], codePosition: CodePosition) + val codePosition: CodePosition + + override def getMessage: String = this match { + case InvalidCredentials(codePosition) => show"Invalid credentials at: $codePosition" + case InsufficientPermissions(msg, codePosition) => show"Insufficient permissions at: $codePosition\n$msg" + case NotFound(entity, id, codePosition) => show"Entity $entity id=$id not found at: $codePosition" + case ParentNotExist(entity, id, codePosition) => show"Entity's parent $entity id=$id not exist at: $codePosition" + case ValidationFailed(errors, codePosition) => + show"Validation failed at: $codePosition:\n${errors.mkString_("- ", "\n", "")}" + } + override def toString: String = this.show } object CommonError { - final case class InvalidCredentials(codePosition: CodePosition) extends CommonError - final case class InsufficientPermissions(msg: String, codePosition: CodePosition) extends CommonError { - override def getMessage: String = msg - } - final case class NotFound(entity: String, id: ID[_], codePosition: CodePosition) extends CommonError { - override def getMessage: String = show"Entity $entity id=$id not found at: $codePosition" - } - final case class ParentNotExist(entity: String, id: ID[_], codePosition: CodePosition) extends CommonError { - override def getMessage: String = show"Entity's parent $entity id=$id not exist at: $codePosition" - } - final case class ValidationFailed(errors: NonEmptyList[String], codePosition: CodePosition) extends CommonError { - override def getMessage: String = - show"Validation failed at: $codePosition:\n${errors.mkString_("- ", "\n", "")}" - } + + def invalidCredentials(using codePosition: CodePosition): CommonError = + InvalidCredentials(codePosition) + def insufficientPermissions(msg: String)(using codePosition: CodePosition): CommonError = + InsufficientPermissions(msg, codePosition) + def notFound[Entity](entity: String, id: ID[Entity])(using codePosition: CodePosition): CommonError = + NotFound(entity, id.unwrap, codePosition) + def parentNotExist[Entity](entity: String, id: ID[Entity])(using codePosition: CodePosition): CommonError = + ParentNotExist(entity, id.unwrap, codePosition) + def validationFailed(error: String, errors: String*)(using codePosition: CodePosition): CommonError = + ValidationFailed(NonEmptyList(error, errors.toList), codePosition) } diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/FastEq.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/FastEq.scala index 98e585d1..83cb0214 100644 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/FastEq.scala +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/FastEq.scala @@ -1,26 +1,20 @@ package io.branchtalk.shared.model import cats.Eq -import magnolia1._ - -import scala.language.experimental.macros +import magnolia1.* // Custom implementation of Eq which relies on Magnolia for derivation as opposed to Kittens' version. trait FastEq[T] extends Eq[T] -object FastEq extends FastEqLowLevel { - type Typeclass[T] = FastEq[T] - - def join[T](caseClass: ReadOnlyCaseClass[Typeclass, T]): Typeclass[T] = - (x, y) => caseClass.parameters.forall(p => p.typeclass.eqv(p.dereference(x), p.dereference(y))) +object FastEq extends Derivation[FastEq], FastEqLowLevel { - def split[T](sealedTrait: SealedTrait[Typeclass, T]): Typeclass[T] = - (x, y) => sealedTrait.split(x)(sub => sub.cast.isDefinedAt(y) && sub.typeclass.eqv(sub.cast(x), sub.cast(y))) + def join[T](caseClass: CaseClass[FastEq, T]): FastEq[T] = + (x, y) => caseClass.parameters.forall(p => p.typeclass.eqv(p.deref(x), p.deref(y))) - def semi[T]: Typeclass[T] = macro Magnolia.gen[T] + def split[T](sealedTrait: SealedTrait[FastEq, T]): FastEq[T] = + (x, y) => sealedTrait.choose(x)(sub => sub.cast.isDefinedAt(y) && sub.typeclass.eqv(sub.cast(x), sub.cast(y))) } trait FastEqLowLevel { self: FastEq.type => - implicit def liftEq[T](implicit normalEq: Eq[T]): FastEq[T] = - (x: T, y: T) => normalEq.eqv(x, y) + given liftEq[T](using normalEq: Eq[T]): FastEq[T] = (x: T, y: T) => normalEq.eqv(x, y) } diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/ID.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/ID.scala new file mode 100644 index 00000000..b11af722 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/ID.scala @@ -0,0 +1,17 @@ +package io.branchtalk.shared.model + +import cats.{ Order, Show } +import cats.effect.Sync +import neotype.* + +type ID[Entity] = ID.Type[Entity] +object ID extends NewtypeT[UUID] { + + def unapply[Entity](entity: ID[Entity]): Some[UUID] = Some(entity.unwrap) + def create[F[_]: Sync, Entity](using UUID.Generator): F[ID[Entity]] = UUID.create[F].map(unsafeMake[Entity]) + def parse[F[_]: Sync, Entity](string: String)(using UUID.Generator): F[ID[Entity]] = + UUID.parse[F](string).map(unsafeMake[Entity]) + + given [Entity]: Show[ID[Entity]] = unsafeMakeF[Show, Entity](Show[UUID]) + given [Entity]: Order[ID[Entity]] = unsafeMakeF[Order, Entity](Order[UUID]) +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/OptionUpdatable.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/OptionUpdatable.scala deleted file mode 100644 index b1c4d29f..00000000 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/OptionUpdatable.scala +++ /dev/null @@ -1,39 +0,0 @@ -package io.branchtalk.shared.model - -import cats.{ Applicative, Traverse } -import io.scalaland.catnip.Semi - -// Express the intent that something should be updated, erased or kept better than Option[Either[Unit, *]]. -@Semi(ShowPretty, FastEq) sealed trait OptionUpdatable[+A] { - - def fold[B](set: A => B, keep: => B, erase: => B): B = this match { - case OptionUpdatable.Set(value) => set(value) - case OptionUpdatable.Erase => keep - case OptionUpdatable.Keep => erase - } - - def toOptionEither: Option[Either[Unit, A]] = this match { - case OptionUpdatable.Set(value) => Some(Right(value)) - case OptionUpdatable.Erase => Some(Left(())) - case OptionUpdatable.Keep => None - } -} -object OptionUpdatable { - final case class Set[+A](value: A) extends OptionUpdatable[A] - case object Erase extends OptionUpdatable[Nothing] - case object Keep extends OptionUpdatable[Nothing] - - def setFromOption[A](option: Option[A]): OptionUpdatable[A] = option.fold[OptionUpdatable[A]](Erase)(Set(_)) - - private val applicative: Applicative[OptionUpdatable] = new Applicative[OptionUpdatable] { - override def pure[A](a: A): OptionUpdatable[A] = Set(a) - override def ap[A, B](ff: OptionUpdatable[A => B])(fa: OptionUpdatable[A]): OptionUpdatable[B] = (ff, fa) match { - case (Set(f), Set(a)) => Set(f(a)) - case (Erase, _) => Erase - case (_, Erase) => Erase - case _ => Keep - } - } - private val traverse: Traverse[OptionUpdatable] = cats.derived.semiauto.traverse[OptionUpdatable] - implicit val appTraverse: ApplicativeTraverse[OptionUpdatable] = ApplicativeTraverse.semi(applicative, traverse) -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/Paginated.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/Paginated.scala index 3f3cd72a..df877cb8 100644 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/Paginated.scala +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/Paginated.scala @@ -1,13 +1,34 @@ package io.branchtalk.shared.model -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.NonNegative +import cats.{ Order, Show } +import neotype.* -final case class Paginated[+Entity](entities: List[Entity], nextOffset: Option[Long Refined NonNegative]) { +final case class Paginated[+Entity]( + entities: List[Entity], + nextOffset: Option[Paginated.Offset] +) { def map[B](f: Entity => B): Paginated[B] = Paginated(entities.map(f), nextOffset) } object Paginated { def empty[Entity]: Paginated[Entity] = Paginated(entities = List.empty, nextOffset = None) + + type Offset = Offset.Type + object Offset extends Newtype[Long] { + + override inline def validate(input: Long): Boolean = input >= 0L + + given Show[Offset] = unsafeMakeF[Show](Show[Long]) + given Order[Offset] = unsafeMakeF[Order](Order[Long]) + } + + type Limit = Limit.Type + object Limit extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input > 0 + + given Show[Limit] = unsafeMakeF[Show](Show[Int]) + given Order[Limit] = unsafeMakeF[Order](Order[Int]) + } } diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/ParseNewtype.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/ParseNewtype.scala new file mode 100644 index 00000000..bd331266 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/ParseNewtype.scala @@ -0,0 +1,22 @@ +package io.branchtalk.shared.model + +import cats.data.NonEmptyList +import cats.effect.Sync +import neotype.* + +object ParseNewtype { + + def apply[F[_]]: ApplyF[F] = new ApplyF[F] + + class ApplyF[F[_]] { + def parse[P]: ApplyFP[F, P] = new ApplyFP[F, P] + } + + class ApplyFP[F[_], P] { + def apply[T](t: T)(using F: Sync[F], newtype: Newtype.WithType[T, P], codePosition: CodePosition): F[P] = F.defer { + F.fromEither { + newtype.make(t).leftMap(msg => CommonError.ValidationFailed(NonEmptyList.one(msg), codePosition)) + } + } + } +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/ParseRefined.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/ParseRefined.scala deleted file mode 100644 index a56fe2c3..00000000 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/ParseRefined.scala +++ /dev/null @@ -1,23 +0,0 @@ -package io.branchtalk.shared.model - -import cats.effect.Sync -import eu.timepit.refined._ -import eu.timepit.refined.api.{ Refined, Validate } - -object ParseRefined { - - def apply[F[_]]: ApplyF[F] = new ApplyF[F] - - class ApplyF[F[_]] { - def parse[P]: ApplyFP[F, P] = new ApplyFP[F, P] - } - - class ApplyFP[F[_], P] { - def apply[T](t: T)(implicit F: Sync[F], validate: Validate[T, P]): F[T Refined P] = - F.defer { - F.fromEither { - refineV[P](t).leftMap(msg => new Throwable(msg)) - } - } - } -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/SensitiveData.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/SensitiveData.scala index 0a3bb340..d459a924 100644 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/SensitiveData.scala +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/SensitiveData.scala @@ -2,100 +2,92 @@ package io.branchtalk.shared.model import cats.{ Eq, Show } import com.sksamuel.avro4s.{ Decoder, Encoder, SchemaFor } -import enumeratum._ +import enumeratum.* import io.branchtalk.shared.model.AvroSerialization.DeserializationResult -import io.estatico.newtype.macros.newtype -import io.estatico.newtype.ops._ -import io.scalaland.catnip.Semi import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec +import neotype.* import scala.collection.compat.immutable.ArraySeq import scala.util.{ Random, Try } // Express intent that some data should not be stored as unencrypted format. -@Semi(Decoder, Encoder, SchemaFor) final case class SensitiveData[A](value: A) { +final case class SensitiveData[A](value: A) derives FastEq, Decoder, Encoder, SchemaFor { def encrypt( - algorithm: SensitiveData.Algorithm, - key: SensitiveData.Key - )(implicit encoder: Encoder[A]): SensitiveData.Encrypted[A] = algorithm.encrypt[A](this, key) + algorithm: SensitiveData.Algorithm, + key: SensitiveData.Key + )(using Encoder[A], SchemaFor[A]): SensitiveData.Encrypted[A] = algorithm.encrypt[A](this, key) override def toString: String = "SENSITIVE DATA" } object SensitiveData { - @newtype final case class Encrypted[A](bytes: ArraySeq[Byte]) { + type Encrypted[A] = Encrypted.Type[A] + object Encrypted extends NewtypeT[ArraySeq[Byte]] { - def decrypt( - algorithm: SensitiveData.Algorithm, - key: SensitiveData.Key - )(implicit decoder: Decoder[A], schemaFor: SchemaFor[A]): DeserializationResult[SensitiveData[A]] = - algorithm.decrypt[A](this, key) - } - object Encrypted { - - implicit def show[A]: Show[Encrypted[A]] = Show.wrap(_ => "ENCRYPTED") - implicit def eq[A]: Eq[Encrypted[A]] = Eq.by(_.bytes) - - implicit def decoder[A]: Decoder[Encrypted[A]] = - Decoder[Array[Byte]].map(ArraySeq.from(_)).coerce[Decoder[Encrypted[A]]] - implicit def encoder[A]: Encoder[Encrypted[A]] = - Encoder[Array[Byte]].comap[ArraySeq[Byte]](_.toArray).coerce[Encoder[Encrypted[A]]] - implicit def schemaFor[A]: SchemaFor[Encrypted[A]] = - SchemaFor[Array[Byte]].forType[ArraySeq[Byte]].coerce[SchemaFor[Encrypted[A]]] - } + extension [A](enc: Encrypted[A]) { + def decrypt( + algorithm: SensitiveData.Algorithm, + key: SensitiveData.Key + )(using Decoder[A], SchemaFor[A]): DeserializationResult[SensitiveData[A]] = + algorithm.decrypt[A](enc, key) + } - @newtype final case class Key(bytes: ArraySeq[Byte]) - object Key { + given [A]: Decoder[Encrypted[A]] = unsafeMakeF[Decoder, A](Decoder[Array[Byte]].map(ArraySeq.from)) + given [A]: Encoder[Encrypted[A]] = + unsafeMakeF[Encoder, A](Encoder[Array[Byte]].contramap[ArraySeq[Byte]](_.toArray)) + given [A]: SchemaFor[Encrypted[A]] = SchemaFor[Array[Byte]].forType[Encrypted[A]] - implicit val show: Show[Key] = Show.wrap(_ => "KEY") - implicit val eq: Eq[Key] = Eq.by(_.bytes) + given [A]: Show[Encrypted[A]] = _ => "ENCRYPTED" + given [A]: Eq[Encrypted[A]] = Eq.by(_.unwrap) } - sealed trait Algorithm extends EnumEntry with EnumEntry.Hyphencase { - - def generateKey(): Key - - // Avro4s fails to correctly serialize-then-deserialize primitives so we have to use wrapper (GenericRecord schema) - - def encrypt[A: Encoder](value: SensitiveData[A], key: Key): Encrypted[A] + type Key = Key.Type + object Key extends Newtype[ArraySeq[Byte]] { - def decrypt[A: Decoder: SchemaFor](encrypted: Encrypted[A], key: Key): DeserializationResult[SensitiveData[A]] + given Show[Key] = _ => "KEY" + given Eq[Key] = Eq.by(_.unwrap) } - object Algorithm extends Enum[Algorithm] { - private def getCipher(name: String)(key: Key, mode: Int) = - Cipher.getInstance(name).tap(_.init(mode, new SecretKeySpec(key.bytes.toArray, name))) + enum Algorithm extends EnumEntry, EnumEntry.Hyphencase { + case Blowfish - case object Blowfish extends Algorithm { + import Algorithm.* - private val defaultKeySize = 32 - private val blowfishCipher = getCipher("Blowfish") _ + final def generateKey(): Key = this match { + case Blowfish => Key(ArraySeq.from(Random.nextBytes(defaultKeySize))) + } - override def generateKey(): Key = Key(ArraySeq.from(Random.nextBytes(defaultKeySize))) + // Avro4s fails to correctly serialize-then-deserialize primitives so we have to use wrapper (GenericRecord schema) - override def encrypt[A: Encoder](value: SensitiveData[A], key: Key): Encrypted[A] = { + final def encrypt[A: Encoder: SchemaFor](value: SensitiveData[A], key: Key): Encrypted[A] = this match { + case Blowfish => val cipher = blowfishCipher(key, Cipher.ENCRYPT_MODE) AvroSerialization.serializeUnsafe(value).pipe(cipher.doFinal).pipe(ArraySeq.from(_)).pipe(Encrypted(_)) - } + } - override def decrypt[A: Decoder: SchemaFor]( - encrypted: Encrypted[A], - key: Key - ): DeserializationResult[SensitiveData[A]] = { + final def decrypt[A: Decoder: SchemaFor]( + encrypted: Encrypted[A], + key: Key + ): DeserializationResult[SensitiveData[A]] = this match { + case Blowfish => val cipher = blowfishCipher(key, Cipher.DECRYPT_MODE) - Try(cipher.doFinal(encrypted.bytes.toArray)).toEither.left + Try(cipher.doFinal(encrypted.unwrap.toArray)).toEither.left .map(DeserializationError.DecodingError("SensitiveData decoding error", _)) .flatMap(AvroSerialization.deserializeUnsafe[SensitiveData[A]](_)) - } } + } + object Algorithm { - def default: Algorithm = Blowfish // TODO: make configurable + private def getCipher(name: String)(key: Key, mode: Int) = + Cipher.getInstance(name).tap(_.init(mode, new SecretKeySpec(key.unwrap.toArray, name))) - val values = findValues + private val defaultKeySize = 32 + private val blowfishCipher = getCipher("Blowfish") _ + + def default: Algorithm = Blowfish // TODO: make configurable } - implicit def show[A]: Show[A] = Show.wrap(_ => "SENSITIVE DATA") - implicit def eq[A: Eq]: Eq[SensitiveData[A]] = Eq.by(_.value) + given [A]: Show[A] = _ => "SENSITIVE DATA" } diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/ShowPretty.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/ShowPretty.scala index f7c81435..fc557aae 100644 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/ShowPretty.scala +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/ShowPretty.scala @@ -1,14 +1,13 @@ package io.branchtalk.shared.model import cats.Show -import magnolia1._ - -import scala.language.experimental.macros +import magnolia1.* // Custom implementation of ShowPretty which relies on Magnolia for derivation as opposed to Kittens' version. +@SuppressWarnings(Array("org.wartremover.warts.MutableDataStructures")) trait ShowPretty[T] extends Show[T] { - def show(t: T): String = showPretty(t).toString() + final def show(t: T): String = showPretty(t).result() def showPretty( t: T, @@ -18,34 +17,33 @@ trait ShowPretty[T] extends Show[T] { ): StringBuilder } -object ShowPretty extends ShowPrettyLowLevel { - type Typeclass[T] = ShowPretty[T] +@SuppressWarnings(Array("org.wartremover.warts.MutableDataStructures")) +object ShowPretty extends Derivation[ShowPretty], ShowPrettyLowLevel { - def join[T](caseClass: ReadOnlyCaseClass[Typeclass, T]): Typeclass[T] = - (t: T, sb: StringBuilder, indentWith: String, indentLevel: Int) => { + def join[T](caseClass: CaseClass[ShowPretty, T]): ShowPretty[T] = { + (t: T, sb: StringBuilder, indentWith: String, indentLevel: Int) => val nextIndent = indentLevel + 1 val lastIndex = caseClass.parameters.size - 1 - sb.append(caseClass.typeName.full).append("(\n") + void(sb.append(caseClass.typeInfo.full).append("(\n")) caseClass.parameters.foreach { p => - sb.append(indentWith * nextIndent).append(p.label).append(" = ") - p.typeclass.showPretty(p.dereference(t), sb, indentWith, nextIndent) + void(sb.append(indentWith * nextIndent).append(p.label).append(" = ")) + void(p.typeclass.showPretty(p.deref(t), sb, indentWith, nextIndent)) if (p.index =!= lastIndex) { sb.append(",") } sb.append("\n") } sb.append(indentWith * indentLevel).append(")") - } + } - def split[T](sealedTrait: SealedTrait[Typeclass, T]): Typeclass[T] = + def split[T](sealedTrait: SealedTrait[ShowPretty, T]): ShowPretty[T] = (t: T, sb: StringBuilder, indentWith: String, indentLevel: Int) => - sealedTrait.split(t)(sub => sub.typeclass.showPretty(sub.cast(t), sb, indentWith, indentLevel)) - - def semi[T]: Typeclass[T] = macro Magnolia.gen[T] + sealedTrait.choose(t)(sub => sub.typeclass.showPretty(sub.cast(t), sb, indentWith, indentLevel)) } +@SuppressWarnings(Array("org.wartremover.warts.MutableDataStructures")) trait ShowPrettyLowLevel { self: ShowPretty.type => - implicit def liftShow[T](implicit normalShow: Show[T]): ShowPretty[T] = + given liftShow[T](using normalShow: Show[T]): ShowPretty[T] = (t: T, sb: StringBuilder, _: String, _: Int) => sb.append(normalShow.show(t)) } diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/UUIDGenerator.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/UUIDGenerator.scala deleted file mode 100644 index bf742e75..00000000 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/UUIDGenerator.scala +++ /dev/null @@ -1,22 +0,0 @@ -package io.branchtalk.shared.model - -import cats.effect.Sync -import com.eatthepath.uuid.FastUUID -import com.fasterxml.uuid.Generators -import eu.timepit.refined.api.Refined -import eu.timepit.refined.string.Uuid - -trait UUIDGenerator { - - def apply(string: String Refined Uuid): UUID - def create[F[_]: Sync]: F[UUID] - def parse[F[_]: Sync](string: String): F[UUID] -} - -object UUIDGenerator { - object FastUUIDGenerator extends UUIDGenerator { - override def apply(string: Refined[String, Uuid]): UUID = FastUUID.parseUUID(string.value) - override def create[F[_]: Sync]: F[UUID] = Sync[F].delay(Generators.timeBasedGenerator().generate()) - override def parse[F[_]: Sync](string: String): F[UUID] = Sync[F].delay(FastUUID.parseUUID(string)) - } -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/Updatable.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/Updatable.scala deleted file mode 100644 index e84a8a18..00000000 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/Updatable.scala +++ /dev/null @@ -1,33 +0,0 @@ -package io.branchtalk.shared.model - -import cats.{ Applicative, Traverse } -import io.branchtalk.ADT -import io.scalaland.catnip.Semi - -// Express the intent that something should be updated or not better than Option. -@Semi(ShowPretty, FastEq) sealed trait Updatable[+A] extends ADT { - - def fold[B](set: A => B, keep: => B): B = this match { - case Updatable.Set(value) => set(value) - case Updatable.Keep => keep - } - - def toOption: Option[A] = this match { - case Updatable.Set(value) => Some(value) - case Updatable.Keep => None - } -} -object Updatable { - final case class Set[+A](value: A) extends Updatable[A] - case object Keep extends Updatable[Nothing] - - private val applicative: Applicative[Updatable] = new Applicative[Updatable] { - override def pure[A](a: A): Updatable[A] = Set(a) - override def ap[A, B](ff: Updatable[A => B])(fa: Updatable[A]): Updatable[B] = (ff, fa) match { - case (Set(f), Set(a)) => Set(f(a)) - case _ => Keep - } - } - private val traverse: Traverse[Updatable] = cats.derived.semiauto.traverse[Updatable] - implicit val appTraverse: ApplicativeTraverse[Updatable] = ApplicativeTraverse.semi(applicative, traverse) -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/avro.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/avro.scala new file mode 100644 index 00000000..296a5623 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/avro.scala @@ -0,0 +1,174 @@ +package io.branchtalk.shared.model + +import java.io.{ ByteArrayInputStream, ByteArrayOutputStream } +import java.net.URI +import java.util +import cats.{ Eq, Id, Show } +import cats.data.{ Chain, NonEmptyChain, NonEmptyList, NonEmptyVector } +import cats.effect.{ Resource, Sync, SyncIO } +import com.sksamuel.avro4s.* +import io.scalaland.chimney.partial +import org.apache.avro.Schema +import neotype.* + +import scala.collection.compat.Factory +import scala.jdk.CollectionConverters.* +import scala.util.{ Failure, Success } +import scala.util.control.NoStackTrace +import org.apache.avro.Schema + +import scala.language.implicitConversions + +// Missing helpers for serializations and deserialization with some API saner than byte array streams. +enum DeserializationError extends Throwable, NoStackTrace derives FastEq, ShowPretty { + case DecodingError(badValue: String, error: Throwable) + case DecryptionError(badValue: String, errors: Map[String, String]) +} +object DeserializationError { + + private given Show[Throwable] = _.getMessage + private given Eq[Throwable] = _.getMessage === _.getMessage + + given partial.AsResult[AvroSerialization.DeserializationResult] with { + def asResult[A](fa: AvroSerialization.DeserializationResult[A]): partial.Result[A] = + fa.fold[partial.Result[A]](partial.Result.fromErrorThrowable, partial.Result.fromValue) + } +} + +object AvroSerialization { + + private val logger = com.typesafe.scalalogging.Logger(getClass) + + type DeserializationResult[+A] = Either[DeserializationError, A] + + def serialize[F[_]: Sync, A: Encoder: SchemaFor](value: A): F[Array[Byte]] = + Resource.fromAutoCloseable(Sync[F].delay(new ByteArrayOutputStream())).use { baos => + Sync[F].delay { + val aos = AvroOutputStream.json[A].to(baos).build() + aos.write(value) + aos.close() + aos.flush() + baos.toByteArray + } + } + + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf", "org.wartremover.warts.Throw")) + def deserialize[F[_]: Sync, A: Decoder: SchemaFor](arr: Array[Byte]): F[DeserializationResult[A]] = + Resource.fromAutoCloseable(Sync[F].delay(new ByteArrayInputStream(arr))).use { bais => + Sync[F] + .delay { + AvroInputStream + .json[A] + .from(bais) + .build(SchemaFor[A].schema) + .asInstanceOf[AvroJsonInputStream[A]] + .singleEntity match { + case Success(value) => + value.asRight[DeserializationError] + case Failure(error) => + DeserializationError.DecodingError("Failed to extract Avro message", error).asLeft[A] + } + } + .handleError { (error: Throwable) => + logger.error(s"Avro deserialization error for '${new String(arr, branchtalkCharset)}'", error) + DeserializationError.DecodingError(new String(arr, branchtalkCharset), error).asLeft[A] + } + } + + def serializeUnsafe[A: Encoder: SchemaFor](value: A): Array[Byte] = + serialize[SyncIO, A](value).unsafeRunSync() + + def deserializeUnsafe[A: Decoder: SchemaFor](arr: Array[Byte]): DeserializationResult[A] = + deserialize[SyncIO, A](arr).unsafeRunSync() +} + +object AvroSupport { + + // newtype - order of implicits is necessary (swapping them would break derivations, so we can't use typeclass syntax) + + @SuppressWarnings(Array("org.wartremover.warts.Throw")) + given [A, B](using A: Newtype.WithType[B, A], B: Decoder[B]): Decoder[A] = + B.map[A](b => A.make(b).fold[A](str => throw Avro4sDecodingException(str, b), identity[A])) + given [A, B](using A: Newtype.WithType[B, A], B: Encoder[B]): Encoder[A] = B.contramap[A](A.unwrap) + given [A, B](using A: Newtype.WithType[B, A], B: SchemaFor[B]): SchemaFor[A] = B.forType[A] + + // cats - copy pased because: + // Implementation restriction: package com.sksamuel.avro4s.cats is not a valid prefix for a wildcard export, as it is a package + + import scala.jdk.CollectionConverters.* + + given [T](using schemaFor: SchemaFor[T]): SchemaFor[NonEmptyList[T]] = SchemaFor(Schema.createArray(schemaFor.schema)) + given [T](using schemaFor: SchemaFor[T]): SchemaFor[NonEmptyVector[T]] = SchemaFor( + Schema.createArray(schemaFor.schema) + ) + given [T](using schemaFor: SchemaFor[T]): SchemaFor[NonEmptyChain[T]] = SchemaFor( + Schema.createArray(schemaFor.schema) + ) + + @SuppressWarnings(Array("org.wartremover.warts.Equals")) + given [T](using encoder: Encoder[T]): Encoder[NonEmptyList[T]] = (schema: Schema) => { + require(schema.getType == Schema.Type.ARRAY) + val encode = encoder.encode(schema) + (value: NonEmptyList[T]) => value.map(encode).toList.asJava + } + + @SuppressWarnings(Array("org.wartremover.warts.Equals")) + given [T](using encoder: Encoder[T]): Encoder[NonEmptyVector[T]] = (schema: Schema) => { + require(schema.getType == Schema.Type.ARRAY) + val encode = encoder.encode(schema) + (value: NonEmptyVector[T]) => value.map(encode).toVector.asJava + } + + @SuppressWarnings(Array("org.wartremover.warts.Equals")) + given [T](using encoder: Encoder[T]): Encoder[NonEmptyChain[T]] = (schema: Schema) => { + require(schema.getType == Schema.Type.ARRAY) + val encode = encoder.encode(schema) + (value: NonEmptyChain[T]) => value.map(encode).toNonEmptyList.toList.asJava + } + + @SuppressWarnings(Array("org.wartremover.warts.Equals")) + given [T](using decoder: Decoder[T]): Decoder[NonEmptyList[T]] = (schema: Schema) => { + require(schema.getType == Schema.Type.ARRAY) + val decode = decoder.decode(schema) + (value: Any) => + value match { + case array: Array[?] if array.nonEmpty => NonEmptyList.fromListUnsafe(array.toList.map(decode)) + case list: java.util.Collection[?] if !list.isEmpty => + NonEmptyList.fromListUnsafe(list.asScala.map(decode).toList) + case other => sys.error(s"Unsupported type $other") + } + } + + @SuppressWarnings(Array("org.wartremover.warts.Equals")) + given [T](using decoder: Decoder[T]): Decoder[NonEmptyVector[T]] = (schema: Schema) => { + require(schema.getType == Schema.Type.ARRAY) + val decode = decoder.decode(schema) + (value: Any) => + value match { + case array: Array[?] if array.nonEmpty => NonEmptyVector.fromVectorUnsafe(array.toVector.map(decode)) + case list: java.util.Collection[?] if !list.isEmpty => + NonEmptyVector.fromVectorUnsafe(list.asScala.map(decode).toVector) + case other => sys.error(s"Unsupported type $other") // A + } + } + + @SuppressWarnings(Array("org.wartremover.warts.Equals", "org.wartremover.warts.OptionPartial")) + given [T](using decoder: Decoder[T]): Decoder[NonEmptyChain[T]] = (schema: Schema) => { + require(schema.getType == Schema.Type.ARRAY) + val decode = decoder.decode(schema) + (value: Any) => + value match { + case array: Array[?] if array.nonEmpty => NonEmptyChain.fromChainUnsafe(Chain.fromSeq(array.toSeq).map(decode)) + case list: java.util.Collection[?] if !list.isEmpty => + NonEmptyChain.fromChainUnsafe(Chain.fromSeq(list.asScala.toSeq).map(decode)) + case other => sys.error(s"Unsupported type $other") + } + } + + // custom types + + given Decoder[URI] = Decoder[String].map(URI.create) + @SuppressWarnings(Array("org.wartremover.warts.ToString")) // false warning - URI overrides toString + given Encoder[URI] = Encoder[String].contramap[URI](_.toString) + given SchemaFor[URI] = SchemaFor[String].forType[URI] +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/model.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/model.scala deleted file mode 100644 index 2712bd48..00000000 --- a/modules/common/src/main/scala/io/branchtalk/shared/model/model.scala +++ /dev/null @@ -1,158 +0,0 @@ -package io.branchtalk.shared - -import java.nio.charset.{ Charset, StandardCharsets } -import java.time.{ OffsetDateTime, ZoneId } -import java.time.format.DateTimeFormatter -import java.util.regex.Pattern -import java.util.{ Locale, UUID => jUUID } -import cats.effect.{ Clock, Sync } -import cats.{ Eq, Functor, Order, Show } -import eu.timepit.refined.api.Refined -import eu.timepit.refined.string.Uuid -import io.estatico.newtype.macros.newtype -import io.estatico.newtype.ops._ -import org.typelevel.log4cats.SelfAwareStructuredLogger -import org.typelevel.log4cats.slf4j.Slf4jLogger - -import scala.annotation.unused -import scala.reflect.runtime.universe.{ WeakTypeTag, weakTypeOf } - -package object model { - - implicit class ShowCompanionOps(@unused private val s: Show.type) extends AnyVal { - - // displays "wrapper.package.WrapperName(content)" - def wrap[Wrapper: WeakTypeTag, Content: Show](f: Wrapper => Content): Show[Wrapper] = { - @SuppressWarnings(Array("org.wartremover.warts.ToString")) val name = weakTypeOf[Wrapper].toString - s => show"$name(${f(s)})" - } - } - - type Logger[F[_]] = SelfAwareStructuredLogger[F] - val Logger = Slf4jLogger - - type UUID = jUUID - object UUID { - - def apply(string: String Refined Uuid)(implicit uuidGenerator: UUIDGenerator): UUID = - uuidGenerator(string) - def create[F[_]: Sync](implicit uuidGenerator: UUIDGenerator): F[UUID] = - uuidGenerator.create[F] - def parse[F[_]: Sync](string: String)(implicit uuidGenerator: UUIDGenerator): F[UUID] = - uuidGenerator.parse[F](string) - } - - @newtype final case class ID[+Entity](uuid: UUID) - object ID { - def unapply[Entity](entity: ID[Entity]): Some[UUID] = Some(entity.uuid) - def create[F[_]: Sync, Entity](implicit uuidGenerator: UUIDGenerator): F[ID[Entity]] = - UUID.create[F].map(ID[Entity]) - def parse[F[_]: Sync, Entity](string: String)(implicit uuidGenerator: UUIDGenerator): F[ID[Entity]] = - UUID.parse[F](string).map(ID[Entity]) - - implicit def show[Entity]: Show[ID[Entity]] = Show.wrap(_.uuid) - implicit def order[Entity]: Order[ID[Entity]] = Order[UUID].coerce[Order[ID[Entity]]] - } - - @newtype final case class CreationTime(offsetDateTime: OffsetDateTime) - object CreationTime { - def unapply(creationTime: CreationTime): Some[OffsetDateTime] = Some(creationTime.offsetDateTime) - def now[F[_]: Functor: Clock]: F[CreationTime] = - Clock[F].realTimeInstant.map(OffsetDateTime.ofInstant(_, ZoneId.systemDefault())).map(CreationTime(_)) - - implicit val show: Show[CreationTime] = Show.wrap(_.offsetDateTime.pipe(DateTimeFormatter.ISO_INSTANT.format)) - implicit val order: Order[CreationTime] = - Order.by[CreationTime, OffsetDateTime](_.offsetDateTime)(Order.fromComparable) - } - @newtype final case class ModificationTime(offsetDateTime: OffsetDateTime) - object ModificationTime { - def unapply(modificationTime: ModificationTime): Some[OffsetDateTime] = Some(modificationTime.offsetDateTime) - def now[F[_]: Functor: Clock]: F[ModificationTime] = - Clock[F].realTimeInstant.map(OffsetDateTime.ofInstant(_, ZoneId.systemDefault())).map(ModificationTime(_)) - - implicit val show: Show[ModificationTime] = Show.wrap(_.offsetDateTime.pipe(DateTimeFormatter.ISO_INSTANT.format)) - implicit val order: Order[ModificationTime] = - Order.by[ModificationTime, OffsetDateTime](_.offsetDateTime)(Order.fromComparable) - } - - @newtype final case class CreationScheduled[Entity](id: ID[Entity]) - object CreationScheduled { - def unapply[Entity](creationScheduled: CreationScheduled[Entity]): Some[ID[Entity]] = Some(creationScheduled.id) - - implicit def show[Entity]: Show[CreationScheduled[Entity]] = Show.wrap(_.id) - implicit def eq[Entity]: Eq[CreationScheduled[Entity]] = Eq[ID[Entity]].coerce - } - @newtype final case class UpdateScheduled[Entity](id: ID[Entity]) - object UpdateScheduled { - def unapply[Entity](updateScheduled: UpdateScheduled[Entity]): Some[ID[Entity]] = Some(updateScheduled.id) - - implicit def show[Entity]: Show[UpdateScheduled[Entity]] = Show.wrap(_.id) - implicit def eq[Entity]: Eq[UpdateScheduled[Entity]] = Eq[ID[Entity]].coerce - } - @newtype final case class DeletionScheduled[Entity](id: ID[Entity]) - object DeletionScheduled { - def unapply[Entity](deletionScheduled: DeletionScheduled[Entity]): Some[ID[Entity]] = Some(deletionScheduled.id) - - implicit def show[Entity]: Show[DeletionScheduled[Entity]] = Show.wrap(_.id) - implicit def eq[Entity]: Eq[DeletionScheduled[Entity]] = Eq[ID[Entity]].coerce - } - @newtype final case class RestoreScheduled[Entity](id: ID[Entity]) - object RestoreScheduled { - def unapply[Entity](restoreScheduled: RestoreScheduled[Entity]): Some[ID[Entity]] = Some(restoreScheduled.id) - - implicit def show[Entity]: Show[RestoreScheduled[Entity]] = Show.wrap(_.id) - implicit def eq[Entity]: Eq[RestoreScheduled[Entity]] = Eq[ID[Entity]].coerce - } - - implicit class Untupled2[I1, I2, Out](private val f: ((I1, I2)) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2): Out = f.apply((i1, i2)) - } - implicit class Untupled2A[I1, I2, Out](private val f: (I1, I2) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2): Out = f.apply(i1, i2) - } - implicit class Untupled3[I1, I2, I3, Out](private val f: ((I1, I2, I3)) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3): Out = f.apply((i1, i2, i3)) - } - implicit class Untupled3A[I1, I2, I3, Out](private val f: (I1, (I2, I3)) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3): Out = f.apply(i1, (i2, i3)) - } - implicit class Untupled4[I1, I2, I3, I4, Out](private val f: ((I1, I2, I3, I4)) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4): Out = f.apply((i1, i2, i3, i4)) - } - implicit class Untupled4A[I1, I2, I3, I4, Out](private val f: (I1, (I2, I3, I4)) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4): Out = f.apply(i1, (i2, i3, i4)) - } - implicit class Untupled5[I1, I2, I3, I4, I5, Out](private val f: ((I1, I2, I3, I4, I5)) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5): Out = f.apply((i1, i2, i3, i4, i5)) - } - implicit class Untupled5A[I1, I2, I3, I4, I5, Out](private val f: (I1, (I2, I3, I4, I5)) => Out) extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5): Out = f.apply(i1, (i2, i3, i4, i5)) - } - implicit class Untupled6[I1, I2, I3, I4, I5, I6, Out](private val f: ((I1, I2, I3, I4, I5, I6)) => Out) - extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6): Out = f.apply((i1, i2, i3, i4, i5, i6)) - } - implicit class Untupled6A[I1, I2, I3, I4, I5, I6, Out](private val f: (I1, (I2, I3, I4, I5, I6)) => Out) - extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6): Out = f.apply(i1, (i2, i3, i4, i5, i6)) - } - implicit class Untupled7[I1, I2, I3, I4, I5, I6, I7, Out](private val f: ((I1, I2, I3, I4, I5, I6, I7)) => Out) - extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6, i7: I7): Out = f.apply((i1, i2, i3, i4, i5, i6, i7)) - } - implicit class Untupled7A[I1, I2, I3, I4, I5, I6, I7, Out](private val f: (I1, (I2, I3, I4, I5, I6, I7)) => Out) - extends AnyVal { - def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6, i7: I7): Out = f.apply(i1, (i2, i3, i4, i5, i6, i7)) - } - - val branchtalkCharset: Charset = StandardCharsets.UTF_8 // used in getBytes and new String - val branchtalkLocale: Locale = Locale.ROOT // used in toLowerCase(branchtalkLocale) in Meta definitions - - private val basePattern: Pattern = Pattern.compile("([A-Z]+)([A-Z][a-z])") - private val swapPattern: Pattern = Pattern.compile("([a-z\\d])([A-Z])") - def discriminatorNameMapper(separator: String): String => String = in => { - val simpleName = in.substring(in.lastIndexOf(separator) + separator.length) - val partial = basePattern.matcher(simpleName).replaceAll("$1-$2") - swapPattern.matcher(partial).replaceAll("$1-$2").toLowerCase(branchtalkLocale) - } -} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/scheduled.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/scheduled.scala new file mode 100644 index 00000000..4c696051 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/scheduled.scala @@ -0,0 +1,40 @@ +package io.branchtalk.shared.model + +import cats.{ Eq, Show } +import neotype.* + +type CreationScheduled[A] = CreationScheduled.Type[A] +object CreationScheduled extends NewtypeF[ID] { + + def unapply[Entity](creationScheduled: CreationScheduled[Entity]): Some[ID[Entity]] = Some(creationScheduled.unwrap) + + given [Entity]: Show[CreationScheduled[Entity]] = unsafeMakeF[Show, Entity](Show[ID[Entity]]) + given [Entity]: Eq[CreationScheduled[Entity]] = unsafeMakeF[Eq, Entity](Eq[ID[Entity]]) +} + +type UpdateScheduled[A] = UpdateScheduled.Type[A] +object UpdateScheduled extends NewtypeF[ID] { + + def unapply[Entity](updateScheduled: UpdateScheduled[Entity]): Some[ID[Entity]] = Some(updateScheduled.unwrap) + + given [Entity]: Show[UpdateScheduled[Entity]] = unsafeMakeF[Show, Entity](Show[ID[Entity]]) + given [Entity]: Eq[UpdateScheduled[Entity]] = unsafeMakeF[Eq, Entity](Eq[ID[Entity]]) +} + +type DeletionScheduled[A] = DeletionScheduled.Type[A] +object DeletionScheduled extends NewtypeF[ID] { + + def unapply[Entity](deletionScheduled: DeletionScheduled[Entity]): Some[ID[Entity]] = Some(deletionScheduled.unwrap) + + given [Entity]: Show[DeletionScheduled[Entity]] = unsafeMakeF[Show, Entity](Show[ID[Entity]]) + given [Entity]: Eq[DeletionScheduled[Entity]] = unsafeMakeF[Eq, Entity](Eq[ID[Entity]]) +} + +type RestoreScheduled[A] = RestoreScheduled.Type[A] +object RestoreScheduled extends NewtypeF[ID] { + + def unapply[Entity](restoreScheduled: RestoreScheduled[Entity]): Some[ID[Entity]] = Some(restoreScheduled.unwrap) + + given [Entity]: Show[RestoreScheduled[Entity]] = unsafeMakeF[Show, Entity](Show[ID[Entity]]) + given [Entity]: Eq[RestoreScheduled[Entity]] = unsafeMakeF[Eq, Entity](Eq[ID[Entity]]) +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/strings.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/strings.scala new file mode 100644 index 00000000..87d14639 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/strings.scala @@ -0,0 +1,22 @@ +package io.branchtalk.shared.model + +import java.nio.charset.{ Charset, StandardCharsets } +import java.util.Locale +import java.util.regex.Pattern + +val branchtalkCharset: Charset = StandardCharsets.UTF_8 // used in getBytes and new String +val branchtalkLocale: Locale = Locale.ROOT // used in toLowerCase(branchtalkLocale) in Meta definitions + +private val basePattern: Pattern = Pattern.compile("([A-Z]+)([A-Z][a-z])") +private val swapPattern: Pattern = Pattern.compile("([a-z\\d])([A-Z])") +def discriminatorNameMapper(separator: String): String => String = in => { + val simpleName = in.substring(in.lastIndexOf(separator) + separator.length) + val partial = basePattern.matcher(simpleName).replaceAll("$1-$2") + swapPattern.matcher(partial).replaceAll("$1-$2").toLowerCase(branchtalkLocale) +} +// String => String has to be object rather than val, so that Jsoniter macro could find it +object adtDiscriminatorNameMapper extends (String => String) { + private val impl = discriminatorNameMapper(".") + + override def apply(name: String): String = impl(name) +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/times.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/times.scala new file mode 100644 index 00000000..ece0c98e --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/times.scala @@ -0,0 +1,27 @@ +package io.branchtalk.shared.model + +import java.time.{ OffsetDateTime, ZoneId } +import java.time.format.DateTimeFormatter +import cats.{ Functor, Order, Show } +import cats.effect.{ Clock, Sync } +import neotype.* + +type CreationTime = CreationTime.Type +object CreationTime extends Newtype[OffsetDateTime] { + def unapply(creationTime: CreationTime): Some[OffsetDateTime] = Some(creationTime.unwrap) + def now[F[_]: Functor: Clock]: F[CreationTime] = + Clock[F].realTimeInstant.map(OffsetDateTime.ofInstant(_, ZoneId.systemDefault())).map(unsafeMake) + + given Show[CreationTime] = unsafeMakeF[Show](_.pipe(DateTimeFormatter.ISO_INSTANT.format)) + given Order[CreationTime] = unsafeMakeF[Order](Order.fromComparable) +} + +type ModificationTime = ModificationTime.Type +object ModificationTime extends Newtype[OffsetDateTime] { + def unapply(ModificationTime: ModificationTime): Some[OffsetDateTime] = Some(ModificationTime.unwrap) + def now[F[_]: Functor: Clock]: F[ModificationTime] = + Clock[F].realTimeInstant.map(OffsetDateTime.ofInstant(_, ZoneId.systemDefault())).map(unsafeMake) + + given Show[ModificationTime] = unsafeMakeF[Show](_.pipe(DateTimeFormatter.ISO_INSTANT.format)) + given Order[ModificationTime] = unsafeMakeF[Order](Order.fromComparable) +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/untupled_ops.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/untupled_ops.scala new file mode 100644 index 00000000..18729380 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/untupled_ops.scala @@ -0,0 +1,41 @@ +package io.branchtalk.shared.model + +extension [I1, I2, Out](f: ((I1, I2)) => Out) def untupled(i1: I1, i2: I2): Out = f.apply((i1, i2)) + +extension [I1, I2, Out](f: (I1, I2) => Out) def untupled(i1: I1, i2: I2): Out = f.apply(i1, i2) + +extension [I1, I2, I3, Out](f: ((I1, I2, I3)) => Out) def untupled(i1: I1, i2: I2, i3: I3): Out = f.apply((i1, i2, i3)) + +extension [I1, I2, I3, Out](f: (I1, (I2, I3)) => Out) def untupled(i1: I1, i2: I2, i3: I3): Out = f.apply(i1, (i2, i3)) + +extension [I1, I2, I3, I4, Out](f: ((I1, I2, I3, I4)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4): Out = f.apply((i1, i2, i3, i4)) +} + +extension [I1, I2, I3, I4, Out](f: (I1, (I2, I3, I4)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4): Out = f.apply(i1, (i2, i3, i4)) +} + +extension [I1, I2, I3, I4, I5, Out](f: ((I1, I2, I3, I4, I5)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5): Out = f.apply((i1, i2, i3, i4, i5)) +} + +extension [I1, I2, I3, I4, I5, Out](f: (I1, (I2, I3, I4, I5)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5): Out = f.apply(i1, (i2, i3, i4, i5)) +} + +extension [I1, I2, I3, I4, I5, I6, Out](f: ((I1, I2, I3, I4, I5, I6)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6): Out = f.apply((i1, i2, i3, i4, i5, i6)) +} + +extension [I1, I2, I3, I4, I5, I6, Out](f: (I1, (I2, I3, I4, I5, I6)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6): Out = f.apply(i1, (i2, i3, i4, i5, i6)) +} + +extension [I1, I2, I3, I4, I5, I6, I7, Out](f: ((I1, I2, I3, I4, I5, I6, I7)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6, i7: I7): Out = f.apply((i1, i2, i3, i4, i5, i6, i7)) +} + +extension [I1, I2, I3, I4, I5, I6, I7, Out](f: (I1, (I2, I3, I4, I5, I6, I7)) => Out) { + def untupled(i1: I1, i2: I2, i3: I3, i4: I4, i5: I5, i6: I6, i7: I7): Out = f.apply(i1, (i2, i3, i4, i5, i6, i7)) +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/updatables.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/updatables.scala new file mode 100644 index 00000000..ef9adc66 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/updatables.scala @@ -0,0 +1,66 @@ +package io.branchtalk.shared.model + +import cats.{ Applicative, Traverse } + +// Express the intent that something should be updated or not better than Option. +enum Updatable[+A] derives ShowPretty, FastEq { + case Set(value: A) + case Keep + + def fold[B](set: A => B, keep: => B): B = this match { + case Set(value) => set(value) + case Keep => keep + } + + def toOption: Option[A] = this match { + case Set(value) => Some(value) + case Keep => None + } +} +object Updatable { + + private val applicative: Applicative[Updatable] = new Applicative[Updatable] { + override def pure[A](a: A): Updatable[A] = Set(a) + override def ap[A, B](ff: Updatable[A => B])(fa: Updatable[A]): Updatable[B] = (ff, fa) match { + case (Set(f), Set(a)) => Set(f(a)) + case _ => Keep + } + } + private val traverse: Traverse[Updatable] = cats.derived.semiauto.traverse[Updatable] + given ApplicativeTraverse[Updatable] = ApplicativeTraverse.derived(applicative, traverse) +} + +// Express the intent that something should be updated, erased or kept better than Option[Either[Unit, *]]. +enum OptionUpdatable[+A] derives ShowPretty, FastEq { + case Set(value: A) + case Erase + case Keep + + def fold[B](set: A => B, keep: => B, erase: => B): B = this match { + case Set(value) => set(value) + case Erase => keep + case Keep => erase + } + + def toOptionEither: Option[Either[Unit, A]] = this match { + case Set(value) => Some(Right(value)) + case Erase => Some(Left(())) + case Keep => None + } +} +object OptionUpdatable { + + def setFromOption[A](option: Option[A]): OptionUpdatable[A] = option.fold[OptionUpdatable[A]](Erase)(Set(_)) + + private val applicative: Applicative[OptionUpdatable] = new Applicative[OptionUpdatable] { + override def pure[A](a: A): OptionUpdatable[A] = Set(a) + override def ap[A, B](ff: OptionUpdatable[A => B])(fa: OptionUpdatable[A]): OptionUpdatable[B] = (ff, fa) match { + case (Set(f), Set(a)) => Set(f(a)) + case (Erase, _) => Erase + case (_, Erase) => Erase + case _ => Keep + } + } + private val traverse: Traverse[OptionUpdatable] = cats.derived.semiauto.traverse[OptionUpdatable] + given ApplicativeTraverse[OptionUpdatable] = ApplicativeTraverse.derived(applicative, traverse) +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/uuids.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/uuids.scala new file mode 100644 index 00000000..7d5052db --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/uuids.scala @@ -0,0 +1,23 @@ +package io.branchtalk.shared.model + +import cats.effect.Sync +import com.eatthepath.uuid.FastUUID +import com.fasterxml.uuid.Generators + +type UUID = java.util.UUID +object UUID { + + val empty: UUID = java.util.UUID.fromString("00000000-0000-0000-0000-000000000000") + + def create[F[_]: Sync](using generator: Generator): F[UUID] = generator.create[F] + def parse[F[_]: Sync](string: String)(using generator: Generator): F[UUID] = generator.parse[F](string) + + trait Generator { + def create[F[_]: Sync]: F[UUID] + def parse[F[_]: Sync](string: String): F[UUID] + } + object FastGenerator extends Generator { + override def create[F[_]: Sync]: F[UUID] = Sync[F].delay(Generators.timeBasedGenerator().generate()) + override def parse[F[_]: Sync](string: String): F[UUID] = Sync[F].delay(FastUUID.parseUUID(string)) + } +} diff --git a/modules/common/src/main/scala/io/branchtalk/shared/model/void.scala b/modules/common/src/main/scala/io/branchtalk/shared/model/void.scala new file mode 100644 index 00000000..e87e0310 --- /dev/null +++ b/modules/common/src/main/scala/io/branchtalk/shared/model/void.scala @@ -0,0 +1,8 @@ +package io.branchtalk.shared.model + +import scala.annotation.unused + +/** Explicitly mark value as unused, because it's e.g. returned from a mutable operation, and we care only about + * side-effect. + */ +inline def void[A](@unused value: A): Unit = () diff --git a/modules/common/src/main/scala/neotype/newtypes.scala b/modules/common/src/main/scala/neotype/newtypes.scala new file mode 100644 index 00000000..1358a4fd --- /dev/null +++ b/modules/common/src/main/scala/neotype/newtypes.scala @@ -0,0 +1,56 @@ +package neotype + +import cats.data.NonEmptyList +import cats.effect.Sync + +// TODO: PR to upstream +abstract class NewtypeT[A] { self => + opaque type Type[B] = A + + def validate(input: A): Boolean | String = true + + inline def apply[B](inline input: A): Type[B] = instance[B].apply(input) + inline def applyAll[B](inline values: A*): List[Type[B]] = instance[B].applyAll(values*) + + final def make[B](input: A): Either[String, Type[B]] = validate(input) match { + case true => Right(input) + case false => Left("Validation Failed") + case message: String => Left(message) + } + + inline def unwrap[B](inline input: Type[B]): A = input + inline def unsafeMake[B](inline input: A): Type[B] = input + inline def unsafeMakeF[F[_], B](inline input: F[A]): F[Type[B]] = input + + private val impl = new Newtype[A] { + override inline def validate(input: A): Boolean | String = self.validate(input) + } + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + given instance[B]: Newtype.WithType[A, Type[B]] = impl.asInstanceOf[Newtype.WithType[A, Type[B]]] +} + +// TODO: PR to upstream +abstract class NewtypeF[F[_]] { self => + opaque type Type[A] = F[A] + + def validate[A](input: F[A]): Boolean | String = true + + inline def apply[A](inline input: F[A]): Type[A] = instance[A].apply(input) + inline def applyAll[A](inline values: F[A]*): List[Type[A]] = instance[A].applyAll(values*) + + final def make[A](input: F[A]): Either[String, Type[A]] = validate(input) match { + case true => Right(input) + case false => Left("Validation Failed") + case message: String => Left(message) + } + + inline def unwrap[A](inline input: Type[A]): F[A] = input + inline def unsafeMake[A](inline input: F[A]): Type[A] = input + inline def unsafeMakeF[G[_], A](inline input: G[F[A]]): G[Type[A]] = input + + private val impl = new Newtype[F[Any]] { + override inline def validate(input: F[Any]): Boolean | String = self.validate(input) + } + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + given instance[A]: Newtype.WithType[F[A], Type[A]] = impl.asInstanceOf[Newtype.WithType[F[A], Type[A]]] +} diff --git a/modules/common/src/test/scala/com/devskiller/jfairy/makeFairy.scala b/modules/common/src/test/scala/com/devskiller/jfairy/makeFairy.scala new file mode 100644 index 00000000..1e115006 --- /dev/null +++ b/modules/common/src/test/scala/com/devskiller/jfairy/makeFairy.scala @@ -0,0 +1,136 @@ +package com.devskiller.jfairy + +import com.devskiller.jfairy.data.* +import com.devskiller.jfairy.producer +import com.devskiller.jfairy.producer.* +import com.devskiller.jfairy.producer.company.* +import com.devskiller.jfairy.producer.net.* +import com.devskiller.jfairy.producer.payment.* +import com.devskiller.jfairy.producer.person.* +import com.devskiller.jfairy.producer.text.* + +// workaround for default package +def makeFairy(localeString: String): Fairy = { + + // these psychopats used Guice for building this, and now I'm getting: + // com.google.inject.CreationException: Unable to create injector + val randomGenerator: RandomGenerator = RandomGenerator() + val baseProducer: BaseProducer = BaseProducer(randomGenerator) + val dataMaster: DataMaster = MapBasedDataMaster(baseProducer).tap { dm => + dm.readResources("jfairy.yml") + dm.readResources("jfairy_" + localeString + ".yml") + } + val textProducer: TextProducer = makeTextProducer(baseProducer, dataMaster) + val timeProvider: TimeProvider = TimeProvider() + val dateProducer: DateProducer = DateProducer(baseProducer, timeProvider) + val networkProducer: NetworkProducer = makeNetworkProducer(baseProducer) + val creditCardProvider: CreditCardProvider = CreditCardProvider(dataMaster, baseProducer, dateProducer) + val (nationalIdentificationNumberFactory: NationalIdentificationNumberFactory, + nationalIdentityCardNumberProvider: NationalIdentityCardNumberProvider, + vatIdentificationNumberProvider: VATIdentificationNumberProvider, + addressProvider: AddressProvider, + passportNumberProvider: PassportNumberProvider + ) = localeString match { + case "de" => + ( + producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer), + producer.person.locale.de.DeNationalIdentityCardNumberProvider(baseProducer), + producer.company.locale.de.DeVATIdentificationNumberProvider(), + producer.person.locale.de.DeAddressProvider(dataMaster, baseProducer), + producer.person.locale.de.DePassportNumberProvider(baseProducer) + ) + case "es" => + ( + producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer), + producer.person.locale.es.EsNationalIdentityCardNumberProvider(), + producer.company.locale.es.EsVATIdentificationNumberProvider(), + producer.person.locale.es.EsAddressProvider(dataMaster, baseProducer), + producer.person.locale.es.EsPassportNumberProvider() + ) + /* + case "fr" => + ( + producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer), + producer.person.locale.fr.FrNationalIdentityCardNumberProvider(baseProducer), + producer.company.locale.fr.FrVATIdentificationNumberProvider(baseProducer), + producer.person.locale.fr.FrAddressProvider(dataMaster, baseProducer), + producer.person.locale.fr.FrPassportNumberProvider() + ) + */ + case "ka" => + ( + producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer), + producer.person.locale.ka.KaNationalIdentityCardNumberProvider(baseProducer), + producer.company.locale.ka.KaVATIdentificationNumberProvider(baseProducer), + producer.person.locale.ka.KaAddressProvider(dataMaster, baseProducer), + producer.person.locale.ka.KaPassportNumberProvider(baseProducer) + ) + case "pl" => + ( + producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer), + producer.person.locale.pl.PlNationalIdentityCardNumberProvider(dateProducer, baseProducer), + producer.company.locale.pl.PlVATIdentificationNumberProvider(baseProducer), + producer.person.locale.pl.PlAddressProvider(dataMaster, baseProducer), + producer.person.locale.pl.PlPassportNumberProvider() + ) + case "sv" => + val num = producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer) + ( + num, + producer.person.locale.sv.SvNationalIdentityCardNumberProvider(dateProducer, baseProducer), + producer.company.locale.sv.SvVATIdentificationNumberProvider(baseProducer, dateProducer, num), + producer.person.locale.sv.SvAddressProvider(dataMaster, baseProducer), + producer.person.locale.sv.SvPassportNumberProvider() + ) + case "zh" => + ( + producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer), + producer.person.locale.zh.ZhNationalIdentityCardNumberProvider(baseProducer), + producer.company.locale.zh.ZhVATIdentificationNumberProvider(), + producer.person.locale.zh.ZhAddressProvider(dataMaster, baseProducer), + producer.person.locale.zh.ZhPassportNumberProvider() + ) + // en + case _ => + ( + producer.person.locale.NoNationalIdentificationNumberFactory(baseProducer, dateProducer), + producer.person.locale.en.EnNationalIdentityCardNumberProvider(baseProducer), + producer.company.locale.en.EnVATIdentificationNumberProvider(baseProducer), + producer.person.locale.en.EnAddressProvider(dataMaster, baseProducer), + producer.person.locale.en.EnPassportNumberProvider() + ) + } + val companyFactory: CompanyFactory = new CompanyFactory { + override def produceCompany(companyProperties: CompanyProperties.CompanyProperty*): CompanyProvider = + DefaultCompanyProvider(baseProducer, dataMaster, vatIdentificationNumberProvider, companyProperties: _*) + } + val personFactory: PersonFactory = new PersonFactory { + override def producePersonProvider(personProperties: PersonProperties.PersonProperty*): PersonProvider = + DefaultPersonProvider( + dataMaster, + dateProducer, + baseProducer, + nationalIdentificationNumberFactory, + nationalIdentityCardNumberProvider, + addressProvider, + companyFactory, + passportNumberProvider, + timeProvider, + personProperties: _* + ) + } + val ibanFactory: IBANFactory = new IBANFactory { + override def produceIBANProvider(properties: IBANProperties.Property*): IBANProvider = + DefaultIBANProvider(baseProducer, dataMaster, properties: _*) + } + + Fairy(textProducer, + personFactory, + networkProducer, + baseProducer, + dateProducer, + creditCardProvider, + companyFactory, + ibanFactory + ) +} diff --git a/modules/common/src/test/scala/com/devskiller/jfairy/producer/net/net_producers.scala b/modules/common/src/test/scala/com/devskiller/jfairy/producer/net/net_producers.scala new file mode 100644 index 00000000..623240b0 --- /dev/null +++ b/modules/common/src/test/scala/com/devskiller/jfairy/producer/net/net_producers.scala @@ -0,0 +1,7 @@ +package com.devskiller.jfairy.producer.net + +import com.devskiller.jfairy.producer.* + +// workaround for default package +def makeNetworkProducer(baseProducer: BaseProducer): NetworkProducer = + NetworkProducer(IPNumberProducer(baseProducer)) diff --git a/modules/common/src/test/scala/com/devskiller/jfairy/producer/text/text_producers.scala b/modules/common/src/test/scala/com/devskiller/jfairy/producer/text/text_producers.scala new file mode 100644 index 00000000..196484c9 --- /dev/null +++ b/modules/common/src/test/scala/com/devskiller/jfairy/producer/text/text_producers.scala @@ -0,0 +1,9 @@ +package com.devskiller.jfairy.producer.text + +import com.devskiller.jfairy.data.* +import com.devskiller.jfairy.producer.* +import com.devskiller.jfairy.producer.text.* + +// workaround for default package +def makeTextProducer(baseProducer: BaseProducer, dataMaster: DataMaster): TextProducer = + TextProducer(TextProducerInternal(dataMaster, baseProducer), baseProducer) diff --git a/modules/common/src/test/scala/io/branchtalk/shared/Fixtures.scala b/modules/common/src/test/scala/io/branchtalk/shared/Fixtures.scala index 75c2faab..db75016d 100644 --- a/modules/common/src/test/scala/io/branchtalk/shared/Fixtures.scala +++ b/modules/common/src/test/scala/io/branchtalk/shared/Fixtures.scala @@ -1,17 +1,19 @@ package io.branchtalk.shared import cats.effect.IO -import com.devskiller.jfairy.Fairy -import com.devskiller.jfairy.producer.{ BaseProducer, DateProducer } -import com.devskiller.jfairy.producer.company.{ Company, CompanyProperties } -import com.devskiller.jfairy.producer.net.NetworkProducer -import com.devskiller.jfairy.producer.payment.CreditCard -import com.devskiller.jfairy.producer.person.{ Person, PersonProperties } -import com.devskiller.jfairy.producer.text.TextProducer +import com.devskiller.jfairy.* +import com.devskiller.jfairy.data.* +import com.devskiller.jfairy.producer +import com.devskiller.jfairy.producer.* +import com.devskiller.jfairy.producer.company.* +import com.devskiller.jfairy.producer.net.* +import com.devskiller.jfairy.producer.payment.* +import com.devskiller.jfairy.producer.person.* +import com.devskiller.jfairy.producer.text.* object Fixtures { - private val fairy = Fairy.create().pure[IO] + private val fairy = makeFairy("en").pure[IO] val baseProducer: IO[BaseProducer] = fairy.map(_.baseProducer) def company(companyProperties: CompanyProperties.CompanyProperty*): IO[Company] = diff --git a/modules/common/src/test/scala/io/branchtalk/shared/model/TestUUIDGenerator.scala b/modules/common/src/test/scala/io/branchtalk/shared/model/TestUUIDGenerator.scala index f4ced76a..0d216806 100644 --- a/modules/common/src/test/scala/io/branchtalk/shared/model/TestUUIDGenerator.scala +++ b/modules/common/src/test/scala/io/branchtalk/shared/model/TestUUIDGenerator.scala @@ -1,12 +1,10 @@ package io.branchtalk.shared.model import cats.effect.Sync -import eu.timepit.refined.api.Refined -import eu.timepit.refined.string.Uuid import scala.collection.mutable -class TestUUIDGenerator extends UUIDGenerator { +class TestUUIDGenerator extends UUID.Generator { private val queue = mutable.Queue.empty[UUID] @@ -14,22 +12,16 @@ class TestUUIDGenerator extends UUIDGenerator { queue.enqueue(uuid) () } - def stubNext(string: String Refined Uuid): Unit = synchronized { - queue.enqueue(apply(string)) - () - } def clean(): Unit = synchronized { queue.dequeueAll(_ => true) () } - override def apply(string: String Refined Uuid): UUID = UUIDGenerator.FastUUIDGenerator(string) - override def create[F[_]: Sync]: F[UUID] = synchronized { - if (queue.isEmpty) UUIDGenerator.FastUUIDGenerator.create[F] + if (queue.isEmpty) UUID.FastGenerator.create[F] else queue.dequeue().pure[F] } - override def parse[F[_]: Sync](string: String): F[UUID] = UUIDGenerator.FastUUIDGenerator.parse[F](string) + override def parse[F[_]: Sync](string: String): F[UUID] = UUID.FastGenerator.parse[F](string) } diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelAPIs.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelAPIs.scala index a362fbca..de68e0c3 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelAPIs.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelAPIs.scala @@ -1,9 +1,9 @@ package io.branchtalk.discussions.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.discussions.api.ChannelModels._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.discussions.api.ChannelModels.* import io.branchtalk.discussions.model.Channel import io.branchtalk.shared.model.{ ID, OptionUpdatable, Updatable } import sttp.model.StatusCode @@ -21,7 +21,7 @@ object ChannelAPIs { val paginate: AuthedEndpoint[ Option[Authentication], - (Option[PaginationOffset], Option[PaginationLimit]), + (Option[Pagination.Offset], Option[Pagination.Limit]), ChannelError, Pagination[APIChannel], Any @@ -33,8 +33,8 @@ object ChannelAPIs { .get .securityIn(optAuthHeader) .in(prefix) - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APIChannel]]) .errorOut(errorMapping) .notRequiringPermissions @@ -84,7 +84,7 @@ object ChannelAPIs { List( EndpointIO.Example.of( UpdateChannelRequest( - newUrlName = Updatable.Set(Channel.UrlName("example")), + newUrlName = Updatable.Set(Channel.UrlName.unsafeMake("example")), // should not be unsafeMake :( newName = Updatable.Set(Channel.Name("example")), newDescription = OptionUpdatable.Set(Channel.Description("example")) ), @@ -115,7 +115,7 @@ object ChannelAPIs { .out(jsonBody[UpdateChannelResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _) => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID.unsafeMake(channelID.unwrap))) } val delete: AuthedEndpoint[Authentication, ID[Channel], ChannelError, DeleteChannelResponse, Any] = endpoint @@ -129,7 +129,7 @@ object ChannelAPIs { .out(jsonBody[DeleteChannelResponse]) .errorOut(errorMapping) .requiringPermissions(channelID => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID.unsafeMake(channelID.unwrap))) ) val restore: AuthedEndpoint[Authentication, ID[Channel], ChannelError, RestoreChannelResponse, Any] = endpoint @@ -143,6 +143,6 @@ object ChannelAPIs { .out(jsonBody[RestoreChannelResponse]) .errorOut(errorMapping) .requiringPermissions(channelID => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID.unsafeMake(channelID.unwrap))) ) } diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelModels.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelModels.scala index c241c0dc..4f94bcab 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelModels.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/ChannelModels.scala @@ -1,78 +1,61 @@ package io.branchtalk.discussions.api import cats.data.NonEmptyList -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.string.MatchesRegex -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.ADT -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.api.TapirSupport._ +import io.branchtalk.api.JsoniterSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } import io.branchtalk.discussions.model.Channel import io.branchtalk.shared.model.{ ID, OptionUpdatable, Updatable } -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* -@SuppressWarnings(Array("org.wartremover.warts.All")) // for macros object ChannelModels { // properties codecs + given JsCodec[Channel.UrlName] = newtypeCodec + given JsCodec[Channel.Name] = newtypeCodec + given JsCodec[Channel.Description] = newtypeCodec - implicit val channelUrlNameCodec: JsCodec[Channel.UrlName] = - summonCodec[String](JsonCodecMaker.make).refine[MatchesRegex["[A-Za-z0-9_-]+"]].asNewtype[Channel.UrlName] - implicit val channelNameCodec: JsCodec[Channel.Name] = - summonCodec[String](JsonCodecMaker.make).refine[NonEmpty].asNewtype[Channel.Name] - implicit val channelDescriptionCodec: JsCodec[Channel.Description] = - summonCodec[String](JsonCodecMaker.make).refine[NonEmpty].asNewtype[Channel.Description] - - // properties schemas - implicit val channelUrlNameSchema: JsSchema[Channel.UrlName] = - summonSchema[String Refined MatchesRegex["[A-Za-z0-9_-]+"]].asNewtype[Channel.UrlName] - implicit val channelNameSchema: JsSchema[Channel.Name] = - summonSchema[NonEmptyString].asNewtype[Channel.Name] - implicit val channelDescriptionSchema: JsSchema[Channel.Description] = - summonSchema[NonEmptyString].asNewtype[Channel.Description] - - @Semi(JsCodec, JsSchema) sealed trait ChannelError extends ADT + sealed trait ChannelError derives DefaultJsCodec, JsSchema object ChannelError { - @Semi(JsCodec, JsSchema) final case class BadCredentials(msg: String) extends ChannelError - @Semi(JsCodec, JsSchema) final case class NoPermission(msg: String) extends ChannelError - @Semi(JsCodec, JsSchema) final case class NotFound(msg: String) extends ChannelError - @Semi(JsCodec, JsSchema) final case class ValidationFailed(error: NonEmptyList[String]) extends ChannelError + final case class BadCredentials(msg: String) extends ChannelError derives DefaultJsCodec, JsSchema + final case class NoPermission(msg: String) extends ChannelError derives DefaultJsCodec, JsSchema + final case class NotFound(msg: String) extends ChannelError derives DefaultJsCodec, JsSchema + final case class ValidationFailed(error: NonEmptyList[String]) extends ChannelError derives DefaultJsCodec, JsSchema } - @Semi(JsCodec, JsSchema) final case class APIChannel( + final case class APIChannel( id: ID[Channel], urlName: Channel.UrlName, name: Channel.Name, description: Option[Channel.Description] - ) + ) derives DefaultJsCodec, + JsSchema object APIChannel { def fromDomain(channel: Channel): APIChannel = channel.data.into[APIChannel].withFieldConst(_.id, channel.id).transform } - @Semi(JsCodec, JsSchema) final case class CreateChannelRequest( + final case class CreateChannelRequest( urlName: Channel.UrlName, name: Channel.Name, description: Option[Channel.Description] - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class CreateChannelResponse(id: ID[Channel]) + final case class CreateChannelResponse(id: ID[Channel]) derives DefaultJsCodec, JsSchema // TODO: unify behavior (Channel sets UrlName while Post generates it) - @Semi(JsCodec, JsSchema) final case class UpdateChannelRequest( + final case class UpdateChannelRequest( newUrlName: Updatable[Channel.UrlName], newName: Updatable[Channel.Name], newDescription: OptionUpdatable[Channel.Description] - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class UpdateChannelResponse(id: ID[Channel]) + final case class UpdateChannelResponse(id: ID[Channel]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class DeleteChannelResponse(id: ID[Channel]) + final case class DeleteChannelResponse(id: ID[Channel]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class RestoreChannelResponse(id: ID[Channel]) + final case class RestoreChannelResponse(id: ID[Channel]) derives DefaultJsCodec, JsSchema } diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentAPIs.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentAPIs.scala index 65f1794a..ef7c6453 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentAPIs.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentAPIs.scala @@ -1,9 +1,9 @@ package io.branchtalk.discussions.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.discussions.api.CommentModels._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.discussions.api.CommentModels.* import io.branchtalk.discussions.model.{ Channel, Comment, Post } import io.branchtalk.shared.model.{ ID, Updatable } import sttp.model.StatusCode @@ -23,7 +23,7 @@ object CommentAPIs { val newest: AuthedEndpoint[ Option[Authentication], - (ID[Channel], ID[Post], Option[PaginationOffset], Option[PaginationLimit], Option[ID[Comment]]), + (ID[Channel], ID[Post], Option[Pagination.Offset], Option[Pagination.Limit], Option[ID[Comment]]), CommentError, Pagination[APIComment], Any @@ -35,8 +35,8 @@ object CommentAPIs { .get .securityIn(optAuthHeader) .in(prefix / "newest") - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .in(query[Option[ID[Comment]]]("reply-to")) .out(jsonBody[Pagination[APIComment]]) .errorOut(errorMapping) @@ -155,7 +155,7 @@ object CommentAPIs { .out(jsonBody[UpdateCommentResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _, _, _) => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.unwrap))) } val delete: AuthedEndpoint[ @@ -175,7 +175,7 @@ object CommentAPIs { .out(jsonBody[DeleteCommentResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _, _) => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.unwrap))) } val restore: AuthedEndpoint[ @@ -195,7 +195,7 @@ object CommentAPIs { .out(jsonBody[RestoreCommentResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _, _) => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.unwrap))) } val upvote: AuthedEndpoint[ diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentModels.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentModels.scala index b233e82a..bbe552ba 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentModels.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/CommentModels.scala @@ -1,42 +1,28 @@ package io.branchtalk.discussions.api import cats.data.NonEmptyList -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.NonNegative -import io.branchtalk.ADT -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.api.TapirSupport._ +import io.branchtalk.api.JsoniterSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } import io.branchtalk.discussions.model.{ Channel, Comment, Post, User } import io.branchtalk.shared.model.{ ID, Updatable } -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* -@SuppressWarnings(Array("org.wartremover.warts.All")) // for macros object CommentModels { // properties codecs - implicit val commentContentCodec: JsCodec[Comment.Content] = - summonCodec[String](JsonCodecMaker.make).asNewtype[Comment.Content] - implicit val commentRepliesNrCodec: JsCodec[Comment.RepliesNr] = - summonCodec[Int](JsonCodecMaker.make).refine[NonNegative].asNewtype[Comment.RepliesNr] + given JsCodec[Comment.Content] = newtypeCodec + given JsCodec[Comment.RepliesNr] = newtypeCodec - // properties schemas - implicit val commentContentSchema: JsSchema[Comment.Content] = - summonSchema[String].asNewtype[Comment.Content] - implicit val commentRepliesNrSchema: JsSchema[Comment.RepliesNr] = - summonSchema[Int Refined NonNegative].asNewtype[Comment.RepliesNr] - - @Semi(JsCodec, JsSchema) sealed trait CommentError extends ADT + sealed trait CommentError derives DefaultJsCodec, JsSchema object CommentError { - @Semi(JsCodec, JsSchema) final case class BadCredentials(msg: String) extends CommentError - @Semi(JsCodec, JsSchema) final case class NoPermission(msg: String) extends CommentError - @Semi(JsCodec, JsSchema) final case class NotFound(msg: String) extends CommentError - @Semi(JsCodec, JsSchema) final case class ValidationFailed(error: NonEmptyList[String]) extends CommentError + final case class BadCredentials(msg: String) extends CommentError derives DefaultJsCodec, JsSchema + final case class NoPermission(msg: String) extends CommentError derives DefaultJsCodec, JsSchema + final case class NotFound(msg: String) extends CommentError derives DefaultJsCodec, JsSchema + final case class ValidationFailed(error: NonEmptyList[String]) extends CommentError derives DefaultJsCodec, JsSchema } - @Semi(JsCodec, JsSchema) final case class APIComment( + final case class APIComment( id: ID[Comment], authorID: ID[User], channelID: ID[Channel], @@ -44,27 +30,30 @@ object CommentModels { content: Comment.Content, replyTo: Option[ID[Comment]], repliesNr: Comment.RepliesNr - ) + ) derives DefaultJsCodec, + JsSchema object APIComment { def fromDomain(comment: Comment): APIComment = comment.data.into[APIComment].withFieldConst(_.id, comment.id).transform } - @Semi(JsCodec, JsSchema) final case class CreateCommentRequest( + final case class CreateCommentRequest( content: Comment.Content, replyTo: Option[ID[Comment]] - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class CreateCommentResponse(id: ID[Comment]) + final case class CreateCommentResponse(id: ID[Comment]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class UpdateCommentRequest( + final case class UpdateCommentRequest( newContent: Updatable[Comment.Content] - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class UpdateCommentResponse(id: ID[Comment]) + final case class UpdateCommentResponse(id: ID[Comment]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class DeleteCommentResponse(id: ID[Comment]) + final case class DeleteCommentResponse(id: ID[Comment]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class RestoreCommentResponse(id: ID[Comment]) + final case class RestoreCommentResponse(id: ID[Comment]) derives DefaultJsCodec, JsSchema } diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostAPIs.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostAPIs.scala index f83302bd..31a1d51f 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostAPIs.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostAPIs.scala @@ -2,10 +2,10 @@ package io.branchtalk.discussions.api import java.net.URI -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.discussions.api.PostModels._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.discussions.api.PostModels.* import io.branchtalk.discussions.model.{ Channel, Post } import io.branchtalk.shared.model.{ ID, Updatable } import sttp.model.StatusCode @@ -23,7 +23,7 @@ object PostAPIs { val newest: AuthedEndpoint[ Option[Authentication], - (ID[Channel], Option[PaginationOffset], Option[PaginationLimit]), + (ID[Channel], Option[Pagination.Offset], Option[Pagination.Limit]), PostError, Pagination[APIPost], Any @@ -35,8 +35,8 @@ object PostAPIs { .get .securityIn(optAuthHeader) .in(prefix / "newest") - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APIPost]]) .errorOut(errorMapping) .notRequiringPermissions @@ -139,7 +139,7 @@ object PostAPIs { .out(jsonBody[UpdatePostResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _, _) => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.unwrap))) } val delete: AuthedEndpoint[ @@ -159,7 +159,7 @@ object PostAPIs { .out(jsonBody[DeletePostResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _) => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.unwrap))) } val restore: AuthedEndpoint[ @@ -179,7 +179,7 @@ object PostAPIs { .out(jsonBody[RestorePostResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _) => - RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.uuid))) + RequiredPermissions.anyOf(Permission.IsOwner, Permission.ModerateChannel(ChannelID(channelID.unwrap))) } val upvote: AuthedEndpoint[ diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostModels.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostModels.scala index d50a5f24..fdc82670 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostModels.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/PostModels.scala @@ -3,17 +3,12 @@ package io.branchtalk.discussions.api import java.net.URI import cats.data.NonEmptyList -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.numeric.NonNegative -import io.branchtalk.ADT -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.discussions.model._ -import io.branchtalk.shared.model.{ ID, Updatable, discriminatorNameMapper } -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ +import com.github.plokhotnyuk.jsoniter_scala.macros.* +import io.branchtalk.api.JsoniterSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.discussions.model.* +import io.branchtalk.shared.model.* +import io.scalaland.chimney.dsl.* import sttp.tapir.Schema import sttp.tapir.generic.Configuration @@ -24,78 +19,67 @@ import scala.util.Try object PostModels { // properties codecs - implicit val postUrlTitleCodec: JsCodec[Post.UrlTitle] = - summonCodec[String](JsonCodecMaker.make).refine[NonEmpty].asNewtype[Post.UrlTitle] - implicit val postTitleCodec: JsCodec[Post.Title] = - summonCodec[String](JsonCodecMaker.make).refine[NonEmpty].asNewtype[Post.Title] - implicit val postURLCodec: JsCodec[Post.URL] = - summonCodec[String](JsonCodecMaker.make) + given JsCodec[Post.UrlTitle] = newtypeCodec + given JsCodec[Post.Title] = newtypeCodec + given JsCodec[Post.URL] = + DefaultJsCodec + .derived[String] .mapDecode[URI](s => Try(URI.create(s)).fold(_ => Left(s"Invalid URI: $s"), Right(_)))(_.toString) - .asNewtype[Post.URL] - implicit val postTextCodec: JsCodec[Post.Text] = - summonCodec[String](JsonCodecMaker.make).asNewtype[Post.Text] - implicit val postContentCodec: JsCodec[Post.Content] = - summonCodec[Post.Content]( - JsonCodecMaker.make(CodecMakerConfig.withAdtLeafClassNameMapper(discriminatorNameMapper("."))) - ) - implicit val postRepliesNrCodec: JsCodec[Post.CommentsNr] = - summonCodec[Int](JsonCodecMaker.make).refine[NonNegative].asNewtype[Post.CommentsNr] + .asNewtypeCodec[Post.URL] + given JsCodec[Post.Text] = newtypeCodec + given JsCodec[Post.Content] = { + inline given CodecMakerConfig = CodecMakerConfig.withAdtLeafClassNameMapper(adtDiscriminatorNameMapper) + DefaultJsCodec.derived[Post.Content] + } + given JsCodec[Post.CommentsNr] = newtypeCodec // properties schemas - implicit val postUrlTitleSchema: JsSchema[Post.UrlTitle] = - summonSchema[String Refined NonEmpty].asNewtype[Post.UrlTitle] - implicit val postTitleSchema: JsSchema[Post.Title] = - summonSchema[String Refined NonEmpty].asNewtype[Post.Title] - implicit val postURLSchema: JsSchema[Post.URL] = - summonSchema[URI].asNewtype[Post.URL] - implicit val postTextSchema: JsSchema[Post.Text] = - summonSchema[String].asNewtype[Post.Text] - implicit val postContentSchema: JsSchema[Post.Content] = { + given JsSchema[Post.Content] = { // used in macros - @unused implicit val customConfiguration: Configuration = - Configuration.default.copy(toEncodedName = discriminatorNameMapper(".")) + @unused given Configuration = Configuration.default.copy(toEncodedName = adtDiscriminatorNameMapper) Schema.derived[Post.Content] } - implicit val postCommentsNrSchema: JsSchema[Post.CommentsNr] = - summonSchema[Int Refined NonNegative].asNewtype[Post.CommentsNr] - @Semi(JsCodec, JsSchema) sealed trait PostError extends ADT + sealed trait PostError derives DefaultJsCodec, JsSchema object PostError { - @Semi(JsCodec, JsSchema) final case class BadCredentials(msg: String) extends PostError - @Semi(JsCodec, JsSchema) final case class NoPermission(msg: String) extends PostError - @Semi(JsCodec, JsSchema) final case class NotFound(msg: String) extends PostError - @Semi(JsCodec, JsSchema) final case class ValidationFailed(error: NonEmptyList[String]) extends PostError + final case class BadCredentials(msg: String) extends PostError derives DefaultJsCodec, JsSchema + final case class NoPermission(msg: String) extends PostError derives DefaultJsCodec, JsSchema + final case class NotFound(msg: String) extends PostError derives DefaultJsCodec, JsSchema + final case class ValidationFailed(error: NonEmptyList[String]) extends PostError derives DefaultJsCodec, JsSchema } - @Semi(JsCodec, JsSchema) final case class APIPost( + final case class APIPost( id: ID[Post], channelID: ID[Channel], urlTitle: Post.UrlTitle, title: Post.Title, content: Post.Content, commentsNr: Post.CommentsNr - ) + ) derives DefaultJsCodec, + JsSchema object APIPost { def fromDomain(post: Post): APIPost = post.data.into[APIPost].withFieldConst(_.id, post.id).transform } - @Semi(JsCodec, JsSchema) final case class CreatePostRequest( + final case class CreatePostRequest( title: Post.Title, content: Post.Content - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class CreatePostResponse(id: ID[Post]) + final case class CreatePostResponse(id: ID[Post]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class UpdatePostRequest( + final case class UpdatePostRequest( newTitle: Updatable[Post.Title], newContent: Updatable[Post.Content] - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class UpdatePostResponse(id: ID[Post]) + final case class UpdatePostResponse(id: ID[Post]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class DeletePostResponse(id: ID[Post]) + final case class DeletePostResponse(id: ID[Post]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class RestorePostResponse(id: ID[Post]) + final case class RestorePostResponse(id: ID[Post]) derives DefaultJsCodec, JsSchema } diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionAPIs.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionAPIs.scala index 467d88ae..6e6e7bce 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionAPIs.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionAPIs.scala @@ -1,10 +1,10 @@ package io.branchtalk.discussions.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.discussions.api.PostModels._ -import io.branchtalk.discussions.api.SubscriptionModels._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.discussions.api.PostModels.* +import io.branchtalk.discussions.api.SubscriptionModels.* import sttp.model.StatusCode object SubscriptionAPIs { @@ -22,7 +22,7 @@ object SubscriptionAPIs { val newest: AuthedEndpoint[ Option[Authentication], - (Option[PaginationOffset], Option[PaginationLimit]), + (Option[Pagination.Offset], Option[Pagination.Limit]), PostError, Pagination[APIPost], Any @@ -34,8 +34,8 @@ object SubscriptionAPIs { .get .securityIn(optAuthHeader) .in(prefix / "newest") - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APIPost]]) .errorOut(PostAPIs.errorMapping) // an exception in our API .notRequiringPermissions diff --git a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionModels.scala b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionModels.scala index 16f9f5c8..40612021 100644 --- a/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionModels.scala +++ b/modules/discussions-api/src/main/scala/io/branchtalk/discussions/api/SubscriptionModels.scala @@ -1,32 +1,29 @@ package io.branchtalk.discussions.api import cats.data.NonEmptyList -import io.branchtalk.ADT -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.api.TapirSupport._ +import io.branchtalk.api.JsoniterSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } import io.branchtalk.discussions.model.Channel import io.branchtalk.shared.model.ID -import io.scalaland.catnip.Semi -@SuppressWarnings(Array("org.wartremover.warts.All")) // for macros object SubscriptionModels { - @Semi(JsCodec, JsSchema) sealed trait SubscriptionError extends ADT + sealed trait SubscriptionError derives DefaultJsCodec, JsSchema object SubscriptionError { - @Semi(JsCodec, JsSchema) final case class BadCredentials(msg: String) extends SubscriptionError - @Semi(JsCodec, JsSchema) final case class NoPermission(msg: String) extends SubscriptionError - @Semi(JsCodec, JsSchema) final case class NotFound(msg: String) extends SubscriptionError - @Semi(JsCodec, JsSchema) final case class ValidationFailed(error: NonEmptyList[String]) extends SubscriptionError + final case class BadCredentials(msg: String) extends SubscriptionError derives DefaultJsCodec, JsSchema + final case class NoPermission(msg: String) extends SubscriptionError derives DefaultJsCodec, JsSchema + final case class NotFound(msg: String) extends SubscriptionError derives DefaultJsCodec, JsSchema + final case class ValidationFailed(error: NonEmptyList[String]) extends SubscriptionError + derives DefaultJsCodec, + JsSchema } - @Semi(JsCodec, JsSchema) final case class APISubscriptions(channels: List[ID[Channel]]) + final case class APISubscriptions(channels: List[ID[Channel]]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class SubscribeRequest(channels: List[ID[Channel]]) + final case class SubscribeRequest(channels: List[ID[Channel]]) derives DefaultJsCodec, JsSchema + final case class SubscribeResponse(channels: List[ID[Channel]]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class SubscribeResponse(channels: List[ID[Channel]]) - - @Semi(JsCodec, JsSchema) final case class UnsubscribeRequest(channels: List[ID[Channel]]) - - @Semi(JsCodec, JsSchema) final case class UnsubscribeResponse(channels: List[ID[Channel]]) + final case class UnsubscribeRequest(channels: List[ID[Channel]]) derives DefaultJsCodec, JsSchema + final case class UnsubscribeResponse(channels: List[ID[Channel]]) derives DefaultJsCodec, JsSchema } diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/DiscussionsFixtures.scala b/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/DiscussionsFixtures.scala deleted file mode 100644 index eb84d6f2..00000000 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/DiscussionsFixtures.scala +++ /dev/null @@ -1,39 +0,0 @@ -package io.branchtalk.discussions - -import cats.effect.IO -import io.branchtalk.discussions.model._ -import io.branchtalk.shared.model.{ ID, UUIDGenerator } -import io.branchtalk.shared.Fixtures._ - -trait DiscussionsFixtures { - - def editorIDCreate(implicit uuidGenerator: UUIDGenerator): IO[ID[User]] = ID.create[IO, User] - - def subscriberIDCreate(implicit uuidGenerator: UUIDGenerator): IO[ID[User]] = ID.create[IO, User] - - def voterIDCreate(implicit uuidGenerator: UUIDGenerator): IO[ID[User]] = ID.create[IO, User] - - def channelCreate(implicit uuidGenerator: UUIDGenerator): IO[Channel.Create] = - ( - ID.create[IO, User], - noWhitespaces.flatMap(Channel.UrlName.parse[IO]), - nameLike.flatMap(Channel.Name.parse[IO]), - textProducer.map(_.loremIpsum).flatMap(Channel.Description.parse[IO]).map(Option.apply) - ).mapN(Channel.Create.apply) - - def postCreate(channelID: ID[Channel])(implicit uuidGenerator: UUIDGenerator): IO[Post.Create] = - ( - ID.create[IO, User], - channelID.pure[IO], - nameLike.flatMap(Post.Title.parse[IO]), - textProducer.map(_.loremIpsum).map(Post.Text(_)).map(Post.Content.Text(_)) - ).mapN(Post.Create.apply) - - def commentCreate(postID: ID[Post])(implicit uuidGenerator: UUIDGenerator): IO[Comment.Create] = - ( - ID.create[IO, User], - postID.pure[IO], - textProducer.map(_.loremIpsum).map(Comment.Content(_)), - none[ID[Comment]].pure[IO] - ).mapN(Comment.Create.apply) -} diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/DiscussionsModule.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/DiscussionsModule.scala index a8fb06dd..0f4d7f3c 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/DiscussionsModule.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/DiscussionsModule.scala @@ -4,11 +4,11 @@ import cats.data.NonEmptyList import cats.effect.{ Async, Resource } import cats.effect.std.Dispatcher import io.branchtalk.discussions.events.{ DiscussionEvent, DiscussionsCommandEvent } -import io.branchtalk.discussions.reads._ -import io.branchtalk.discussions.writes._ -import io.branchtalk.shared.model._ -import io.branchtalk.shared.infrastructure._ -import com.softwaremill.macwire.wire +import io.branchtalk.discussions.reads.* +import io.branchtalk.discussions.writes.* +import io.branchtalk.logging.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.logging.MDC import io.prometheus.client.CollectorRegistry @@ -30,7 +30,6 @@ final case class DiscussionsWrites[F[_]]( runProjecions: StreamRunner[F] ) -@nowarn("cat=unused") // macwire object DiscussionsModule { private val module = DomainModule[DiscussionEvent, DiscussionsCommandEvent] @@ -39,69 +38,74 @@ object DiscussionsModule { val postgresProjectionName = "postgres-projection" def reads[F[_]: Async]( - domainConfig: DomainConfig, + domainConfig: DomainModule.Config, registry: CollectorRegistry ): Resource[F, DiscussionsReads[F]] = - Logger.getLogger[F].pipe { logger => - Resource.make(logger.info("Initialize Discussions reads"))(_ => logger.info("Shut down Discussions reads")) - } >> - module.setupReads[F](domainConfig, registry).map { case ReadsInfrastructure(transactor, consumer) => - val channelReads: ChannelReads[F] = wire[ChannelReadsImpl[F]] - val postReads: PostReads[F] = wire[PostReadsImpl[F]] - val commentReads: CommentReads[F] = wire[CommentReadsImpl[F]] - val subscriptionReads: SubscriptionReads[F] = wire[SubscriptionReadsImpl[F]] + for { + logger <- Resource.eval(Logger.create[F]) + _ <- Resource.make(logger.info("Initialize Discussions reads"))(_ => logger.info("Shut down Discussions reads")) + case Reads.Infrastructure(transactor, consumer) <- module.setupReads[F](domainConfig, logger, registry) + } yield { + // macwire got removed due to: + // https://github.com/softwaremill/macwire/blob/abf95284e24138a984a06ef4f08d0788af371825/macros/src/main/scala-3/com/softwaremill/macwire/internals/ConstructorCrimper.scala#L91 + val channelReads: ChannelReads[F] = ChannelReadsImpl[F](transactor) + val postReads: PostReads[F] = PostReadsImpl[F](transactor) + val commentReads: CommentReads[F] = CommentReadsImpl[F](transactor) + val subscriptionReads: SubscriptionReads[F] = SubscriptionReadsImpl[F](transactor) - wire[DiscussionsReads[F]] - } + DiscussionsReads(channelReads, postReads, commentReads, subscriptionReads, consumer) + } def writes[F[_]: Async: Dispatcher: MDC]( - domainConfig: DomainConfig, - registry: CollectorRegistry - )(implicit uuidGenerator: UUIDGenerator): Resource[F, DiscussionsWrites[F]] = - Logger.getLogger[F].pipe { logger => - Resource.make(logger.info("Initialize Discussions writes"))(_ => logger.info("Shut down Discussions writes")) >> - module.setupWrites[F](domainConfig, registry).map { - case WritesInfrastructure(transactor, - internalProducer, - internalConsumerStream, - producer, - consumerStream, - cache - ) => - val channelWrites: ChannelWrites[F] = wire[ChannelWritesImpl[F]] - val postWrites: PostWrites[F] = wire[PostWritesImpl[F]] - val commentWrites: CommentWrites[F] = wire[CommentWritesImpl[F]] - val subscriptionWrites: SubscriptionWrites[F] = wire[SubscriptionWritesImpl[F]] + domainConfig: DomainModule.Config, + registry: CollectorRegistry + )(using UUID.Generator): Resource[F, DiscussionsWrites[F]] = + for { + logger <- Resource.eval(Logger.create[F]) + _ <- Resource.make(logger.info("Initialize Discussions writes"))(_ => logger.info("Shut down Discussions writes")) + case Writes.Infrastructure(transactor, + internalProducer, + internalConsumerStream, + producer, + consumerStream, + cache + ) <- module.setupWrites[F](domainConfig, logger, registry) + } yield { + // macwire got removed due to: + // https://github.com/softwaremill/macwire/blob/abf95284e24138a984a06ef4f08d0788af371825/macros/src/main/scala-3/com/softwaremill/macwire/internals/ConstructorCrimper.scala#L91 + val channelWrites: ChannelWrites[F] = ChannelWritesImpl[F](internalProducer, transactor) + val postWrites: PostWrites[F] = PostWritesImpl[F](internalProducer, transactor) + val commentWrites: CommentWrites[F] = CommentWritesImpl[F](internalProducer, transactor) + val subscriptionWrites: SubscriptionWrites[F] = SubscriptionWritesImpl[F](internalProducer, transactor) - val commandHandler: Projector[F, DiscussionsCommandEvent, (UUID, DiscussionEvent)] = NonEmptyList - .of( - wire[ChannelCommandHandler[F]], - wire[PostCommandHandler[F]], - wire[CommentCommandHandler[F]], - wire[SubscriptionCommandHandler[F]] - ) - .reduce - val postgresProjector: Projector[F, DiscussionEvent, (UUID, DiscussionEvent)] = NonEmptyList - .of( - wire[ChannelPostgresProjector[F]], - wire[CommentPostgresProjector[F]], - wire[PostPostgresProjector[F]], - wire[SubscriptionPostgresProjector[F]] - ) - .reduce - val runProjector: StreamRunner[F] = { - val runCommandProjector: StreamRunner[F] = - internalConsumerStream.runCachedThrough(logger, cache)( - ConsumerStream.noID.andThen(commandHandler).andThen(producer).andThen(ConsumerStream.produced) - ) - val runPostgresProjector: StreamRunner[F] = - consumerStream(domainConfig.consumers(postgresProjectionName)).runCachedThrough(logger, cache)( - ConsumerStream.noID.andThen(postgresProjector).andThen(ConsumerStream.noID) - ) - runCommandProjector |+| runPostgresProjector - } + val commandHandler: Projector[F, DiscussionsCommandEvent, (UUID, DiscussionEvent)] = NonEmptyList + .of( + ChannelCommandHandler[F], + PostCommandHandler[F], + CommentCommandHandler[F], + SubscriptionCommandHandler[F] + ) + .reduce + val postgresProjector: Projector[F, DiscussionEvent, (UUID, DiscussionEvent)] = NonEmptyList + .of( + ChannelPostgresProjector[F](transactor), + CommentPostgresProjector[F](transactor), + PostPostgresProjector[F](transactor), + SubscriptionPostgresProjector[F](transactor) + ) + .reduce + val runProjector: StreamRunner[F] = { + val runCommandProjector: StreamRunner[F] = + internalConsumerStream.runCachedThrough(logger, cache)( + ConsumerStream.noID.andThen(commandHandler).andThen(producer).andThen(ConsumerStream.produced) + ) + val runPostgresProjector: StreamRunner[F] = + consumerStream(domainConfig.consumers(postgresProjectionName)).runCachedThrough(logger, cache)( + ConsumerStream.noID.andThen(postgresProjector).andThen(ConsumerStream.noID) + ) + runCommandProjector |+| runPostgresProjector + } - wire[DiscussionsWrites[F]] - } + DiscussionsWrites(commentWrites, postWrites, channelWrites, subscriptionWrites, runProjector) } } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/ChannelCommandEvent.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/ChannelCommandEvent.scala index 8351c1af..85e413f6 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/ChannelCommandEvent.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/ChannelCommandEvent.scala @@ -1,17 +1,15 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.scalaland.catnip.Semi -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.discussions.model.{ Channel, User } import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait ChannelCommandEvent extends ADT +sealed trait ChannelCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object ChannelCommandEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Create( + final case class Create( id: ID[Channel], authorID: ID[User], urlName: Channel.UrlName, @@ -19,9 +17,9 @@ object ChannelCommandEvent { description: Option[Channel.Description], createdAt: CreationTime, correlationID: CorrelationID - ) extends ChannelCommandEvent + ) extends ChannelCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Update( + final case class Update( id: ID[Channel], editorID: ID[User], newUrlName: Updatable[Channel.UrlName], @@ -29,17 +27,17 @@ object ChannelCommandEvent { newDescription: OptionUpdatable[Channel.Description], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends ChannelCommandEvent + ) extends ChannelCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Delete( + final case class Delete( id: ID[Channel], editorID: ID[User], correlationID: CorrelationID - ) extends ChannelCommandEvent + ) extends ChannelCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Restore( + final case class Restore( id: ID[Channel], editorID: ID[User], correlationID: CorrelationID - ) extends ChannelCommandEvent + ) extends ChannelCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/CommentCommandEvent.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/CommentCommandEvent.scala index 23a27b57..b5577d9f 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/CommentCommandEvent.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/CommentCommandEvent.scala @@ -1,17 +1,15 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.scalaland.catnip.Semi -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.discussions.model.{ Channel, Comment, Post, User } import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait CommentCommandEvent extends ADT +sealed trait CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object CommentCommandEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Create( + final case class Create( id: ID[Comment], authorID: ID[User], channelID: ID[Channel], @@ -20,43 +18,43 @@ object CommentCommandEvent { replyTo: Option[ID[Comment]], createdAt: CreationTime, correlationID: CorrelationID - ) extends CommentCommandEvent + ) extends CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Update( + final case class Update( id: ID[Comment], editorID: ID[User], newContent: Updatable[Comment.Content], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends CommentCommandEvent + ) extends CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Delete( + final case class Delete( id: ID[Comment], editorID: ID[User], correlationID: CorrelationID - ) extends CommentCommandEvent + ) extends CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Restore( + final case class Restore( id: ID[Comment], editorID: ID[User], correlationID: CorrelationID - ) extends CommentCommandEvent + ) extends CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Upvote( + final case class Upvote( id: ID[Comment], voterID: ID[User], correlationID: CorrelationID - ) extends CommentCommandEvent + ) extends CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Downvote( + final case class Downvote( id: ID[Comment], voterID: ID[User], correlationID: CorrelationID - ) extends CommentCommandEvent + ) extends CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class RevokeVote( + final case class RevokeVote( id: ID[Comment], voterID: ID[User], correlationID: CorrelationID - ) extends CommentCommandEvent + ) extends CommentCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/DiscussionsCommandEvent.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/DiscussionsCommandEvent.scala index 5c54fd43..2edb6da2 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/DiscussionsCommandEvent.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/DiscussionsCommandEvent.scala @@ -1,15 +1,33 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.scalaland.catnip.Semi -import io.branchtalk.ADT -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import com.sksamuel.avro4s.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, ShowPretty, SchemaFor) sealed trait DiscussionsCommandEvent extends ADT +sealed trait DiscussionsCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object DiscussionsCommandEvent { final case class ForChannel(channel: ChannelCommandEvent) extends DiscussionsCommandEvent + derives Decoder, + Encoder, + FastEq, + ShowPretty, + SchemaFor final case class ForComment(comment: CommentCommandEvent) extends DiscussionsCommandEvent + derives Decoder, + Encoder, + FastEq, + ShowPretty, + SchemaFor final case class ForPost(post: PostCommandEvent) extends DiscussionsCommandEvent + derives Decoder, + Encoder, + FastEq, + ShowPretty, + SchemaFor final case class ForSubscription(subscription: SubscriptionCommandEvent) extends DiscussionsCommandEvent + derives Decoder, + Encoder, + FastEq, + ShowPretty, + SchemaFor } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/PostCommandEvent.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/PostCommandEvent.scala index 93da777f..222100b0 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/PostCommandEvent.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/PostCommandEvent.scala @@ -1,17 +1,15 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.scalaland.catnip.Semi -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.discussions.model.{ Channel, Post, User } import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait PostCommandEvent extends ADT +sealed trait PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object PostCommandEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Create( + final case class Create( id: ID[Post], authorID: ID[User], channelID: ID[Channel], @@ -20,9 +18,9 @@ object PostCommandEvent { content: Post.Content, createdAt: CreationTime, correlationID: CorrelationID - ) extends PostCommandEvent + ) extends PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Update( + final case class Update( id: ID[Post], editorID: ID[User], newUrlTitle: Updatable[Post.UrlTitle], @@ -30,35 +28,35 @@ object PostCommandEvent { newContent: Updatable[Post.Content], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends PostCommandEvent + ) extends PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Delete( + final case class Delete( id: ID[Post], editorID: ID[User], correlationID: CorrelationID - ) extends PostCommandEvent + ) extends PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Restore( + final case class Restore( id: ID[Post], editorID: ID[User], correlationID: CorrelationID - ) extends PostCommandEvent + ) extends PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Upvote( + final case class Upvote( id: ID[Post], voterID: ID[User], correlationID: CorrelationID - ) extends PostCommandEvent + ) extends PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Downvote( + final case class Downvote( id: ID[Post], voterID: ID[User], correlationID: CorrelationID - ) extends PostCommandEvent + ) extends PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class RevokeVote( + final case class RevokeVote( id: ID[Post], voterID: ID[User], correlationID: CorrelationID - ) extends PostCommandEvent + ) extends PostCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/SubscriptionCommandEvent.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/SubscriptionCommandEvent.scala index d931fb23..eb9cbe33 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/SubscriptionCommandEvent.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/events/SubscriptionCommandEvent.scala @@ -1,27 +1,25 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT -import io.branchtalk.discussions.model._ +import com.sksamuel.avro4s.* +import io.branchtalk.discussions.model.* import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ -import io.scalaland.catnip.Semi +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait SubscriptionCommandEvent extends ADT +sealed trait SubscriptionCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object SubscriptionCommandEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Subscribe( + final case class Subscribe( subscriberID: ID[User], subscriptions: Set[ID[Channel]], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends SubscriptionCommandEvent + ) extends SubscriptionCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Unsubscribe( + final case class Unsubscribe( subscriberID: ID[User], subscriptions: Set[ID[Channel]], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends SubscriptionCommandEvent + ) extends SubscriptionCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/infrastructure/DoobieExtensions.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/infrastructure/DoobieExtensions.scala index a064fe66..50d8192b 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/infrastructure/DoobieExtensions.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/infrastructure/DoobieExtensions.scala @@ -1,14 +1,24 @@ package io.branchtalk.discussions.infrastructure +import cats.Show import io.branchtalk.discussions.model.{ Post, Vote } -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.model.branchtalkLocale object DoobieExtensions { - implicit val postContentTypeMeta: Meta[Post.Content.Type] = - pgEnumString("post_content_type", Post.Content.Type.withNameInsensitive, _.entryName.toLowerCase(branchtalkLocale)) + private given [A: Show]: Show[Array[A]] = _.map(_.show).mkString(", ") - implicit val voteTypeMeta: Meta[Vote.Type] = + @SuppressWarnings(Array("org.wartremover.warts.Throw")) + given postContentTypeMeta: Meta[Post.Content.Type] = pgEnumString( + "post_content_type", + name => + Post.Content.Type.values + .find(_.entryName.equalsIgnoreCase(name)) + .getOrElse(throw new NoSuchElementException(show"$name is not a member of Enum (${Post.Content.Type.values})")), + _.entryName.toLowerCase(branchtalkLocale) + ) + + given voteTypeMeta: Meta[Vote.Type] = pgEnumString("vote_type", Vote.Type.withNameInsensitive, _.entryName.toLowerCase(branchtalkLocale)) } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/model/PostDao.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/model/PostDao.scala index b058c9de..bc91dc80 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/model/PostDao.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/model/PostDao.scala @@ -1,7 +1,7 @@ package io.branchtalk.discussions.model import io.branchtalk.shared.model.{ CreationTime, ID, ModificationTime } -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final case class PostDao( id: ID[Post], diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/ChannelReadsImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/ChannelReadsImpl.scala index 8d0366a3..497b8009 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/ChannelReadsImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/ChannelReadsImpl.scala @@ -1,16 +1,12 @@ package io.branchtalk.discussions.reads import cats.effect.Sync -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } import io.branchtalk.discussions.model.Channel -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* final class ChannelReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends ChannelReads[F] { - implicit private val logHandler: LogHandler = doobieLogger(getClass) - private val commonSelect: Fragment = fr"""SELECT id, | url_name, @@ -25,34 +21,41 @@ final class ChannelReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends Chan case Channel.Sorting.Alphabetically => fr"ORDER BY name ASC" } - private def idExists(id: ID[Channel]): Fragment = fr"id = ${id} AND deleted = FALSE" + private def idExists(id: ID[Channel]): Fragment = fr"id = $id AND deleted = FALSE" - private def idDeleted(id: ID[Channel]): Fragment = fr"id = ${id} AND deleted = TRUE" + private def idDeleted(id: ID[Channel]): Fragment = fr"id = $id AND deleted = TRUE" override def paginate( sortBy: Channel.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Channel]] = (commonSelect ++ Fragments.whereAnd(fr"deleted = FALSE") ++ orderBy(sortBy)) - .paginate[Channel](offset, limit) + .paginate[Channel](offset, + limit, + show"Paginate Discussions' Channel from $offset taking $limit sorted by $sortBy" + ) .transact(transactor) override def exists(id: ID[Channel]): F[Boolean] = - (fr"SELECT 1 FROM channels WHERE" ++ idExists(id)).exists.transact(transactor) + (fr"SELECT 1 FROM channels WHERE" ++ idExists(id)) + .exists(show"Discussions' Channel ID=$id exists") + .transact(transactor) override def deleted(id: ID[Channel]): F[Boolean] = - (fr"SELECT 1 FROM channels WHERE" ++ idDeleted(id)).exists.transact(transactor) + (fr"SELECT 1 FROM channels WHERE" ++ idDeleted(id)) + .exists(show"Discussions' Channel ID=$id deleted") + .transact(transactor) override def getById(id: ID[Channel], isDeleted: Boolean = false): F[Option[Channel]] = (commonSelect ++ fr"WHERE" ++ (if (isDeleted) idDeleted(id) else idExists(id))) - .query[Channel] + .queryWithLabel[Channel](show"Get Discussions' Channel by ID=$id") .option .transact(transactor) override def requireById(id: ID[Channel], isDeleted: Boolean = false): F[Channel] = (commonSelect ++ fr"WHERE" ++ (if (isDeleted) idDeleted(id) else idExists(id))) - .query[Channel] + .queryWithLabel[Channel](show"Require Discussions' Channel by ID=$id") .failNotFound("User", id) .transact(transactor) } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/CommentReadsImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/CommentReadsImpl.scala index 72338c3a..0c75091f 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/CommentReadsImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/CommentReadsImpl.scala @@ -1,16 +1,12 @@ package io.branchtalk.discussions.reads import cats.effect.Sync -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } import io.branchtalk.discussions.model.{ Comment, Post } -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.model.{ ID, Paginated } final class CommentReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends CommentReads[F] { - implicit private val logHandler: LogHandler = doobieLogger(getClass) - private val commonSelect: Fragment = fr"""SELECT id, | author_id, @@ -34,37 +30,46 @@ final class CommentReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends Comm case Comment.Sorting.Controversial => fr"ORDER by controversial_score DESC" } - private def idExists(id: ID[Comment]): Fragment = fr"id = ${id} AND deleted = FALSE" + private def idExists(id: ID[Comment]): Fragment = fr"id = $id AND deleted = FALSE" - private def idDeleted(id: ID[Comment]): Fragment = fr"id = ${id} AND deleted = TRUE" + private def idDeleted(id: ID[Comment]): Fragment = fr"id = $id AND deleted = TRUE" override def paginate( post: ID[Post], repliesTo: Option[ID[Comment]], sortBy: Comment.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Comment]] = (commonSelect ++ Fragments.whereAndOpt(fr"post_id = $post".some, repliesTo.map(parent => fr"reply_to = $parent"), fr"deleted = FALSE".some - ) ++ orderBy(sortBy)).paginate[Comment](offset, limit).transact(transactor) + ) ++ orderBy(sortBy)) + .paginate[Comment](offset, + limit, + show"Paginate Discussions' Comment from $offset taking $limit sorted by $sortBy" + ) + .transact(transactor) override def exists(id: ID[Comment]): F[Boolean] = - (fr"SELECT 1 FROM comments WHERE" ++ idExists(id)).exists.transact(transactor) + (fr"SELECT 1 FROM comments WHERE" ++ idExists(id)) + .exists(show"Discussions' Comment ID=$id exists") + .transact(transactor) override def deleted(id: ID[Comment]): F[Boolean] = - (fr"SELECT 1 FROM comments WHERE" ++ idDeleted(id)).exists.transact(transactor) + (fr"SELECT 1 FROM comments WHERE" ++ idDeleted(id)) + .exists(show"Discussions' Comment ID=$id deleted") + .transact(transactor) override def getById(id: ID[Comment], isDeleted: Boolean = false): F[Option[Comment]] = (commonSelect ++ fr"WHERE" ++ (if (isDeleted) idDeleted(id) else idExists(id))) - .query[Comment] + .queryWithLabel[Comment](show"Get Discussions' Comment by ID=$id") .option .transact(transactor) override def requireById(id: ID[Comment], isDeleted: Boolean = false): F[Comment] = (commonSelect ++ fr"WHERE" ++ (if (isDeleted) idDeleted(id) else idExists(id))) - .query[Comment] + .queryWithLabel[Comment](show"Require Discussions' Comment by ID=$id") .failNotFound("Comment", id) .transact(transactor) } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/PostReadsImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/PostReadsImpl.scala index 85b312ce..563ec9af 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/PostReadsImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/PostReadsImpl.scala @@ -2,17 +2,13 @@ package io.branchtalk.discussions.reads import cats.data.NonEmptySet import cats.effect.Sync -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } -import io.branchtalk.discussions.infrastructure.DoobieExtensions._ +import io.branchtalk.discussions.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.discussions.model.{ Channel, Post, PostDao } -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.model.{ ID, Paginated } final class PostReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends PostReads[F] { - implicit private val logHandler: LogHandler = doobieLogger(getClass) - private val commonSelect: Fragment = fr"""SELECT id, | author_id, @@ -36,37 +32,39 @@ final class PostReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends PostRea case Post.Sorting.Controversial => fr"ORDER by controversial_score DESC" } - private def idExists(id: ID[Post]): Fragment = fr"id = ${id} AND deleted = FALSE" + private def idExists(id: ID[Post]): Fragment = fr"id = $id AND deleted = FALSE" - private def idDeleted(id: ID[Post]): Fragment = fr"id = ${id} AND deleted = TRUE" + private def idDeleted(id: ID[Post]): Fragment = fr"id = $id AND deleted = TRUE" override def paginate( channels: NonEmptySet[ID[Channel]], sortBy: Post.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Post]] = - (commonSelect ++ Fragments.whereAnd(Fragments.in(fr"channel_id", channels), fr"deleted = FALSE") ++ orderBy(sortBy)) - .paginate[PostDao](offset, limit) + (commonSelect ++ Fragments.whereAnd(Fragments.in(fr"channel_id", channels.toNonEmptyList), + fr"deleted = FALSE" + ) ++ orderBy(sortBy)) + .paginate[PostDao](offset, limit, show"Paginate Discussions' Post from $offset taking $limit sorted by $sortBy") .map(_.map(_.toDomain)) .transact(transactor) override def exists(id: ID[Post]): F[Boolean] = - (fr"SELECT 1 FROM posts WHERE" ++ idExists(id)).exists.transact(transactor) + (fr"SELECT 1 FROM posts WHERE" ++ idExists(id)).exists(show"Discussions' Post $id exists").transact(transactor) override def deleted(id: ID[Post]): F[Boolean] = - (fr"SELECT 1 FROM posts WHERE" ++ idDeleted(id)).exists.transact(transactor) + (fr"SELECT 1 FROM posts WHERE" ++ idDeleted(id)).exists(show"Discussions' Post ID=$id deleted").transact(transactor) override def getById(id: ID[Post], isDeleted: Boolean = false): F[Option[Post]] = (commonSelect ++ fr"WHERE" ++ (if (isDeleted) idDeleted(id) else idExists(id))) - .query[PostDao] + .queryWithLabel[PostDao](show"Get Discussions' Post by ID=$id") .map(_.toDomain) .option .transact(transactor) override def requireById(id: ID[Post], isDeleted: Boolean = false): F[Post] = (commonSelect ++ fr"WHERE" ++ (if (isDeleted) idDeleted(id) else idExists(id))) - .query[PostDao] + .queryWithLabel[PostDao](show"Require Discussions' Post by ID=$id") .map(_.toDomain) .failNotFound("Post", id) .transact(transactor) diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/SubscriptionReadsImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/SubscriptionReadsImpl.scala index c00ab9a0..e875851a 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/SubscriptionReadsImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/reads/SubscriptionReadsImpl.scala @@ -2,21 +2,19 @@ package io.branchtalk.discussions.reads import cats.effect.Sync import io.branchtalk.discussions.model.{ Subscription, User } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* final class SubscriptionReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends SubscriptionReads[F] { - implicit private val logHandler: LogHandler = doobieLogger(getClass) - private val commonSelect: Fragment = fr"""SELECT subscriber_id, | subscriptions_ids |FROM subscriptions""".stripMargin override def requireForUser(userID: ID[User]): F[Subscription] = - (commonSelect ++ fr"WHERE subscriber_id = ${userID}") - .query[Subscription] + (commonSelect ++ fr"WHERE subscriber_id = $userID") + .queryWithLabel[Subscription](show"Require Discussions' Subscription for User=$userID") .option .map(_.getOrElse(Subscription(userID, Set.empty))) .transact(transactor) diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelCommandHandler.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelCommandHandler.scala index c59dfb8d..4469da77 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelCommandHandler.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelCommandHandler.scala @@ -6,7 +6,7 @@ import fs2.Stream import io.branchtalk.discussions.events.{ ChannelCommandEvent, ChannelEvent, DiscussionEvent, DiscussionsCommandEvent } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class ChannelCommandHandler[F[_]: Sync] extends Projector[F, DiscussionsCommandEvent, (UUID, DiscussionEvent)] { @@ -28,14 +28,14 @@ final class ChannelCommandHandler[F[_]: Sync] extends Projector[F, DiscussionsCo } def toCreate(command: ChannelCommandEvent.Create): F[(UUID, ChannelEvent.Created)] = - (command.id.uuid -> command.transformInto[ChannelEvent.Created]).pure[F] + (command.id.unwrap -> command.transformInto[ChannelEvent.Created]).pure[F] def toUpdate(command: ChannelCommandEvent.Update): F[(UUID, ChannelEvent.Updated)] = - (command.id.uuid -> command.transformInto[ChannelEvent.Updated]).pure[F] + (command.id.unwrap -> command.transformInto[ChannelEvent.Updated]).pure[F] def toDelete(command: ChannelCommandEvent.Delete): F[(UUID, ChannelEvent.Deleted)] = - (command.id.uuid -> command.transformInto[ChannelEvent.Deleted]).pure[F] + (command.id.unwrap -> command.transformInto[ChannelEvent.Deleted]).pure[F] def toRestore(command: ChannelCommandEvent.Restore): F[(UUID, ChannelEvent.Restored)] = - (command.id.uuid -> command.transformInto[ChannelEvent.Restored]).pure[F] + (command.id.unwrap -> command.transformInto[ChannelEvent.Restored]).pure[F] } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelPostgresProjector.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelPostgresProjector.scala index f7608237..7939c33f 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelPostgresProjector.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelPostgresProjector.scala @@ -6,7 +6,7 @@ import com.typesafe.scalalogging.Logger import fs2.Stream import io.branchtalk.discussions.events.{ ChannelEvent, DiscussionEvent } import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID @@ -15,8 +15,6 @@ final class ChannelPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) private val logger = Logger(getClass) - implicit private val logHandler: LogHandler = doobieLogger(getClass) - override def apply(in: Stream[F, DiscussionEvent]): Stream[F, (UUID, DiscussionEvent)] = in.collect { case DiscussionEvent.ForChannel(event) => event @@ -48,7 +46,7 @@ final class ChannelPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) | ${event.description}, | ${event.createdAt} |) - |ON CONFLICT (id) DO NOTHING""".stripMargin.update.run.as(event.id.uuid -> event).transact(transactor) + |ON CONFLICT (id) DO NOTHING""".stripMargin.update.run.as(event.id.unwrap -> event).transact(transactor) } def toUpdate(event: ChannelEvent.Updated): F[(UUID, ChannelEvent.Updated)] = @@ -69,21 +67,21 @@ final class ChannelPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) (updates :+ fr"last_modified_at = ${event.modifiedAt}").intercalate(fr",") ++ fr"WHERE id = ${event.id}").update.run.void ) - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } def toDelete(event: ChannelEvent.Deleted): F[(UUID, ChannelEvent.Deleted)] = withCorrelationID(event.correlationID) { sql"UPDATE channels SET deleted = TRUE WHERE id = ${event.id}".update.run - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } def toRestore(event: ChannelEvent.Restored): F[(UUID, ChannelEvent.Restored)] = withCorrelationID(event.correlationID) { sql"UPDATE channels SET deleted = FALSE WHERE id = ${event.id}".update.run - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelWritesImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelWritesImpl.scala index 7b4de688..257c2405 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelWritesImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/ChannelWritesImpl.scala @@ -4,18 +4,17 @@ import cats.effect.Sync import io.branchtalk.discussions.events.{ ChannelCommandEvent, DiscussionsCommandEvent } import io.branchtalk.discussions.model.Channel import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.infrastructure.{ EventBusProducer, Writes } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model._ -import io.scalaland.chimney.dsl._ +import io.branchtalk.shared.infrastructure.{ KafkaEventBus, Writes } +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* +import io.scalaland.chimney.dsl.* final class ChannelWritesImpl[F[_]: Sync: MDC]( - producer: EventBusProducer[F, DiscussionsCommandEvent], + producer: KafkaEventBus.Producer[F, DiscussionsCommandEvent], transactor: Transactor[F] -)(implicit - uuidGenerator: UUIDGenerator -) extends Writes[F, Channel, DiscussionsCommandEvent](producer) - with ChannelWrites[F] { +)(using UUID.Generator) + extends Writes[F, Channel, DiscussionsCommandEvent](producer), + ChannelWrites[F] { private val channelCheck = new EntityCheck("Channel", transactor) @@ -36,7 +35,7 @@ final class ChannelWritesImpl[F[_]: Sync: MDC]( override def updateChannel(updatedChannel: Channel.Update): F[UpdateScheduled[Channel]] = for { id <- updatedChannel.id.pure[F] - _ <- channelCheck(id, sql"""SELECT 1 FROM channels WHERE id = ${id} AND deleted = FALSE""") + _ <- channelCheck(id, sql"""SELECT 1 FROM channels WHERE id = $id AND deleted = FALSE""") now <- ModificationTime.now[F] correlationID <- CorrelationID.getCurrentOrGenerate[F] command = updatedChannel @@ -51,7 +50,7 @@ final class ChannelWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = deletedChannel.id - _ <- channelCheck(id, sql"""SELECT 1 FROM channels WHERE id = ${id} AND deleted = FALSE""") + _ <- channelCheck(id, sql"""SELECT 1 FROM channels WHERE id = $id AND deleted = FALSE""") command = deletedChannel.into[ChannelCommandEvent.Delete].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForChannel(command)) } yield DeletionScheduled(id) @@ -60,7 +59,7 @@ final class ChannelWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = restoredChannel.id - _ <- channelCheck(id, sql"""SELECT 1 FROM channels WHERE id = ${id} AND deleted = TRUE""") + _ <- channelCheck(id, sql"""SELECT 1 FROM channels WHERE id = $id AND deleted = TRUE""") command = restoredChannel .into[ChannelCommandEvent.Restore] .withFieldConst(_.correlationID, correlationID) diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentCommandHandler.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentCommandHandler.scala index 499b1b6f..1e0c2361 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentCommandHandler.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentCommandHandler.scala @@ -6,7 +6,7 @@ import fs2.Stream import io.branchtalk.discussions.events.{ CommentCommandEvent, CommentEvent, DiscussionEvent, DiscussionsCommandEvent } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class CommentCommandHandler[F[_]: Sync] extends Projector[F, DiscussionsCommandEvent, (UUID, DiscussionEvent)] { @@ -31,23 +31,23 @@ final class CommentCommandHandler[F[_]: Sync] extends Projector[F, DiscussionsCo } def toCreate(command: CommentCommandEvent.Create): F[(UUID, CommentEvent.Created)] = - (command.id.uuid -> command.transformInto[CommentEvent.Created]).pure[F] + (command.id.unwrap -> command.transformInto[CommentEvent.Created]).pure[F] def toUpdate(command: CommentCommandEvent.Update): F[(UUID, CommentEvent.Updated)] = - (command.id.uuid -> command.transformInto[CommentEvent.Updated]).pure[F] + (command.id.unwrap -> command.transformInto[CommentEvent.Updated]).pure[F] def toDelete(command: CommentCommandEvent.Delete): F[(UUID, CommentEvent.Deleted)] = - (command.id.uuid -> command.transformInto[CommentEvent.Deleted]).pure[F] + (command.id.unwrap -> command.transformInto[CommentEvent.Deleted]).pure[F] def toRestore(command: CommentCommandEvent.Restore): F[(UUID, CommentEvent.Restored)] = - (command.id.uuid -> command.transformInto[CommentEvent.Restored]).pure[F] + (command.id.unwrap -> command.transformInto[CommentEvent.Restored]).pure[F] def toUpvote(command: CommentCommandEvent.Upvote): F[(UUID, CommentEvent.Upvoted)] = - (command.id.uuid -> command.transformInto[CommentEvent.Upvoted]).pure[F] + (command.id.unwrap -> command.transformInto[CommentEvent.Upvoted]).pure[F] def toDownvote(command: CommentCommandEvent.Downvote): F[(UUID, CommentEvent.Downvoted)] = - (command.id.uuid -> command.transformInto[CommentEvent.Downvoted]).pure[F] + (command.id.unwrap -> command.transformInto[CommentEvent.Downvoted]).pure[F] def toRevokeVote(command: CommentCommandEvent.RevokeVote): F[(UUID, CommentEvent.VoteRevoked)] = - (command.id.uuid -> command.transformInto[CommentEvent.VoteRevoked]).pure[F] + (command.id.unwrap -> command.transformInto[CommentEvent.VoteRevoked]).pure[F] } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentPostgresProjector.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentPostgresProjector.scala index af55c891..8734be4a 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentPostgresProjector.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentPostgresProjector.scala @@ -6,10 +6,10 @@ import com.typesafe.scalalogging.Logger import doobie.Transactor import fs2.Stream import io.branchtalk.discussions.events.{ CommentEvent, DiscussionEvent } -import io.branchtalk.discussions.infrastructure.DoobieExtensions._ +import io.branchtalk.discussions.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.discussions.model.{ Comment, User, Vote } import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.{ ID, UUID } @@ -18,8 +18,6 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) private val logger = Logger(getClass) - implicit private val logHandler: LogHandler = doobieLogger(getClass) - override def apply(in: Stream[F, DiscussionEvent]): Stream[F, (UUID, DiscussionEvent)] = in.collect { case DiscussionEvent.ForComment(event) => event @@ -44,7 +42,10 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) .fold(0.pure[ConnectionIO]) { replyId => sql"""SELECT nesting_level + 1 |FROM comments - |WHERE id = ${replyId}""".stripMargin.query[Int].option.map(_.getOrElse(0)) + |WHERE id = $replyId""".stripMargin + .queryWithLabel[Int](show"Get nesting level new Discussions' Comment ID=$replyId") + .option + .map(_.getOrElse(0)) } .flatMap { nestingLevel => sql"""INSERT INTO comments ( @@ -77,7 +78,7 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |WHERE id = ${event.replyTo} |""".stripMargin.update.run } - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } @@ -96,7 +97,7 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) Sync[ConnectionIO].delay( logger.warn(show"Comment update ignored as it doesn't contain any modification:\n$event") ) - }).as(event.id.uuid -> event).transact(transactor) + }).as(event.id.unwrap -> event).transact(transactor) } def toDelete(event: CommentEvent.Deleted): F[(UUID, CommentEvent.Deleted)] = @@ -111,7 +112,7 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |SET replies_nr = replies_nr - 1 |FROM (SELECT reply_to FROM comments WHERE id = ${event.id}) as subquery |WHERE id = subquery.reply_to - |""".stripMargin.update.run).as(event.id.uuid -> event).transact(transactor) + |""".stripMargin.update.run).as(event.id.unwrap -> event).transact(transactor) } def toRestore(event: CommentEvent.Restored): F[(UUID, CommentEvent.Restored)] = @@ -126,17 +127,17 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |SET replies_nr = replies_nr + 1 |FROM (SELECT reply_to FROM comments WHERE id = ${event.id}) as subquery |WHERE id = subquery.reply_to - |""".stripMargin.update.run).as(event.id.uuid -> event).transact(transactor) + |""".stripMargin.update.run).as(event.id.unwrap -> event).transact(transactor) } private def fetchVote(commentID: ID[Comment], voterID: ID[User]) = - sql"SELECT vote FROM comment_votes WHERE comment_id = ${commentID} AND voter_id = ${voterID}" - .query[Vote.Type] + sql"SELECT vote FROM comment_votes WHERE comment_id = $commentID AND voter_id = $voterID" + .queryWithLabel[Vote.Type](show"Get Discussions' Vote for Comment=$commentID AND Voter=$voterID") .option private def updateCommentVotes(commentID: ID[Comment], upvotes: Fragment, downvotes: Fragment) = (fr"WITH nw AS (SELECT" ++ upvotes ++ fr"AS upvotes," - ++ downvotes ++ fr"AS downvotes FROM comments WHERE id = ${commentID})" ++ + ++ downvotes ++ fr"AS downvotes FROM comments WHERE id = $commentID)" ++ fr""" |UPDATE comments |SET upvotes_nr = nw.upvotes, @@ -144,7 +145,7 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) | total_score = nw.upvotes - nw.downvotes, | controversial_score = LEAST(nw.upvotes, nw.downvotes) |FROM nw - |WHERE id = ${commentID}""".stripMargin).update.run + |WHERE id = $commentID""".stripMargin).update.run def toUpvote(event: CommentEvent.Upvoted): F[(UUID, CommentEvent.Upvoted)] = withCorrelationID(event.correlationID) { @@ -173,7 +174,7 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |)""".stripMargin.update.run >> updateCommentVotes(event.id, fr"upvotes_nr + 1", fr"downvotes_nr").void } - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } @@ -204,7 +205,7 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |)""".stripMargin.update.run >> updateCommentVotes(event.id, fr"upvotes_nr", fr"downvotes_nr + 1").void } - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } @@ -228,7 +229,7 @@ final class CommentPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) // do nothing - vote doesn't exist ().pure[ConnectionIO] } - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentWritesImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentWritesImpl.scala index 45eca77b..722b2451 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentWritesImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/CommentWritesImpl.scala @@ -4,18 +4,17 @@ import cats.effect.Sync import io.branchtalk.discussions.events.{ CommentCommandEvent, DiscussionsCommandEvent } import io.branchtalk.discussions.model.{ Channel, Comment, Post } import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.infrastructure.{ EventBusProducer, Writes } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model._ -import io.scalaland.chimney.dsl._ +import io.branchtalk.shared.infrastructure.{ KafkaEventBus, Writes } +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* +import io.scalaland.chimney.dsl.* final class CommentWritesImpl[F[_]: Sync: MDC]( - producer: EventBusProducer[F, DiscussionsCommandEvent], + producer: KafkaEventBus.Producer[F, DiscussionsCommandEvent], transactor: Transactor[F] -)(implicit - uuidGenerator: UUIDGenerator -) extends Writes[F, Comment, DiscussionsCommandEvent](producer) - with CommentWrites[F] { +)(using UUID.Generator) + extends Writes[F, Comment, DiscussionsCommandEvent](producer), + CommentWrites[F] { private val postCheck = new ParentCheck[Post]("Post", transactor) private val commentCheck = new EntityCheck("Comment", transactor) @@ -43,7 +42,7 @@ final class CommentWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = updatedComment.id - _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = ${id} AND deleted = FALSE""") + _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = $id AND deleted = FALSE""") now <- ModificationTime.now[F] command = updatedComment .into[CommentCommandEvent.Update] @@ -57,7 +56,7 @@ final class CommentWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = deletedComment.id - _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = ${id} AND deleted = FALSE""") + _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = $id AND deleted = FALSE""") command = deletedComment.into[CommentCommandEvent.Delete].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForComment(command)) } yield DeletionScheduled(id) @@ -66,7 +65,7 @@ final class CommentWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = restoredComment.id - _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = ${id} AND deleted = TRUE""") + _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = $id AND deleted = TRUE""") command = restoredComment .into[CommentCommandEvent.Restore] .withFieldConst(_.correlationID, correlationID) @@ -78,7 +77,7 @@ final class CommentWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = vote.id - _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = ${id} AND deleted = FALSE""") + _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = $id AND deleted = FALSE""") command = vote.into[CommentCommandEvent.Upvote].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForComment(command)) } yield () @@ -87,7 +86,7 @@ final class CommentWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = vote.id - _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = ${id} AND deleted = FALSE""") + _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = $id AND deleted = FALSE""") command = vote.into[CommentCommandEvent.Downvote].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForComment(command)) } yield () @@ -96,7 +95,7 @@ final class CommentWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = vote.id - _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = ${id} AND deleted = FALSE""") + _ <- commentCheck(id, sql"""SELECT 1 FROM comments WHERE id = $id AND deleted = FALSE""") command = vote.into[CommentCommandEvent.RevokeVote].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForComment(command)) } yield () diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostCommandHandler.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostCommandHandler.scala index e5622cbf..fa1fd490 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostCommandHandler.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostCommandHandler.scala @@ -6,7 +6,7 @@ import fs2.Stream import io.branchtalk.discussions.events.{ DiscussionEvent, DiscussionsCommandEvent, PostCommandEvent, PostEvent } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class PostCommandHandler[F[_]: Sync] extends Projector[F, DiscussionsCommandEvent, (UUID, DiscussionEvent)] { @@ -31,23 +31,23 @@ final class PostCommandHandler[F[_]: Sync] extends Projector[F, DiscussionsComma } def toCreate(command: PostCommandEvent.Create): F[(UUID, PostEvent.Created)] = - (command.id.uuid -> command.transformInto[PostEvent.Created]).pure[F] + (command.id.unwrap -> command.transformInto[PostEvent.Created]).pure[F] def toUpdate(command: PostCommandEvent.Update): F[(UUID, PostEvent.Updated)] = - (command.id.uuid -> command.transformInto[PostEvent.Updated]).pure[F] + (command.id.unwrap -> command.transformInto[PostEvent.Updated]).pure[F] def toDelete(command: PostCommandEvent.Delete): F[(UUID, PostEvent.Deleted)] = - (command.id.uuid -> command.transformInto[PostEvent.Deleted]).pure[F] + (command.id.unwrap -> command.transformInto[PostEvent.Deleted]).pure[F] def toRestore(command: PostCommandEvent.Restore): F[(UUID, PostEvent.Restored)] = - (command.id.uuid -> command.transformInto[PostEvent.Restored]).pure[F] + (command.id.unwrap -> command.transformInto[PostEvent.Restored]).pure[F] def toUpvote(command: PostCommandEvent.Upvote): F[(UUID, PostEvent.Upvoted)] = - (command.id.uuid -> command.transformInto[PostEvent.Upvoted]).pure[F] + (command.id.unwrap -> command.transformInto[PostEvent.Upvoted]).pure[F] def toDownvote(command: PostCommandEvent.Downvote): F[(UUID, PostEvent.Downvoted)] = - (command.id.uuid -> command.transformInto[PostEvent.Downvoted]).pure[F] + (command.id.unwrap -> command.transformInto[PostEvent.Downvoted]).pure[F] def toRevokeVote(command: PostCommandEvent.RevokeVote): F[(UUID, PostEvent.VoteRevoked)] = - (command.id.uuid -> command.transformInto[PostEvent.VoteRevoked]).pure[F] + (command.id.unwrap -> command.transformInto[PostEvent.VoteRevoked]).pure[F] } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostPostgresProjector.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostPostgresProjector.scala index 77ad623d..217e74a4 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostPostgresProjector.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostPostgresProjector.scala @@ -6,10 +6,10 @@ import com.typesafe.scalalogging.Logger import doobie.Transactor import fs2.Stream import io.branchtalk.discussions.events.{ DiscussionEvent, PostEvent } -import io.branchtalk.discussions.infrastructure.DoobieExtensions._ +import io.branchtalk.discussions.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.discussions.model.{ Post, User, Vote } import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.{ ID, UUID } @@ -18,8 +18,6 @@ final class PostPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) private val logger = Logger(getClass) - implicit private val logHandler: LogHandler = doobieLogger(getClass) - override def apply(in: Stream[F, DiscussionEvent]): Stream[F, (UUID, DiscussionEvent)] = in.collect { case DiscussionEvent.ForPost(event) => event @@ -61,7 +59,7 @@ final class PostPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) | ${contentRaw}, | ${event.createdAt} |) - |ON CONFLICT (id) DO NOTHING""".stripMargin.update.run.as(event.id.uuid -> event).transact(transactor) + |ON CONFLICT (id) DO NOTHING""".stripMargin.update.run.as(event.id.unwrap -> event).transact(transactor) } def toUpdate(event: PostEvent.Updated): F[(UUID, PostEvent.Updated)] = @@ -83,29 +81,31 @@ final class PostPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) Sync[ConnectionIO].delay( logger.warn(show"Post update ignored as it doesn't contain any modification:\n$event") ) - }).as(event.id.uuid -> event).transact(transactor) + }).as(event.id.unwrap -> event).transact(transactor) } def toDelete(event: PostEvent.Deleted): F[(UUID, PostEvent.Deleted)] = withCorrelationID(event.correlationID) { sql"UPDATE posts SET deleted = TRUE WHERE id = ${event.id}".update.run - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } def toRestore(event: PostEvent.Restored): F[(UUID, PostEvent.Restored)] = withCorrelationID(event.correlationID) { sql"UPDATE posts SET deleted = FALSE WHERE id = ${event.id}".update.run - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } private def fetchVote(postID: ID[Post], voterID: ID[User]) = - sql"SELECT vote FROM post_votes WHERE post_id = ${postID} AND voter_id = ${voterID}".query[Vote.Type].option + sql"SELECT vote FROM post_votes WHERE post_id = $postID AND voter_id = $voterID" + .queryWithLabel[Vote.Type](show"Get Discussions' Vote for Post=$postID AND Voter=$voterID") + .option private def updatePostVotes(postID: ID[Post], upvotes: Fragment, downvotes: Fragment) = (fr"WITH nw AS (SELECT" ++ upvotes ++ fr"AS upvotes," - ++ downvotes ++ fr"AS downvotes FROM posts WHERE id = ${postID})" ++ + ++ downvotes ++ fr"AS downvotes FROM posts WHERE id = $postID)" ++ fr""" |UPDATE posts |SET upvotes_nr = nw.upvotes, @@ -113,7 +113,7 @@ final class PostPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) | total_score = nw.upvotes - nw.downvotes, | controversial_score = LEAST(nw.upvotes, nw.downvotes) |FROM nw - |WHERE id = ${postID}""".stripMargin).update.run + |WHERE id = $postID""".stripMargin).update.run def toUpvote(event: PostEvent.Upvoted): F[(UUID, PostEvent.Upvoted)] = withCorrelationID(event.correlationID) { @@ -142,7 +142,7 @@ final class PostPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |)""".stripMargin.update.run >> updatePostVotes(event.id, fr"upvotes_nr + 1", fr"downvotes_nr").void } - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } @@ -173,7 +173,7 @@ final class PostPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |)""".stripMargin.update.run >> updatePostVotes(event.id, fr"upvotes_nr", fr"downvotes_nr + 1").void } - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } @@ -197,7 +197,7 @@ final class PostPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) // do nothing - vote doesn't exist ().pure[ConnectionIO] } - .as(event.id.uuid -> event) + .as(event.id.unwrap -> event) .transact(transactor) } } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostWritesImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostWritesImpl.scala index b876e135..6e79773b 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostWritesImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/PostWritesImpl.scala @@ -1,32 +1,26 @@ package io.branchtalk.discussions.writes import cats.effect.Sync -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.types.string.NonEmptyString import io.branchtalk.discussions.events.{ DiscussionsCommandEvent, PostCommandEvent } import io.branchtalk.discussions.model.{ Channel, Post } import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.infrastructure.{ EventBusProducer, NormalizeForUrl, Writes } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model._ -import io.scalaland.chimney.dsl._ +import io.branchtalk.shared.infrastructure.{ KafkaEventBus, NormalizeForUrl, Writes } +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* +import io.scalaland.chimney.dsl.* final class PostWritesImpl[F[_]: Sync: MDC]( - producer: EventBusProducer[F, DiscussionsCommandEvent], + producer: KafkaEventBus.Producer[F, DiscussionsCommandEvent], transactor: Transactor[F] -)(implicit - uuidGenerator: UUIDGenerator -) extends Writes[F, Post, DiscussionsCommandEvent](producer) - with PostWrites[F] { +)(using UUID.Generator) + extends Writes[F, Post, DiscussionsCommandEvent](producer), + PostWrites[F] { private val channelCheck = new ParentCheck[Channel]("Channel", transactor) private val postCheck = new EntityCheck("Post", transactor) private def titleToUrlTitle(title: Post.Title): F[Post.UrlTitle] = - ParseRefined[F] - .parse[NonEmpty](NormalizeForUrl(title.nonEmptyString.value)) - .handleError(_ => "post": NonEmptyString) - .map(Post.UrlTitle(_)) + ParseNewtype[F].parse[Post.UrlTitle](NormalizeForUrl(title.unwrap)) override def createPost(newPost: Post.Create): F[CreationScheduled[Post]] = for { @@ -51,7 +45,7 @@ final class PostWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = updatedPost.id - _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = ${id} AND deleted = FALSE""") + _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = $id AND deleted = FALSE""") now <- ModificationTime.now[F] newUrlTitle <- updatedPost.newTitle.traverse(titleToUrlTitle) command = updatedPost @@ -67,7 +61,7 @@ final class PostWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = deletedPost.id - _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = ${id} AND deleted = FALSE""") + _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = $id AND deleted = FALSE""") command = deletedPost.into[PostCommandEvent.Delete].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForPost(command)) } yield DeletionScheduled(id) @@ -76,7 +70,7 @@ final class PostWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = restoredPost.id - _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = ${id} AND deleted = TRUE""") + _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = $id AND deleted = TRUE""") command = restoredPost.into[PostCommandEvent.Restore].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForPost(command)) } yield RestoreScheduled(id) @@ -85,7 +79,7 @@ final class PostWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = vote.id - _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = ${id} AND deleted = FALSE""") + _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = $id AND deleted = FALSE""") command = vote.into[PostCommandEvent.Upvote].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForPost(command)) } yield () @@ -94,7 +88,7 @@ final class PostWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = vote.id - _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = ${id} AND deleted = FALSE""") + _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = $id AND deleted = FALSE""") command = vote.into[PostCommandEvent.Downvote].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForPost(command)) } yield () @@ -103,7 +97,7 @@ final class PostWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = vote.id - _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = ${id} AND deleted = FALSE""") + _ <- postCheck(id, sql"""SELECT 1 FROM posts WHERE id = $id AND deleted = FALSE""") command = vote.into[PostCommandEvent.RevokeVote].withFieldConst(_.correlationID, correlationID).transform _ <- postEvent(id, DiscussionsCommandEvent.ForPost(command)) } yield () diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionCommandHandler.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionCommandHandler.scala index 7d71870f..6c9e47e5 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionCommandHandler.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionCommandHandler.scala @@ -11,7 +11,7 @@ import io.branchtalk.discussions.events.{ } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class SubscriptionCommandHandler[F[_]: Sync] extends Projector[F, DiscussionsCommandEvent, (UUID, DiscussionEvent)] { @@ -32,8 +32,8 @@ final class SubscriptionCommandHandler[F[_]: Sync] } def toSubscribe(command: SubscriptionCommandEvent.Subscribe): F[(UUID, SubscriptionEvent.Subscribed)] = - (command.subscriberID.uuid -> command.transformInto[SubscriptionEvent.Subscribed]).pure[F] + (command.subscriberID.unwrap -> command.transformInto[SubscriptionEvent.Subscribed]).pure[F] def toUnsubscribe(command: SubscriptionCommandEvent.Unsubscribe): F[(UUID, SubscriptionEvent.Unsubscribed)] = - (command.subscriberID.uuid -> command.transformInto[SubscriptionEvent.Unsubscribed]).pure[F] + (command.subscriberID.unwrap -> command.transformInto[SubscriptionEvent.Unsubscribed]).pure[F] } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionPostgresProjector.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionPostgresProjector.scala index 6e729fd4..727dd757 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionPostgresProjector.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionPostgresProjector.scala @@ -5,7 +5,7 @@ import com.typesafe.scalalogging.Logger import fs2.Stream import io.branchtalk.discussions.events.{ DiscussionEvent, SubscriptionEvent } import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID @@ -14,8 +14,6 @@ final class SubscriptionPostgresProjector[F[_]: Sync: MDC](transactor: Transacto private val logger = Logger(getClass) - implicit private val logHandler: LogHandler = doobieLogger(getClass) - override def apply(in: Stream[F, DiscussionEvent]): Stream[F, (UUID, DiscussionEvent)] = in.collect { case DiscussionEvent.ForSubscription(event) => event @@ -42,7 +40,7 @@ final class SubscriptionPostgresProjector[F[_]: Sync: MDC](transactor: Transacto |ON CONFLICT (subscriber_id) DO |UPDATE |SET subscriptions_ids = array_distinct(subscriptions.subscriptions_ids || ${event.subscriptions})""" // - .stripMargin.update.run.as(event.subscriberID.uuid -> event).transact(transactor) + .stripMargin.update.run.as(event.subscriberID.unwrap -> event).transact(transactor) } def toUnsubscribe(event: SubscriptionEvent.Unsubscribed): F[(UUID, SubscriptionEvent.Unsubscribed)] = @@ -50,7 +48,7 @@ final class SubscriptionPostgresProjector[F[_]: Sync: MDC](transactor: Transacto sql"""UPDATE subscriptions |SET subscriptions_ids = array_diff(subscriptions.subscriptions_ids, ${event.subscriptions}) |WHERE subscriber_id = ${event.subscriberID}""".stripMargin.update.run - .as(event.subscriberID.uuid -> event) + .as(event.subscriberID.unwrap -> event) .transact(transactor) } } diff --git a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionWritesImpl.scala b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionWritesImpl.scala index ef70aee7..56e55e2b 100644 --- a/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionWritesImpl.scala +++ b/modules/discussions-impl/src/main/scala/io/branchtalk/discussions/writes/SubscriptionWritesImpl.scala @@ -4,20 +4,17 @@ import cats.effect.Sync import io.branchtalk.discussions.events.{ DiscussionsCommandEvent, SubscriptionCommandEvent } import io.branchtalk.discussions.model.{ Subscription, User } import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.infrastructure.{ EventBusProducer, Writes } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model._ -import io.scalaland.chimney.dsl._ +import io.branchtalk.shared.infrastructure.{ KafkaEventBus, Writes } +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* +import io.scalaland.chimney.dsl.* final class SubscriptionWritesImpl[F[_]: Sync: MDC]( - producer: EventBusProducer[F, DiscussionsCommandEvent], + producer: KafkaEventBus.Producer[F, DiscussionsCommandEvent], transactor: Transactor[F] -)(implicit - uuidGenerator: UUIDGenerator -) extends Writes[F, User, DiscussionsCommandEvent](producer) - with SubscriptionWrites[F] { - - implicit private val logHandler: LogHandler = doobieLogger(getClass) +)(using UUID.Generator) + extends Writes[F, User, DiscussionsCommandEvent](producer), + SubscriptionWrites[F] { private val commonSelect: Fragment = fr"""SELECT subscriber_id, @@ -35,8 +32,8 @@ final class SubscriptionWritesImpl[F[_]: Sync: MDC]( .withFieldConst(_.correlationID, correlationID) .transform _ <- postEvent(id, DiscussionsCommandEvent.ForSubscription(command)) - subscription <- (commonSelect ++ fr"WHERE subscriber_id = ${id}") - .query[Subscription] + subscription <- (commonSelect ++ fr"WHERE subscriber_id = $id") + .queryWithLabel[Subscription](show"Get Discussions' Subscriber ID=$id") .option .map(_.getOrElse(Subscription(id, Set.empty))) .transact(transactor) @@ -53,8 +50,8 @@ final class SubscriptionWritesImpl[F[_]: Sync: MDC]( .withFieldConst(_.correlationID, correlationID) .transform _ <- postEvent(id, DiscussionsCommandEvent.ForSubscription(command)) - subscription <- (commonSelect ++ fr"WHERE subscriber_id = ${id}") - .query[Subscription] + subscription <- (commonSelect ++ fr"WHERE subscriber_id = $id") + .queryWithLabel[Subscription](show"Get Discussions' Subscriber ID=$id") .option .map(_.getOrElse(Subscription(id, Set.empty))) .transact(transactor) diff --git a/modules/discussions-impl/src/it/resources/discussions-test.conf b/modules/discussions-impl/src/test/resources/discussions-test.conf similarity index 100% rename from modules/discussions-impl/src/it/resources/discussions-test.conf rename to modules/discussions-impl/src/test/resources/discussions-test.conf diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/ChannelPaginationSpec.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/ChannelPaginationSpec.scala similarity index 57% rename from modules/discussions-impl/src/it/scala/io/branchtalk/discussions/ChannelPaginationSpec.scala rename to modules/discussions-impl/src/test/scala/io/branchtalk/discussions/ChannelPaginationSpec.scala index 602138ac..6996b287 100644 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/ChannelPaginationSpec.scala +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/ChannelPaginationSpec.scala @@ -1,15 +1,15 @@ package io.branchtalk.discussions import io.branchtalk.discussions.model.Channel -import io.branchtalk.shared.model.TestUUIDGenerator +import io.branchtalk.shared.model.* import org.specs2.mutable.Specification -final class ChannelPaginationSpec extends Specification with DiscussionsIOTest with DiscussionsFixtures { +final class ChannelPaginationSpec extends Specification, DiscussionsIOTest, DiscussionsFixtures { // Channel pagination tests cannot be run in parallel to other Channel tests (no parent to filter other tests) sequential - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "Channel pagination" should { @@ -17,24 +17,32 @@ final class ChannelPaginationSpec extends Specification with DiscussionsIOTest w for { // given editorID <- editorIDCreate - cleanupIDs <- discussionsReads.channelReads.paginate(Channel.Sorting.Newest, 0L, 10).map(_.entities.map(_.id)) + cleanupIDs <- discussionsReads.channelReads + .paginate(Channel.Sorting.Newest, Paginated.Offset(0L), Paginated.Limit(10)) + .map(_.entities.map(_.id)) _ <- cleanupIDs.traverse(id => discussionsWrites.channelWrites.deleteChannel(Channel.Delete(id, editorID))) _ <- cleanupIDs .traverse(discussionsReads.channelReads.deleted(_)) .assert("Channels should be deleted eventually")(_.forall(identity)) .eventually() paginatedData <- (0 until 20).toList.traverse(_ => channelCreate) - paginatedIDs <- paginatedData.traverse(discussionsWrites.channelWrites.createChannel).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.channelWrites.createChannel).map(_.map(_.unwrap)) _ <- paginatedIDs.traverse(discussionsReads.channelReads.requireById(_)).eventually() // when - pagination <- discussionsReads.channelReads.paginate(Channel.Sorting.Newest, 0L, 10) - pagination2 <- discussionsReads.channelReads.paginate(Channel.Sorting.Newest, 10L, 10) + pagination <- discussionsReads.channelReads.paginate(Channel.Sorting.Newest, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.channelReads.paginate(Channel.Sorting.Newest, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } @@ -43,26 +51,32 @@ final class ChannelPaginationSpec extends Specification with DiscussionsIOTest w // given editorID <- editorIDCreate cleanupIDs <- discussionsReads.channelReads - .paginate(Channel.Sorting.Newest, 0L, 1000) + .paginate(Channel.Sorting.Newest, Paginated.Offset(0L), Paginated.Limit(1000)) .map(_.entities.map(_.id)) .flatMap(_.traverse(id => discussionsWrites.channelWrites.deleteChannel(Channel.Delete(id, editorID)))) - .map(_.map(_.id)) + .map(_.map(_.unwrap)) _ <- cleanupIDs .traverse(discussionsReads.channelReads.deleted(_)) .assert("Channels should be deleted eventually")(_.forall(identity)) .eventually() paginatedData <- (0 until 20).toList.traverse(_ => channelCreate) - paginatedIDs <- paginatedData.traverse(discussionsWrites.channelWrites.createChannel).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.channelWrites.createChannel).map(_.map(_.unwrap)) _ <- paginatedIDs.traverse(discussionsReads.channelReads.requireById(_)).eventually() // when - pagination <- discussionsReads.channelReads.paginate(Channel.Sorting.Alphabetically, 0L, 10) - pagination2 <- discussionsReads.channelReads.paginate(Channel.Sorting.Alphabetically, 10L, 10) + pagination <- discussionsReads.channelReads.paginate(Channel.Sorting.Alphabetically, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.channelReads.paginate(Channel.Sorting.Alphabetically, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } } diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/ChannelReadsWritesSpec.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/ChannelReadsWritesSpec.scala similarity index 91% rename from modules/discussions-impl/src/it/scala/io/branchtalk/discussions/ChannelReadsWritesSpec.scala rename to modules/discussions-impl/src/test/scala/io/branchtalk/discussions/ChannelReadsWritesSpec.scala index be188532..a75f227d 100644 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/ChannelReadsWritesSpec.scala +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/ChannelReadsWritesSpec.scala @@ -2,12 +2,12 @@ package io.branchtalk.discussions import cats.effect.IO import io.branchtalk.discussions.model.Channel -import io.branchtalk.shared.model.{ ID, OptionUpdatable, TestUUIDGenerator, Updatable } +import io.branchtalk.shared.model.* import org.specs2.mutable.Specification -final class ChannelReadsWritesSpec extends Specification with DiscussionsIOTest with DiscussionsFixtures { +final class ChannelReadsWritesSpec extends Specification, DiscussionsIOTest, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "Channel Reads & Writes" should { @@ -19,7 +19,7 @@ final class ChannelReadsWritesSpec extends Specification with DiscussionsIOTest creationData <- (0 until 3).toList.traverse(_ => channelCreate) // when toCreate <- creationData.traverse(discussionsWrites.channelWrites.createChannel) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) channels <- ids.traverse(discussionsReads.channelReads.requireById(_)).eventually() channelsOpt <- ids.traverse(discussionsReads.channelReads.getById(_)).eventually() channelsExist <- ids.traverse(discussionsReads.channelReads.exists).eventually() @@ -62,7 +62,7 @@ final class ChannelReadsWritesSpec extends Specification with DiscussionsIOTest editorID <- editorIDCreate creationData <- (0 until 3).toList.traverse(_ => channelCreate) toCreate <- creationData.traverse(discussionsWrites.channelWrites.createChannel) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) created <- ids.traverse(discussionsReads.channelReads.requireById(_)).eventually() updateData = created.zipWithIndex.collect { case (Channel(id, data), 0) => @@ -104,13 +104,13 @@ final class ChannelReadsWritesSpec extends Specification with DiscussionsIOTest .collect { case ((Channel(_, older), Channel(_, newer)), 0) => // set case - older must_=== newer.copy(lastModifiedAt = None) + older === newer.copy(lastModifiedAt = None) case ((Channel(_, older), Channel(_, newer)), 1) => // keep case - older must_=== newer + older === newer case ((Channel(_, older), Channel(_, newer)), 2) => // erase case - older.copy(description = None) must_=== newer.copy(lastModifiedAt = None) + older.copy(description = None) === newer.copy(lastModifiedAt = None) } .lastOption .getOrElse(true must beFalse) @@ -123,7 +123,7 @@ final class ChannelReadsWritesSpec extends Specification with DiscussionsIOTest creationData <- (0 until 3).toList.traverse(_ => channelCreate) // when toCreate <- creationData.traverse(discussionsWrites.channelWrites.createChannel) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) _ <- ids.traverse(discussionsReads.channelReads.requireById(_)).eventually() _ <- ids.map(Channel.Delete(_, editorID)).traverse(discussionsWrites.channelWrites.deleteChannel) _ <- ids diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/CommentReadsWritesSpec.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/CommentReadsWritesSpec.scala similarity index 67% rename from modules/discussions-impl/src/it/scala/io/branchtalk/discussions/CommentReadsWritesSpec.scala rename to modules/discussions-impl/src/test/scala/io/branchtalk/discussions/CommentReadsWritesSpec.scala index 0d18c47a..f5c40a2e 100644 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/CommentReadsWritesSpec.scala +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/CommentReadsWritesSpec.scala @@ -2,14 +2,14 @@ package io.branchtalk.discussions import cats.effect.IO import io.branchtalk.discussions.model.{ Comment, Post } -import io.branchtalk.shared.model.{ ID, TestUUIDGenerator, Updatable } +import io.branchtalk.shared.model.* import org.specs2.mutable.Specification import scala.concurrent.duration.DurationInt -final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest with DiscussionsFixtures { +final class CommentReadsWritesSpec extends Specification, DiscussionsIOTest, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "Comment Reads & Writes" should { @@ -28,14 +28,14 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest "create a Comment and eventually read it" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() creationData <- (0 until 3).toList.traverse(_ => commentCreate(postID)) // when toCreate <- creationData.traverse(discussionsWrites.commentWrites.createComment) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) comments <- ids.traverse(discussionsReads.commentReads.requireById(_)).eventually() commentsOpt <- ids.traverse(discussionsReads.commentReads.getById(_)).eventually() commentsExist <- ids.traverse(discussionsReads.commentReads.exists).eventually() @@ -52,9 +52,9 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest "don't update a Comment that doesn't exists" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) editorID <- editorIDCreate creationData <- (0 until 3).toList.traverse(_ => commentCreate(postID)) fakeUpdateData <- creationData.traverse { data => @@ -76,14 +76,14 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest "update an existing Comment" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() editorID <- editorIDCreate creationData <- (0 until 2).toList.traverse(_ => commentCreate(postID)) toCreate <- creationData.traverse(discussionsWrites.commentWrites.createComment) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) created <- ids.traverse(discussionsReads.commentReads.requireById(_)).eventually() updateData = created.zipWithIndex.collect { case (Comment(id, data), 0) => @@ -113,10 +113,10 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest .collect { case ((Comment(_, older), Comment(_, newer)), 0) => // set case - older must_=== newer.copy(lastModifiedAt = None) + older === newer.copy(lastModifiedAt = None) case ((Comment(_, older), Comment(_, newer)), 1) => // keep case - older must_=== newer + older === newer } .lastOption .getOrElse(true must beFalse) @@ -125,15 +125,15 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest "allow delete and restore of a created Comment" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() creationData <- (0 until 3).toList.traverse(_ => commentCreate(postID)) editorID <- editorIDCreate // when toCreate <- creationData.traverse(discussionsWrites.commentWrites.createComment) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) _ <- ids.traverse(discussionsReads.commentReads.requireById(_)).eventually() _ <- ids.map(Comment.Delete(_, editorID)).traverse(discussionsWrites.commentWrites.deleteComment) _ <- ids @@ -168,13 +168,13 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest "handle Upvoting and Downvoting of Comments" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() creationData <- (0 until 4).toList.traverse(_ => commentCreate(postID)) toCreate <- creationData.traverse(discussionsWrites.commentWrites.createComment) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) _ <- ids.traverse(discussionsReads.commentReads.requireById(_)).eventually() user0ID <- voterIDCreate user1ID <- voterIDCreate @@ -187,7 +187,7 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest _ <- discussionsWrites.commentWrites.downvoteComment(Comment.Downvote(ids(3), user3ID)) firstVotes <- ids .traverse(discussionsReads.commentReads.requireById(_)) - .assert("Comments should have first Votes applied")(_.forall(_.data.totalScore.toInt =!= 0)) + .assert("Comments should have first Votes applied")(_.forall(_.data.totalScore.unwrap =!= 0)) .eventually(delay = 1.second) _ <- discussionsWrites.commentWrites.downvoteComment(Comment.Downvote(ids(0), user0ID)) _ <- discussionsWrites.commentWrites.revokeCommentVote(Comment.RevokeVote(ids(1), user1ID)) @@ -207,40 +207,40 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest thirdsVotes <- ids .traverse(discussionsReads.commentReads.requireById(_)) .assert("Comments should have third Votes applied")( - _.forall(p => p.data.totalScore.toInt > 0 || p.data.controversialScore.toNonNegativeInt.toInt > 0) + _.forall(p => p.data.totalScore.unwrap > 0 || p.data.controversialScore.unwrap > 0) ) .eventually(delay = 1.second) } yield { // then - firstVotes.map(_.data.totalScore) must_=== List(Comment.TotalScore(1), - Comment.TotalScore(1), - Comment.TotalScore(-1), - Comment.TotalScore(-1) + firstVotes.map(_.data.totalScore) === List(Comment.TotalScore(1), + Comment.TotalScore(1), + Comment.TotalScore(-1), + Comment.TotalScore(-1) ) - firstVotes.map(_.data.controversialScore) must_=== List(Comment.ControversialScore(0), - Comment.ControversialScore(0), - Comment.ControversialScore(0), - Comment.ControversialScore(0) + firstVotes.map(_.data.controversialScore) === List(Comment.ControversialScore(0), + Comment.ControversialScore(0), + Comment.ControversialScore(0), + Comment.ControversialScore(0) ) - secondsVotes.map(_.data.totalScore) must_=== List(Comment.TotalScore(-1), - Comment.TotalScore(0), - Comment.TotalScore(1), - Comment.TotalScore(0) + secondsVotes.map(_.data.totalScore) === List(Comment.TotalScore(-1), + Comment.TotalScore(0), + Comment.TotalScore(1), + Comment.TotalScore(0) ) - secondsVotes.map(_.data.controversialScore) must_=== List(Comment.ControversialScore(0), - Comment.ControversialScore(0), - Comment.ControversialScore(0), - Comment.ControversialScore(0) + secondsVotes.map(_.data.controversialScore) === List(Comment.ControversialScore(0), + Comment.ControversialScore(0), + Comment.ControversialScore(0), + Comment.ControversialScore(0) ) - thirdsVotes.map(_.data.totalScore) must_=== List(Comment.TotalScore(0), - Comment.TotalScore(1), - Comment.TotalScore(2), - Comment.TotalScore(1) + thirdsVotes.map(_.data.totalScore) === List(Comment.TotalScore(0), + Comment.TotalScore(1), + Comment.TotalScore(2), + Comment.TotalScore(1) ) - thirdsVotes.map(_.data.controversialScore) must_=== List(Comment.ControversialScore(1), - Comment.ControversialScore(0), - Comment.ControversialScore(0), - Comment.ControversialScore(0) + thirdsVotes.map(_.data.controversialScore) === List(Comment.ControversialScore(1), + Comment.ControversialScore(0), + Comment.ControversialScore(0), + Comment.ControversialScore(0) ) } } @@ -248,67 +248,87 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest "paginate newest Comments by Posts" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() - post2ID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + post2ID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(post2ID).eventually() paginatedData <- (0 until 20).toList.traverse(_ => commentCreate(postID)) - paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) nonPaginatedData <- (0 until 20).toList.traverse(_ => commentCreate(post2ID)) - nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) _ <- (paginatedIDs ++ nonPaginatedIds).traverse(discussionsReads.commentReads.requireById(_)).eventually() // when - pagination <- discussionsReads.commentReads.paginate(postID, None, Comment.Sorting.Newest, 0L, 10) - pagination2 <- discussionsReads.commentReads.paginate(postID, None, Comment.Sorting.Newest, 10L, 10) + pagination <- discussionsReads.commentReads.paginate(postID, + None, + Comment.Sorting.Newest, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.commentReads.paginate(postID, + None, + Comment.Sorting.Newest, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } "paginate newest Comments by Replies" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() - commentID <- commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.id) + commentID <- commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.unwrap) paginatedData <- (0 until 20).toList.traverse(_ => commentCreate(postID).map(_.copy(replyTo = commentID.some))) - paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) nonPaginatedData <- (0 until 20).toList.traverse(_ => commentCreate(postID)) - nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) _ <- (paginatedIDs ++ nonPaginatedIds).traverse(discussionsReads.commentReads.requireById(_)).eventually() // when - pagination <- discussionsReads.commentReads.paginate(postID, commentID.some, Comment.Sorting.Newest, 0L, 10) - pagination2 <- discussionsReads.commentReads.paginate(postID, commentID.some, Comment.Sorting.Newest, 10L, 10) + pagination <- discussionsReads.commentReads.paginate(postID, + commentID.some, + Comment.Sorting.Newest, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.commentReads.paginate(postID, + commentID.some, + Comment.Sorting.Newest, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } "paginate hottest Comments by Posts" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() - post2ID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + post2ID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(post2ID).eventually() paginatedData <- (0 until 20).toList.traverse(_ => commentCreate(postID)) - paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) nonPaginatedData <- (0 until 20).toList.traverse(_ => commentCreate(post2ID)) - nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) _ <- (paginatedIDs ++ nonPaginatedIds).traverse(discussionsReads.commentReads.requireById(_)).eventually() user1ID <- voterIDCreate user2ID <- voterIDCreate @@ -319,34 +339,44 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest _ <- paginatedIDs .traverse(discussionsReads.commentReads.requireById(_)) .assert("Votes should be eventually applied")( - _.forall(e => e.data.totalScore.toInt > 0 || e.data.controversialScore.toNonNegativeInt.value > 0) + _.forall(e => e.data.totalScore.unwrap > 0 || e.data.controversialScore.unwrap > 0) ) .eventually(delay = 1.second) // when - pagination <- discussionsReads.commentReads.paginate(postID, None, Comment.Sorting.Hottest, 0L, 10) - pagination2 <- discussionsReads.commentReads.paginate(postID, None, Comment.Sorting.Hottest, 10L, 10) + pagination <- discussionsReads.commentReads.paginate(postID, + None, + Comment.Sorting.Hottest, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.commentReads.paginate(postID, + None, + Comment.Sorting.Hottest, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } "paginate controversial Comments by Posts" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(postID).eventually() - post2ID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + post2ID <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) _ <- discussionsReads.postReads.requireById(post2ID).eventually() paginatedData <- (0 until 20).toList.traverse(_ => commentCreate(postID)) - paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) nonPaginatedData <- (0 until 20).toList.traverse(_ => commentCreate(post2ID)) - nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.id)) + nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.commentWrites.createComment).map(_.map(_.unwrap)) _ <- (paginatedIDs ++ nonPaginatedIds).traverse(discussionsReads.commentReads.requireById(_)).eventually() user1ID <- voterIDCreate user2ID <- voterIDCreate @@ -357,18 +387,28 @@ final class CommentReadsWritesSpec extends Specification with DiscussionsIOTest _ <- paginatedIDs .traverse(discussionsReads.commentReads.requireById(_)) .assert("Votes should be eventually applied")( - _.forall(e => e.data.totalScore.toInt > 0 || e.data.controversialScore.toNonNegativeInt.value > 0) + _.forall(e => e.data.totalScore.unwrap > 0 || e.data.controversialScore.unwrap > 0) ) .eventually(delay = 1.second) // when - pagination <- discussionsReads.commentReads.paginate(postID, None, Comment.Sorting.Controversial, 0L, 10) - pagination2 <- discussionsReads.commentReads.paginate(postID, None, Comment.Sorting.Controversial, 10L, 10) + pagination <- discussionsReads.commentReads.paginate(postID, + None, + Comment.Sorting.Controversial, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.commentReads.paginate(postID, + None, + Comment.Sorting.Controversial, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } } diff --git a/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/DiscussionsFixtures.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/DiscussionsFixtures.scala new file mode 100644 index 00000000..603e544a --- /dev/null +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/DiscussionsFixtures.scala @@ -0,0 +1,39 @@ +package io.branchtalk.discussions + +import cats.effect.IO +import io.branchtalk.discussions.model.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.Fixtures.* + +trait DiscussionsFixtures { + + def editorIDCreate(using UUID.Generator): IO[ID[User]] = ID.create[IO, User] + + def subscriberIDCreate(using UUID.Generator): IO[ID[User]] = ID.create[IO, User] + + def voterIDCreate(using UUID.Generator): IO[ID[User]] = ID.create[IO, User] + + def channelCreate(using UUID.Generator): IO[Channel.Create] = + ( + ID.create[IO, User], + noWhitespaces.flatMap(ParseNewtype[IO].parse[Channel.UrlName](_)), + nameLike.flatMap(ParseNewtype[IO].parse[Channel.Name](_)), + textProducer.map(_.loremIpsum).flatMap(ParseNewtype[IO].parse[Channel.Description](_)).map(Option.apply) + ).mapN(Channel.Create.apply) + + def postCreate(channelID: ID[Channel])(using UUID.Generator): IO[Post.Create] = + ( + ID.create[IO, User], + channelID.pure[IO], + nameLike.flatMap(ParseNewtype[IO].parse[Post.Title](_)), + textProducer.map(_.loremIpsum).map(Post.Text(_)).map(Post.Content.Text(_)) + ).mapN(Post.Create.apply) + + def commentCreate(postID: ID[Post])(using UUID.Generator): IO[Comment.Create] = + ( + ID.create[IO, User], + postID.pure[IO], + textProducer.map(_.loremIpsum).map(Comment.Content(_)), + none[ID[Comment]].pure[IO] + ).mapN(Comment.Create.apply) +} diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/DiscussionsIOTest.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/DiscussionsIOTest.scala similarity index 73% rename from modules/discussions-impl/src/it/scala/io/branchtalk/discussions/DiscussionsIOTest.scala rename to modules/discussions-impl/src/test/scala/io/branchtalk/discussions/DiscussionsIOTest.scala index 979c47c7..f571a06c 100644 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/DiscussionsIOTest.scala +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/DiscussionsIOTest.scala @@ -1,16 +1,16 @@ package io.branchtalk.discussions import cats.effect.{ IO, Resource } -import io.branchtalk.shared.infrastructure.DomainConfig +import io.branchtalk.shared.infrastructure.DomainModule import io.branchtalk.{ IOTest, ResourcefulTest } -import io.branchtalk.shared.model.UUIDGenerator +import io.branchtalk.shared.model.* -trait DiscussionsIOTest extends IOTest with ResourcefulTest { +trait DiscussionsIOTest extends IOTest, ResourcefulTest { - implicit protected def uuidGenerator: UUIDGenerator + protected given uuidGenerator: UUID.Generator // populated by resources - protected var discussionsCfg: DomainConfig = _ + protected var discussionsCfg: DomainModule.Config = _ protected var discussionsReads: DiscussionsReads[IO] = _ protected var discussionsWrites: DiscussionsWrites[IO] = _ diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/PostReadsWritesSpec.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/PostReadsWritesSpec.scala similarity index 71% rename from modules/discussions-impl/src/it/scala/io/branchtalk/discussions/PostReadsWritesSpec.scala rename to modules/discussions-impl/src/test/scala/io/branchtalk/discussions/PostReadsWritesSpec.scala index a6d2d661..8b8bacb7 100644 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/PostReadsWritesSpec.scala +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/PostReadsWritesSpec.scala @@ -3,14 +3,14 @@ package io.branchtalk.discussions import cats.data.NonEmptySet import cats.effect.IO import io.branchtalk.discussions.model.{ Channel, Post } -import io.branchtalk.shared.model.{ ID, TestUUIDGenerator, Updatable } +import io.branchtalk.shared.model.* import org.specs2.mutable.Specification -import scala.concurrent.duration.DurationInt +import scala.concurrent.duration.* -final class PostReadsWritesSpec extends Specification with DiscussionsIOTest with DiscussionsFixtures { +final class PostReadsWritesSpec extends Specification, DiscussionsIOTest, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "Post Reads & Writes" should { @@ -29,12 +29,12 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit "create a Post and eventually read it" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() creationData <- (0 until 3).toList.traverse(_ => postCreate(channelID)) // when toCreate <- creationData.traverse(discussionsWrites.postWrites.createPost) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) posts <- ids.traverse(discussionsReads.postReads.requireById(_)).eventually() postsOpt <- ids.traverse(discussionsReads.postReads.getById(_)).eventually() postsExist <- ids.traverse(discussionsReads.postReads.exists).eventually() @@ -51,7 +51,7 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit "don't update a Post that doesn't exists" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() editorID <- editorIDCreate creationData <- (0 until 3).toList.traverse(_ => postCreate(channelID)) @@ -75,12 +75,12 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit "update an existing Post" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() editorID <- editorIDCreate creationData <- (0 until 2).toList.traverse(_ => postCreate(channelID)) toCreate <- creationData.traverse(discussionsWrites.postWrites.createPost) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) created <- ids.traverse(discussionsReads.postReads.requireById(_)).eventually() updateData = created.zipWithIndex.collect { case (Post(id, data), 0) => @@ -112,10 +112,10 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit .collect { case ((Post(_, older), Post(_, newer)), 0) => // set case - older must_=== newer.copy(lastModifiedAt = None) + older === newer.copy(lastModifiedAt = None) case ((Post(_, older), Post(_, newer)), 1) => // keep case - older must_=== newer + older === newer } .lastOption .getOrElse(true must beFalse) @@ -124,11 +124,11 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit "handle Upvoting and Downvoting of Posts" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() creationData <- (0 until 4).toList.traverse(_ => postCreate(channelID)) toCreate <- creationData.traverse(discussionsWrites.postWrites.createPost) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) _ <- ids.traverse(discussionsReads.postReads.requireById(_)).eventually() user0ID <- voterIDCreate user1ID <- voterIDCreate @@ -141,7 +141,7 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit _ <- discussionsWrites.postWrites.downvotePost(Post.Downvote(ids(3), user3ID)) firstVotes <- ids .traverse(discussionsReads.postReads.requireById(_)) - .assert("Posts should have first Votes applied")(_.forall(_.data.totalScore.toInt =!= 0)) + .assert("Posts should have first Votes applied")(_.forall(_.data.totalScore.unwrap =!= 0)) .eventually(delay = 1.second) _ <- discussionsWrites.postWrites.downvotePost(Post.Downvote(ids(0), user0ID)) _ <- discussionsWrites.postWrites.revokePostVote(Post.RevokeVote(ids(1), user1ID)) @@ -161,40 +161,40 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit thirdsVotes <- ids .traverse(discussionsReads.postReads.requireById(_)) .assert("Posts should have third Votes applied")( - _.forall(p => p.data.totalScore.toInt > 0 || p.data.controversialScore.toNonNegativeInt.toInt > 0) + _.forall(p => p.data.totalScore.unwrap > 0 || p.data.controversialScore.unwrap > 0) ) .eventually(delay = 1.second) } yield { // then - firstVotes.map(_.data.totalScore) must_=== List(Post.TotalScore(1), - Post.TotalScore(1), - Post.TotalScore(-1), - Post.TotalScore(-1) + firstVotes.map(_.data.totalScore) === List(Post.TotalScore(1), + Post.TotalScore(1), + Post.TotalScore(-1), + Post.TotalScore(-1) ) - firstVotes.map(_.data.controversialScore) must_=== List(Post.ControversialScore(0), - Post.ControversialScore(0), - Post.ControversialScore(0), - Post.ControversialScore(0) + firstVotes.map(_.data.controversialScore) === List(Post.ControversialScore(0), + Post.ControversialScore(0), + Post.ControversialScore(0), + Post.ControversialScore(0) ) - secondsVotes.map(_.data.totalScore) must_=== List(Post.TotalScore(-1), - Post.TotalScore(0), - Post.TotalScore(1), - Post.TotalScore(0) + secondsVotes.map(_.data.totalScore) === List(Post.TotalScore(-1), + Post.TotalScore(0), + Post.TotalScore(1), + Post.TotalScore(0) ) - secondsVotes.map(_.data.controversialScore) must_=== List(Post.ControversialScore(0), - Post.ControversialScore(0), - Post.ControversialScore(0), - Post.ControversialScore(0) + secondsVotes.map(_.data.controversialScore) === List(Post.ControversialScore(0), + Post.ControversialScore(0), + Post.ControversialScore(0), + Post.ControversialScore(0) ) - thirdsVotes.map(_.data.totalScore) must_=== List(Post.TotalScore(0), - Post.TotalScore(1), - Post.TotalScore(2), - Post.TotalScore(1) + thirdsVotes.map(_.data.totalScore) === List(Post.TotalScore(0), + Post.TotalScore(1), + Post.TotalScore(2), + Post.TotalScore(1) ) - thirdsVotes.map(_.data.controversialScore) must_=== List(Post.ControversialScore(1), - Post.ControversialScore(0), - Post.ControversialScore(0), - Post.ControversialScore(0) + thirdsVotes.map(_.data.controversialScore) === List(Post.ControversialScore(1), + Post.ControversialScore(0), + Post.ControversialScore(0), + Post.ControversialScore(0) ) } } @@ -202,13 +202,13 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit "allow delete and restore of a created Post" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() creationData <- (0 until 3).toList.traverse(_ => postCreate(channelID)) editorID <- editorIDCreate // when toCreate <- creationData.traverse(discussionsWrites.postWrites.createPost) - ids = toCreate.map(_.id) + ids = toCreate.map(_.unwrap) _ <- ids.traverse(discussionsReads.postReads.requireById(_)).eventually() _ <- ids.map(Post.Delete(_, editorID)).traverse(discussionsWrites.postWrites.deletePost) _ <- ids @@ -243,39 +243,47 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit "paginate newest Posts by Channels" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - channel2ID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channel2ID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channel2ID).eventually() paginatedData <- (0 until 20).toList.traverse(_ => postCreate(channelID)) - paginatedIDs <- paginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.unwrap)) nonPaginatedData <- (0 until 20).toList.traverse(_ => postCreate(channel2ID)) - nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.id)) + nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.unwrap)) _ <- (paginatedIDs ++ nonPaginatedIds).traverse(discussionsReads.postReads.requireById(_)).eventually() channels = NonEmptySet.of(channelID) // when - pagination <- discussionsReads.postReads.paginate(channels, Post.Sorting.Newest, 0L, 10) - pagination2 <- discussionsReads.postReads.paginate(channels, Post.Sorting.Newest, 10L, 10) + pagination <- discussionsReads.postReads.paginate(channels, + Post.Sorting.Newest, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.postReads.paginate(channels, + Post.Sorting.Newest, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } "paginate hottest Posts by Channels" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - channel2ID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channel2ID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channel2ID).eventually() paginatedData <- (0 until 20).toList.traverse(_ => postCreate(channelID)) - paginatedIDs <- paginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.unwrap)) nonPaginatedData <- (0 until 20).toList.traverse(_ => postCreate(channel2ID)) - nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.id)) + nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.unwrap)) _ <- (paginatedIDs ++ nonPaginatedIds).traverse(discussionsReads.postReads.requireById(_)).eventually() user1ID <- voterIDCreate user2ID <- voterIDCreate @@ -283,32 +291,40 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit _ <- paginatedIDs.take(10).traverse(id => discussionsWrites.postWrites.upvotePost(Post.Upvote(id, user2ID))) _ <- paginatedIDs .traverse(discussionsReads.postReads.requireById(_)) - .assert("Votes should be eventually applied")(_.forall(_.data.totalScore.toInt > 0)) + .assert("Votes should be eventually applied")(_.forall(_.data.totalScore.unwrap > 0)) .eventually(delay = 1.second) channels = NonEmptySet.of(channelID) // when - pagination <- discussionsReads.postReads.paginate(channels, Post.Sorting.Hottest, 0L, 10) - pagination2 <- discussionsReads.postReads.paginate(channels, Post.Sorting.Hottest, 10L, 10) + pagination <- discussionsReads.postReads.paginate(channels, + Post.Sorting.Hottest, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.postReads.paginate(channels, + Post.Sorting.Hottest, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } "paginate controversial Posts by Channels" in { for { // given - channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channelID).eventually() - channel2ID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channel2ID <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) _ <- discussionsReads.channelReads.requireById(channel2ID).eventually() paginatedData <- (0 until 20).toList.traverse(_ => postCreate(channelID)) - paginatedIDs <- paginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.id)) + paginatedIDs <- paginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.unwrap)) nonPaginatedData <- (0 until 20).toList.traverse(_ => postCreate(channel2ID)) - nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.id)) + nonPaginatedIds <- nonPaginatedData.traverse(discussionsWrites.postWrites.createPost).map(_.map(_.unwrap)) _ <- (paginatedIDs ++ nonPaginatedIds).traverse(discussionsReads.postReads.requireById(_)).eventually() user1ID <- voterIDCreate user2ID <- voterIDCreate @@ -317,19 +333,27 @@ final class PostReadsWritesSpec extends Specification with DiscussionsIOTest wit _ <- paginatedIDs .traverse(discussionsReads.postReads.requireById(_)) .assert("Votes should be eventually applied")( - _.forall(e => e.data.totalScore.toInt > 0 || e.data.controversialScore.toNonNegativeInt.value > 0) + _.forall(e => e.data.totalScore.unwrap > 0 || e.data.controversialScore.unwrap > 0) ) .eventually(delay = 1.second) channels = NonEmptySet.of(channelID) // when - pagination <- discussionsReads.postReads.paginate(channels, Post.Sorting.Controversial, 0L, 10) - pagination2 <- discussionsReads.postReads.paginate(channels, Post.Sorting.Controversial, 10L, 10) + pagination <- discussionsReads.postReads.paginate(channels, + Post.Sorting.Controversial, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- discussionsReads.postReads.paginate(channels, + Post.Sorting.Controversial, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } } diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/SubscriptionReadsWritesSpec.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/SubscriptionReadsWritesSpec.scala similarity index 75% rename from modules/discussions-impl/src/it/scala/io/branchtalk/discussions/SubscriptionReadsWritesSpec.scala rename to modules/discussions-impl/src/test/scala/io/branchtalk/discussions/SubscriptionReadsWritesSpec.scala index 79a83786..ecb6722a 100644 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/SubscriptionReadsWritesSpec.scala +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/SubscriptionReadsWritesSpec.scala @@ -1,12 +1,13 @@ package io.branchtalk.discussions import io.branchtalk.discussions.model.Subscription -import io.branchtalk.shared.model.TestUUIDGenerator +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import org.specs2.mutable.Specification -final class SubscriptionReadsWritesSpec extends Specification with DiscussionsIOTest with DiscussionsFixtures { +final class SubscriptionReadsWritesSpec extends Specification, DiscussionsIOTest, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "Subscription Reads & Writes" should { @@ -15,18 +16,18 @@ final class SubscriptionReadsWritesSpec extends Specification with DiscussionsIO // given subscriberID <- subscriberIDCreate ids <- (0 until 3).toList.traverse { _ => - channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) } _ <- ids.traverse(discussionsReads.channelReads.requireById(_)).eventually() // when _ <- discussionsWrites.subscriptionWrites.subscribe(Subscription.Subscribe(subscriberID, ids.toSet)) subscription <- discussionsReads.subscriptionReads .requireForUser(subscriberID) - .assert("Subscriptions should be eventually added")(_.subscriptions === ids.toSet) + .assert("Subscriptions should be eventually added")(_.subscriptions eqv ids.toSet) .eventually() } yield // then - subscription must_=== Subscription(subscriberID, ids.toSet) + subscription === Subscription(subscriberID, ids.toSet) } "remove Subscription and eventually read it" in { @@ -34,17 +35,17 @@ final class SubscriptionReadsWritesSpec extends Specification with DiscussionsIO // given subscriberID <- subscriberIDCreate idsToKeep <- (0 until 3).toList.traverse { _ => - channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) } idsToRemove <- (0 until 3).toList.traverse { _ => - channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) } - ids = (idsToKeep ++ idsToRemove) + ids = idsToKeep ++ idsToRemove _ <- ids.traverse(discussionsReads.channelReads.requireById(_)).eventually() _ <- discussionsWrites.subscriptionWrites.subscribe(Subscription.Subscribe(subscriberID, ids.toSet)) _ <- discussionsReads.subscriptionReads .requireForUser(subscriberID) - .assert("Subscriptions should be eventually added")(_.subscriptions === ids.toSet) + .assert("Subscriptions should be eventually added")(_.subscriptions eqv ids.toSet) .eventually() // when Subscription.Scheduled(left) <- discussionsWrites.subscriptionWrites.unsubscribe( @@ -52,12 +53,12 @@ final class SubscriptionReadsWritesSpec extends Specification with DiscussionsIO ) subscription <- discussionsReads.subscriptionReads .requireForUser(subscriberID) - .assert("Subscriptions should be eventually deleted")(_.subscriptions === idsToKeep.toSet) + .assert("Subscriptions should be eventually deleted")(_.subscriptions eqv idsToKeep.toSet) .eventually() } yield { // then - left must_=== Subscription(subscriberID, idsToKeep.toSet) - subscription must_=== Subscription(subscriberID, idsToKeep.toSet) + left === Subscription(subscriberID, idsToKeep.toSet) + subscription === Subscription(subscriberID, idsToKeep.toSet) } } } diff --git a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/TestDiscussionsConfig.scala b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/TestDiscussionsConfig.scala similarity index 57% rename from modules/discussions-impl/src/it/scala/io/branchtalk/discussions/TestDiscussionsConfig.scala rename to modules/discussions-impl/src/test/scala/io/branchtalk/discussions/TestDiscussionsConfig.scala index c77097c4..9f51b3b4 100644 --- a/modules/discussions-impl/src/it/scala/io/branchtalk/discussions/TestDiscussionsConfig.scala +++ b/modules/discussions-impl/src/test/scala/io/branchtalk/discussions/TestDiscussionsConfig.scala @@ -1,23 +1,15 @@ package io.branchtalk.discussions import cats.effect.{ Async, Resource, Sync } -import io.branchtalk.shared.infrastructure.{ - DomainConfig, - DomainName, - KafkaEventConsumerConfig, - TestKafkaEventBusConfig, - TestPostgresConfig, - TestResources -} -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.scalaland.catnip.Semi +import io.branchtalk.shared.infrastructure.* +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } -@Semi(ConfigReader) final case class TestDiscussionsConfig( +final case class TestDiscussionsConfig( database: TestPostgresConfig, publishedEventBus: TestKafkaEventBusConfig, internalEventBus: TestKafkaEventBusConfig, - consumers: Map[String, KafkaEventConsumerConfig] -) + consumers: Map[String, KafkaEventBus.ConsumerConfig] +) derives ConfigReader object TestDiscussionsConfig { def load[F[_]: Sync]: Resource[F, TestDiscussionsConfig] = @@ -27,11 +19,17 @@ object TestDiscussionsConfig { ) ) - def loadDomainConfig[F[_]: Async]: Resource[F, DomainConfig] = + def loadDomainConfig[F[_]: Async]: Resource[F, DomainModule.Config] = for { TestDiscussionsConfig(dbTest, publishedESTest, internalESTest, consumers) <- TestDiscussionsConfig.load[F] db <- TestResources.postgresConfigResource[F](dbTest) publishedES <- TestResources.kafkaEventBusConfigResource[F](publishedESTest) internalES <- TestResources.kafkaEventBusConfigResource[F](internalESTest) - } yield DomainConfig(DomainName("discussions-test"), db, db, publishedES, internalES, consumers) + } yield DomainModule.Config(DomainModule.Name.unsafeMake("discussions-test"), + db, + db, + publishedES, + internalES, + consumers + ) } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/ChannelEvent.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/ChannelEvent.scala index 1da865df..981b84d7 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/ChannelEvent.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/ChannelEvent.scala @@ -1,17 +1,15 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.discussions.model.{ Channel, User } -import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ -import io.scalaland.catnip.Semi +import io.branchtalk.logging.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait ChannelEvent extends ADT +sealed trait ChannelEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object ChannelEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Created( + final case class Created( id: ID[Channel], authorID: ID[User], urlName: Channel.UrlName, @@ -19,9 +17,9 @@ object ChannelEvent { description: Option[Channel.Description], createdAt: CreationTime, correlationID: CorrelationID - ) extends ChannelEvent + ) extends ChannelEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Updated( + final case class Updated( id: ID[Channel], editorID: ID[User], newUrlName: Updatable[Channel.UrlName], @@ -29,17 +27,17 @@ object ChannelEvent { newDescription: OptionUpdatable[Channel.Description], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends ChannelEvent + ) extends ChannelEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Deleted( + final case class Deleted( id: ID[Channel], editorID: ID[User], correlationID: CorrelationID - ) extends ChannelEvent + ) extends ChannelEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Restored( + final case class Restored( id: ID[Channel], editorID: ID[User], correlationID: CorrelationID - ) extends ChannelEvent + ) extends ChannelEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/CommentEvent.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/CommentEvent.scala index 4c7553df..7a4d53be 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/CommentEvent.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/CommentEvent.scala @@ -1,17 +1,15 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.discussions.model.{ Channel, Comment, Post, User } -import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ -import io.scalaland.catnip.Semi +import io.branchtalk.logging.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait CommentEvent extends ADT +sealed trait CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object CommentEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Created( + final case class Created( id: ID[Comment], authorID: ID[User], channelID: ID[Channel], @@ -20,43 +18,43 @@ object CommentEvent { replyTo: Option[ID[Comment]], createdAt: CreationTime, correlationID: CorrelationID - ) extends CommentEvent + ) extends CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Updated( + final case class Updated( id: ID[Comment], editorID: ID[User], newContent: Updatable[Comment.Content], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends CommentEvent + ) extends CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Deleted( + final case class Deleted( id: ID[Comment], editorID: ID[User], correlationID: CorrelationID - ) extends CommentEvent + ) extends CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Restored( + final case class Restored( id: ID[Comment], editorID: ID[User], correlationID: CorrelationID - ) extends CommentEvent + ) extends CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Upvoted( + final case class Upvoted( id: ID[Comment], voterID: ID[User], correlationID: CorrelationID - ) extends CommentEvent + ) extends CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Downvoted( + final case class Downvoted( id: ID[Comment], voterID: ID[User], correlationID: CorrelationID - ) extends CommentEvent + ) extends CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class VoteRevoked( + final case class VoteRevoked( id: ID[Comment], voterID: ID[User], correlationID: CorrelationID - ) extends CommentEvent + ) extends CommentEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/DiscussionEvent.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/DiscussionEvent.scala index 3700e785..431f8bf6 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/DiscussionEvent.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/DiscussionEvent.scala @@ -1,23 +1,25 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ -import io.scalaland.catnip.Semi +import com.sksamuel.avro4s.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait DiscussionEvent extends ADT +sealed trait DiscussionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object DiscussionEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) - final case class ForChannel(channel: ChannelEvent) extends DiscussionEvent + final case class ForChannel( + channel: ChannelEvent + ) extends DiscussionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) - final case class ForComment(comment: CommentEvent) extends DiscussionEvent + final case class ForComment( + comment: CommentEvent + ) extends DiscussionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(FastEq, ShowPretty, SchemaFor) - final case class ForPost(post: PostEvent) extends DiscussionEvent + final case class ForPost( + post: PostEvent + ) extends DiscussionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(FastEq, ShowPretty, SchemaFor) - final case class ForSubscription(subscription: SubscriptionEvent) extends DiscussionEvent + final case class ForSubscription( + subscription: SubscriptionEvent + ) extends DiscussionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/PostEvent.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/PostEvent.scala index f56f2913..b0398671 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/PostEvent.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/PostEvent.scala @@ -1,17 +1,15 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.discussions.model.{ Channel, Post, User } -import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ -import io.scalaland.catnip.Semi +import io.branchtalk.logging.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait PostEvent extends ADT +sealed trait PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object PostEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Created( + final case class Created( id: ID[Post], authorID: ID[User], channelID: ID[Channel], @@ -20,9 +18,9 @@ object PostEvent { content: Post.Content, createdAt: CreationTime, correlationID: CorrelationID - ) extends PostEvent + ) extends PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Updated( + final case class Updated( id: ID[Post], editorID: ID[User], newUrlTitle: Updatable[Post.UrlTitle], @@ -30,35 +28,35 @@ object PostEvent { newContent: Updatable[Post.Content], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends PostEvent + ) extends PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Deleted( + final case class Deleted( id: ID[Post], editorID: ID[User], correlationID: CorrelationID - ) extends PostEvent + ) extends PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Restored( + final case class Restored( id: ID[Post], editorID: ID[User], correlationID: CorrelationID - ) extends PostEvent + ) extends PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Upvoted( + final case class Upvoted( id: ID[Post], voterID: ID[User], correlationID: CorrelationID - ) extends PostEvent + ) extends PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Downvoted( + final case class Downvoted( id: ID[Post], voterID: ID[User], correlationID: CorrelationID - ) extends PostEvent + ) extends PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class VoteRevoked( + final case class VoteRevoked( id: ID[Post], voterID: ID[User], correlationID: CorrelationID - ) extends PostEvent + ) extends PostEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/SubscriptionEvent.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/SubscriptionEvent.scala index 11b39958..2c84e2fd 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/events/SubscriptionEvent.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/events/SubscriptionEvent.scala @@ -1,27 +1,25 @@ package io.branchtalk.discussions.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.discussions.model.{ Channel, User } import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ -import io.scalaland.catnip.Semi +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait SubscriptionEvent extends ADT +sealed trait SubscriptionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object SubscriptionEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Subscribed( + final case class Subscribed( subscriberID: ID[User], subscriptions: Set[ID[Channel]], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends SubscriptionEvent + ) extends SubscriptionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Unsubscribed( + final case class Unsubscribed( subscriberID: ID[User], subscriptions: Set[ID[Channel]], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends SubscriptionEvent + ) extends SubscriptionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Channel.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Channel.scala index dd3a9d31..d9eefa0a 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Channel.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Channel.scala @@ -1,19 +1,90 @@ package io.branchtalk.discussions.model -import io.scalaland.catnip.Semi -import io.branchtalk.shared.model.{ CreationTime, FastEq, ID, ModificationTime, ShowPretty } +import cats.{ Order, Show } +import io.branchtalk.shared.model.* -@Semi(FastEq, ShowPretty) final case class Channel( +final case class Channel( id: ID[Channel], data: Channel.Data -) -object Channel extends ChannelProperties with ChannelCommands { +) derives FastEq, + ShowPretty +object Channel { - @Semi(FastEq, ShowPretty) final case class Data( + final case class Data( urlName: Channel.UrlName, name: Channel.Name, description: Option[Channel.Description], createdAt: CreationTime, lastModifiedAt: Option[ModificationTime] - ) + ) derives FastEq, + ShowPretty + + final case class Create( + authorID: ID[User], + urlName: Channel.UrlName, + name: Channel.Name, + description: Option[Channel.Description] + ) derives FastEq, + ShowPretty + + final case class Update( + id: ID[Channel], + editorID: ID[User], + newUrlName: Updatable[Channel.UrlName], + newName: Updatable[Channel.Name], + newDescription: OptionUpdatable[Channel.Description] + ) derives FastEq, + ShowPretty + + final case class Delete( + id: ID[Channel], + editorID: ID[User] + ) derives FastEq, + ShowPretty + + final case class Restore( + id: ID[Channel], + editorID: ID[User] + ) derives FastEq, + ShowPretty + + type UrlName = UrlName.Type + object UrlName extends Newtype[String] { + + private val pattern = "[A-Za-z0-9_-]+".r + + override inline def validate(input: String): Boolean = pattern.matches(input) + + def unapply(urlName: UrlName): Some[String] = Some(urlName.unwrap) + + given Show[UrlName] = unsafeMakeF[Show](Show[String]) + given Order[UrlName] = unsafeMakeF[Order](Order[String]) + } + + type Name = Name.Type + object Name extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(name: Name): Some[String] = Some(name.unwrap) + + given Show[Name] = unsafeMakeF[Show](Show[String]) + given Order[Name] = unsafeMakeF[Order](Order[String]) + } + + type Description = Description.Type + object Description extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(description: Description): Some[String] = Some(description.unwrap) + + given Show[Description] = unsafeMakeF[Show](Show[String]) + given Order[Description] = unsafeMakeF[Order](Order[String]) + } + + enum Sorting derives FastEq, ShowPretty { + case Newest + case Alphabetically + } } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/ChannelCommands.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/ChannelCommands.scala deleted file mode 100644 index 84a3475d..00000000 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/ChannelCommands.scala +++ /dev/null @@ -1,42 +0,0 @@ -package io.branchtalk.discussions.model - -import io.scalaland.catnip.Semi -import io.branchtalk.shared.model.{ FastEq, ID, OptionUpdatable, ShowPretty, Updatable } - -trait ChannelCommands { self: Channel.type => - type Create = ChannelCommands.Create - type Update = ChannelCommands.Update - type Delete = ChannelCommands.Delete - type Restore = ChannelCommands.Restore - val Create = ChannelCommands.Create - val Update = ChannelCommands.Update - val Delete = ChannelCommands.Delete - val Restore = ChannelCommands.Restore -} -object ChannelCommands { - - @Semi(FastEq, ShowPretty) final case class Create( - authorID: ID[User], - urlName: Channel.UrlName, - name: Channel.Name, - description: Option[Channel.Description] - ) - - @Semi(FastEq, ShowPretty) final case class Update( - id: ID[Channel], - editorID: ID[User], - newUrlName: Updatable[Channel.UrlName], - newName: Updatable[Channel.Name], - newDescription: OptionUpdatable[Channel.Description] - ) - - @Semi(FastEq, ShowPretty) final case class Delete( - id: ID[Channel], - editorID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class Restore( - id: ID[Channel], - editorID: ID[User] - ) -} diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/ChannelProperties.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/ChannelProperties.scala deleted file mode 100644 index 31a4043d..00000000 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/ChannelProperties.scala +++ /dev/null @@ -1,64 +0,0 @@ -package io.branchtalk.discussions.model - -import cats.{ Order, Show } -import cats.effect.Sync -import enumeratum._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.string.MatchesRegex -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.shared.model._ -import io.estatico.newtype.macros.newtype - -trait ChannelProperties { self: Channel.type => - type UrlName = ChannelProperties.UrlName - type Name = ChannelProperties.Name - type Description = ChannelProperties.Description - type Sorting = ChannelProperties.Sorting - val UrlName = ChannelProperties.UrlName - val Name = ChannelProperties.Name - val Description = ChannelProperties.Description - val Sorting = ChannelProperties.Sorting -} -object ChannelProperties { - - @newtype final case class UrlName(urlString: String Refined MatchesRegex[UrlName.Pattern]) - object UrlName { - type Pattern = "[A-Za-z0-9_-]+" - - def unapply(urlName: UrlName): Some[String Refined MatchesRegex[Pattern]] = Some(urlName.urlString) - def parse[F[_]: Sync](string: String): F[UrlName] = - ParseRefined[F].parse[MatchesRegex[Pattern]](string).map(UrlName.apply) - - implicit val show: Show[UrlName] = Show.wrap(_.urlString.value) - implicit val order: Order[UrlName] = Order.by(_.urlString.value) - } - - @newtype final case class Name(nonEmptyString: NonEmptyString) - object Name { - def unapply(name: Name): Some[NonEmptyString] = Some(name.nonEmptyString) - def parse[F[_]: Sync](string: String): F[Name] = - ParseRefined[F].parse[NonEmpty](string).map(Name.apply) - - implicit val show: Show[Name] = Show.wrap(_.nonEmptyString.value) - implicit val order: Order[Name] = Order.by(_.nonEmptyString.value) - } - - @newtype final case class Description(nonEmptyString: NonEmptyString) - object Description { - def unapply(description: Description): Some[NonEmptyString] = Some(description.nonEmptyString) - def parse[F[_]: Sync](string: String): F[Description] = - ParseRefined[F].parse[NonEmpty](string).map(Description.apply) - - implicit val show: Show[Description] = Show.wrap(_.nonEmptyString.value) - implicit val eq: Order[Description] = Order.by(_.nonEmptyString.value) - } - - sealed trait Sorting extends EnumEntry - object Sorting extends Enum[Sorting] { - case object Newest extends Sorting - case object Alphabetically extends Sorting - - val values = findValues - } -} diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Comment.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Comment.scala index 2941d594..2be87756 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Comment.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Comment.scala @@ -1,15 +1,21 @@ package io.branchtalk.discussions.model -import io.scalaland.catnip.Semi -import io.branchtalk.shared.model._ +import java.net.URI -@Semi(FastEq, ShowPretty) final case class Comment( +import cats.effect.Sync +import cats.{ Order, Show } +import enumeratum.* +import enumeratum.EnumEntry.Hyphencase +import io.branchtalk.shared.model.* + +final case class Comment( id: ID[Comment], data: Comment.Data -) -object Comment extends CommentProperties with CommentCommands { +) derives FastEq, + ShowPretty +object Comment { - @Semi(FastEq, ShowPretty) final case class Data( + final case class Data( authorID: ID[User], channelID: ID[Channel], postID: ID[Post], @@ -23,5 +29,128 @@ object Comment extends CommentProperties with CommentCommands { downvores: Comment.Downvotes, totalScore: Comment.TotalScore, controversialScore: Comment.ControversialScore - ) + ) derives FastEq, + ShowPretty + + final case class Create( + authorID: ID[User], + postID: ID[Post], + content: Comment.Content, + replyTo: Option[ID[Comment]] + ) derives FastEq, + ShowPretty + + final case class Update( + id: ID[Comment], + editorID: ID[User], + newContent: Updatable[Comment.Content] + ) derives FastEq, + ShowPretty + + final case class Delete( + id: ID[Comment], + editorID: ID[User] + ) derives FastEq, + ShowPretty + + final case class Restore( + id: ID[Comment], + editorID: ID[User] + ) derives FastEq, + ShowPretty + + final case class Upvote( + id: ID[Comment], + voterID: ID[User] + ) derives FastEq, + ShowPretty + + final case class Downvote( + id: ID[Comment], + voterID: ID[User] + ) derives FastEq, + ShowPretty + + final case class RevokeVote( + id: ID[Comment], + voterID: ID[User] + ) derives FastEq, + ShowPretty + + type Content = Content.Type + object Content extends Newtype[String] { + def unapply(content: Content): Some[String] = Some(content.unwrap) + + given Show[Content] = unsafeMakeF[Show](Show[String]) + given Order[Content] = unsafeMakeF[Order](Order[String]) + } + + type NestingLevel = NestingLevel.Type + object NestingLevel extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(nestingLevel: NestingLevel): Some[Int] = Some(nestingLevel.unwrap) + + given Show[NestingLevel] = unsafeMakeF[Show](Show[Int]) + given Order[NestingLevel] = unsafeMakeF[Order](Order[Int]) + } + + type RepliesNr = RepliesNr.Type + object RepliesNr extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(repliesNr: RepliesNr): Some[Int] = Some(repliesNr.unwrap) + + given Show[RepliesNr] = unsafeMakeF[Show](Show[Int]) + given Order[RepliesNr] = unsafeMakeF[Order](Order[Int]) + } + + type Upvotes = Upvotes.Type + object Upvotes extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(upvotes: Upvotes): Some[Int] = Some(upvotes.unwrap) + + given Show[Upvotes] = unsafeMakeF[Show](Show[Int]) + given Order[Upvotes] = unsafeMakeF[Order](Order[Int]) + } + + type Downvotes = Downvotes.Type + object Downvotes extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(downvotes: Downvotes): Some[Int] = Some(downvotes.unwrap) + + given Show[Downvotes] = unsafeMakeF[Show](Show[Int]) + given Order[Downvotes] = unsafeMakeF[Order](Order[Int]) + } + + type TotalScore = TotalScore.Type + object TotalScore extends Newtype[Int] { + def unapply(content: TotalScore): Some[Int] = Some(content.unwrap) + + given Show[TotalScore] = unsafeMakeF[Show](Show[Int]) + given Order[TotalScore] = unsafeMakeF[Order](Order[Int]) + } + + type ControversialScore = ControversialScore.Type + object ControversialScore extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(controversialScore: ControversialScore): Some[Int] = Some(controversialScore.unwrap) + + given Show[ControversialScore] = unsafeMakeF[Show](Show[Int]) + given Order[ControversialScore] = unsafeMakeF[Order](Order[Int]) + } + + enum Sorting derives FastEq, ShowPretty { + case Newest + case Hottest + case Controversial + } } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/CommentCommands.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/CommentCommands.scala deleted file mode 100644 index eb0a1b09..00000000 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/CommentCommands.scala +++ /dev/null @@ -1,61 +0,0 @@ -package io.branchtalk.discussions.model - -import io.scalaland.catnip.Semi -import io.branchtalk.shared.model.{ FastEq, ID, ShowPretty, Updatable } - -trait CommentCommands { self: Comment.type => - type Create = CommentCommands.Create - type Update = CommentCommands.Update - type Delete = CommentCommands.Delete - type Restore = CommentCommands.Restore - type Upvote = CommentCommands.Upvote - type Downvote = CommentCommands.Downvote - type RevokeVote = CommentCommands.RevokeVote - val Create = CommentCommands.Create - val Update = CommentCommands.Update - val Delete = CommentCommands.Delete - val Restore = CommentCommands.Restore - val Upvote = CommentCommands.Upvote - val Downvote = CommentCommands.Downvote - val RevokeVote = CommentCommands.RevokeVote -} -object CommentCommands { - - @Semi(FastEq, ShowPretty) final case class Create( - authorID: ID[User], - postID: ID[Post], - content: Comment.Content, - replyTo: Option[ID[Comment]] - ) - - @Semi(FastEq, ShowPretty) final case class Update( - id: ID[Comment], - editorID: ID[User], - newContent: Updatable[Comment.Content] - ) - - @Semi(FastEq, ShowPretty) final case class Delete( - id: ID[Comment], - editorID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class Restore( - id: ID[Comment], - editorID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class Upvote( - id: ID[Comment], - voterID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class Downvote( - id: ID[Comment], - voterID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class RevokeVote( - id: ID[Comment], - voterID: ID[User] - ) -} diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/CommentProperties.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/CommentProperties.scala deleted file mode 100644 index db634abe..00000000 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/CommentProperties.scala +++ /dev/null @@ -1,99 +0,0 @@ -package io.branchtalk.discussions.model - -import cats.{ Order, Show } -import cats.effect.Sync -import enumeratum._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.NonNegative -import io.branchtalk.shared.model._ -import io.estatico.newtype.macros.newtype -import io.estatico.newtype.ops._ - -trait CommentProperties { self: Comment.type => - type Content = CommentProperties.Content - type NestingLevel = CommentProperties.NestingLevel - type RepliesNr = CommentProperties.RepliesNr - type Upvotes = CommentProperties.Upvotes - type Downvotes = CommentProperties.Downvotes - type TotalScore = CommentProperties.TotalScore - type ControversialScore = CommentProperties.ControversialScore - type Sorting = CommentProperties.Sorting - val Content = CommentProperties.Content - val NestingLevel = CommentProperties.NestingLevel - val RepliesNr = CommentProperties.RepliesNr - val Upvotes = CommentProperties.Upvotes - val Downvotes = CommentProperties.Downvotes - val TotalScore = CommentProperties.TotalScore - val ControversialScore = CommentProperties.ControversialScore - val Sorting = CommentProperties.Sorting -} -object CommentProperties { - - @newtype final case class Content(string: String) - object Content { - def unapply(content: Content): Some[String] = Some(content.string) - - implicit val show: Show[Content] = Show.wrap(_.string) - implicit val order: Order[Content] = Order[String].coerce - } - - @newtype final case class NestingLevel(nonNegativeInt: Int Refined NonNegative) - object NestingLevel { - def unapply(nestingLevel: NestingLevel): Some[Int Refined NonNegative] = Some(nestingLevel.nonNegativeInt) - def parse[F[_]: Sync](int: Int): F[NestingLevel] = - ParseRefined[F].parse[NonNegative](int).map(NestingLevel.apply) - - implicit val show: Show[NestingLevel] = Show.wrap(_.nonNegativeInt.value) - implicit val order: Order[NestingLevel] = Order.by(_.nonNegativeInt.value) - } - - @newtype final case class RepliesNr(toNonNegativeInt: Int Refined NonNegative) - object RepliesNr { - def unapply(repliesNr: RepliesNr): Some[Int Refined NonNegative] = Some(repliesNr.toNonNegativeInt) - - implicit val show: Show[RepliesNr] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[RepliesNr] = Order.by(_.toNonNegativeInt.value) - } - - @newtype final case class Upvotes(toNonNegativeInt: Int Refined NonNegative) - object Upvotes { - def unapply(upvotes: Upvotes): Some[Int Refined NonNegative] = Some(upvotes.toNonNegativeInt) - - implicit val show: Show[Upvotes] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[Upvotes] = Order.by(_.toNonNegativeInt.value) - } - - @newtype final case class Downvotes(toNonNegativeInt: Int Refined NonNegative) - object Downvotes { - def unapply(downvotes: Downvotes): Some[Int Refined NonNegative] = Some(downvotes.toNonNegativeInt) - - implicit val show: Show[Downvotes] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[Downvotes] = Order.by(_.toNonNegativeInt.value) - } - - @newtype final case class TotalScore(toInt: Int) - object TotalScore { - def unapply(totalScore: TotalScore): Some[Int] = Some(totalScore.toInt) - - implicit val show: Show[TotalScore] = Show.wrap(_.toInt) - implicit val order: Order[TotalScore] = Order[Int].coerce - } - - @newtype final case class ControversialScore(toNonNegativeInt: Int Refined NonNegative) - object ControversialScore { - def unapply(controversialScore: ControversialScore): Some[Int Refined NonNegative] = - Some(controversialScore.toNonNegativeInt) - - implicit val show: Show[ControversialScore] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[ControversialScore] = Order.by(_.toNonNegativeInt.value) - } - - sealed trait Sorting extends EnumEntry - object Sorting extends Enum[Sorting] { - case object Newest extends Sorting - case object Hottest extends Sorting - case object Controversial extends Sorting - - val values = findValues - } -} diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Post.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Post.scala index 33c97a52..186c2b4d 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Post.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Post.scala @@ -1,15 +1,21 @@ package io.branchtalk.discussions.model -import io.scalaland.catnip.Semi -import io.branchtalk.shared.model._ +import java.net.URI -@Semi(FastEq, ShowPretty) final case class Post( +import cats.effect.Sync +import cats.{ Order, Show } +import enumeratum.* +import enumeratum.EnumEntry.Hyphencase +import io.branchtalk.shared.model.* + +final case class Post( id: ID[Post], data: Post.Data -) -object Post extends PostProperties with PostCommands { +) derives FastEq, + ShowPretty +object Post { - @Semi(FastEq, ShowPretty) final case class Data( + final case class Data( authorID: ID[User], channelID: ID[Channel], urlTitle: Post.UrlTitle, @@ -22,5 +28,184 @@ object Post extends PostProperties with PostCommands { downvotes: Post.Downvotes, totalScore: Post.TotalScore, controversialScore: Post.ControversialScore - ) + ) derives FastEq, + ShowPretty + + final case class Create( + authorID: ID[User], + channelID: ID[Channel], + title: Post.Title, + content: Post.Content + ) derives FastEq, + ShowPretty + + final case class Update( + id: ID[Post], + editorID: ID[User], + newTitle: Updatable[Post.Title], + newContent: Updatable[Post.Content] + ) derives FastEq, + ShowPretty + + final case class Delete( + id: ID[Post], + editorID: ID[User] + ) derives FastEq, + ShowPretty + + final case class Restore( + id: ID[Post], + editorID: ID[User] + ) derives FastEq, + ShowPretty + + final case class Upvote( + id: ID[Post], + voterID: ID[User] + ) derives FastEq, + ShowPretty + + final case class Downvote( + id: ID[Post], + voterID: ID[User] + ) derives FastEq, + ShowPretty + + final case class RevokeVote( + id: ID[Post], + voterID: ID[User] + ) derives FastEq, + ShowPretty + + type UrlTitle = UrlTitle.Type + object UrlTitle extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(name: UrlTitle): Some[String] = Some(name.unwrap) + + given Show[UrlTitle] = unsafeMakeF[Show](Show[String]) + given Order[UrlTitle] = unsafeMakeF[Order](Order[String]) + } + + type Title = Title.Type + object Title extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(name: Title): Some[String] = Some(name.unwrap) + + given Show[Title] = unsafeMakeF[Show](Show[String]) + given Order[Title] = unsafeMakeF[Order](Order[String]) + } + + type URL = URL.Type + object URL extends Newtype[URI] { + def unapply(name: URL): Some[URI] = Some(name.unwrap) + + @SuppressWarnings(Array("org.wartremover.warts.ToString")) // false warning - URI overrides toString + given Show[URL] = _.unwrap.toString + given Order[URL] = unsafeMakeF[Order](Order.fromComparable[URI]) + } + + type Text = Text.Type + object Text extends Newtype[String] { + def unapply(name: Text): Some[String] = Some(name.unwrap) + + given Show[Text] = unsafeMakeF[Show](Show[String]) // without wrapper because it lives only within Context.Text + given Order[Text] = unsafeMakeF[Order](Order[String]) + } + + enum Content derives FastEq, ShowPretty { + case Url(url: Post.URL) + case Text(text: Post.Text) + } + object Content { + + enum Type extends EnumEntry, Hyphencase derives FastEq, ShowPretty { + case Url + case Text + } + + type Raw = Raw.Type + object Raw extends Newtype[String] { + def unapply(name: Raw): Some[String] = Some(name.unwrap) + + given Show[Raw] = unsafeMakeF[Show](Show[String]) + given Order[Raw] = unsafeMakeF[Order](Order[String]) + } + + object Tupled { + def apply(contentType: Type, contentText: Raw): Content = contentType match { + case Type.Url => Content.Url(Post.URL.unsafeMake(URI.create(contentText.unwrap))) + case Type.Text => Content.Text(Post.Text.unsafeMake(contentText.unwrap)) + } + + @SuppressWarnings(Array("org.wartremover.warts.ToString")) // false warning - URI overrides toString + def unpack(content: Content): (Type, Raw) = content match { + case Content.Url(url) => Type.Url -> Raw.unsafeMake(url.unwrap.toString) + case Content.Text(text) => Type.Text -> Raw.unsafeMake(text.unwrap) + } + + def unapply(content: Content): Some[(Type, Raw)] = Some(unpack(content)) + } + } + + type CommentsNr = CommentsNr.Type + object CommentsNr extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(downvotes: CommentsNr): Some[Int] = Some(downvotes.unwrap) + + given Show[CommentsNr] = unsafeMakeF[Show](Show[Int]) + given Order[CommentsNr] = unsafeMakeF[Order](Order[Int]) + } + + type Upvotes = Upvotes.Type + object Upvotes extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(downvotes: Upvotes): Some[Int] = Some(downvotes.unwrap) + + given Show[Upvotes] = unsafeMakeF[Show](Show[Int]) + given Order[Upvotes] = unsafeMakeF[Order](Order[Int]) + } + + type Downvotes = Downvotes.Type + object Downvotes extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(downvotes: Downvotes): Some[Int] = Some(downvotes.unwrap) + + given Show[Downvotes] = unsafeMakeF[Show](Show[Int]) + given Order[Downvotes] = unsafeMakeF[Order](Order[Int]) + } + + type TotalScore = TotalScore.Type + object TotalScore extends Newtype[Int] { + def unapply(content: TotalScore): Some[Int] = Some(content.unwrap) + + given Show[TotalScore] = unsafeMakeF[Show](Show[Int]) + given Order[TotalScore] = unsafeMakeF[Order](Order[Int]) + } + + type ControversialScore = ControversialScore.Type + object ControversialScore extends Newtype[Int] { + + override inline def validate(input: Int): Boolean = input >= 0 + + def unapply(controversialScore: ControversialScore): Some[Int] = Some(controversialScore.unwrap) + + given Show[ControversialScore] = unsafeMakeF[Show](Show[Int]) + given Order[ControversialScore] = unsafeMakeF[Order](Order[Int]) + } + + enum Sorting derives FastEq, ShowPretty { + case Newest + case Hottest + case Controversial + } } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/PostCommands.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/PostCommands.scala deleted file mode 100644 index 0710434c..00000000 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/PostCommands.scala +++ /dev/null @@ -1,62 +0,0 @@ -package io.branchtalk.discussions.model - -import io.scalaland.catnip.Semi -import io.branchtalk.shared.model.{ FastEq, ID, ShowPretty, Updatable } - -trait PostCommands { self: Post.type => - type Create = PostCommands.Create - type Update = PostCommands.Update - type Delete = PostCommands.Delete - type Restore = PostCommands.Restore - type Upvote = PostCommands.Upvote - type Downvote = PostCommands.Downvote - type RevokeVote = PostCommands.RevokeVote - val Create = PostCommands.Create - val Update = PostCommands.Update - val Delete = PostCommands.Delete - val Restore = PostCommands.Restore - val Upvote = PostCommands.Upvote - val Downvote = PostCommands.Downvote - val RevokeVote = PostCommands.RevokeVote -} -object PostCommands { - - @Semi(FastEq, ShowPretty) final case class Create( - authorID: ID[User], - channelID: ID[Channel], - title: Post.Title, - content: Post.Content - ) - - @Semi(FastEq, ShowPretty) final case class Update( - id: ID[Post], - editorID: ID[User], - newTitle: Updatable[Post.Title], - newContent: Updatable[Post.Content] - ) - - @Semi(FastEq, ShowPretty) final case class Delete( - id: ID[Post], - editorID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class Restore( - id: ID[Post], - editorID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class Upvote( - id: ID[Post], - voterID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class Downvote( - id: ID[Post], - voterID: ID[User] - ) - - @Semi(FastEq, ShowPretty) final case class RevokeVote( - id: ID[Post], - voterID: ID[User] - ) -} diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/PostProperties.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/PostProperties.scala deleted file mode 100644 index 866e3fb4..00000000 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/PostProperties.scala +++ /dev/null @@ -1,164 +0,0 @@ -package io.branchtalk.discussions.model - -import java.net.URI - -import cats.effect.Sync -import cats.{ Order, Show } -import enumeratum._ -import enumeratum.EnumEntry.Hyphencase -import io.branchtalk.ADT -import io.branchtalk.shared.model._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.numeric.NonNegative -import eu.timepit.refined.types.string.NonEmptyString -import io.estatico.newtype.macros.newtype -import io.scalaland.catnip.Semi - -trait PostProperties { self: Post.type => - type UrlTitle = PostProperties.UrlTitle - type Title = PostProperties.Title - type URL = PostProperties.URL - type Text = PostProperties.Text - type Content = PostProperties.Content - type CommentsNr = PostProperties.CommentsNr - type Upvotes = PostProperties.Upvotes - type Downvotes = PostProperties.Downvotes - type TotalScore = PostProperties.TotalScore - type ControversialScore = PostProperties.ControversialScore - type Sorting = PostProperties.Sorting - val UrlTitle = PostProperties.UrlTitle - val Title = PostProperties.Title - val URL = PostProperties.URL - val Text = PostProperties.Text - val Content = PostProperties.Content - val CommentsNr = PostProperties.CommentsNr - val Upvotes = PostProperties.Upvotes - val Downvotes = PostProperties.Downvotes - val TotalScore = PostProperties.TotalScore - val ControversialScore = PostProperties.ControversialScore - val Sorting = PostProperties.Sorting -} -object PostProperties { - - @newtype final case class UrlTitle(nonEmptyString: NonEmptyString) - object UrlTitle { - def unapply(urlTitle: UrlTitle): Some[NonEmptyString] = Some(urlTitle.nonEmptyString) - def parse[F[_]: Sync](string: String): F[UrlTitle] = - ParseRefined[F].parse[NonEmpty](string).map(UrlTitle.apply) - - implicit val show: Show[UrlTitle] = Show.wrap(_.nonEmptyString.value) - implicit val order: Order[UrlTitle] = Order.by(_.nonEmptyString.value) - } - - @newtype final case class Title(nonEmptyString: NonEmptyString) - object Title { - def unapply(title: Title): Some[NonEmptyString] = Some(title.nonEmptyString) - def parse[F[_]: Sync](string: String): F[Title] = - ParseRefined[F].parse[NonEmpty](string).map(Title.apply) - - implicit val show: Show[Title] = Show.wrap(_.nonEmptyString.value) - implicit val order: Order[Title] = Order.by(_.nonEmptyString.value) - } - - @newtype final case class URL(uri: URI) - object URL { - def unapply(url: URL): Some[URI] = Some(url.uri) - - implicit val show: Show[URL] = _.uri.toString // without wrapper because it lives only within Context.Url - implicit val order: Order[URL] = Order.by[URL, URI](_.uri)(Order.fromComparable) - } - - @newtype final case class Text(string: String) - object Text { - def unapply(text: Text): Some[String] = Some(text.string) - - implicit val show: Show[Text] = _.string // without wrapper because it lives only within Context.Text - implicit val order: Order[Text] = Order.by(_.string) - } - - @Semi(FastEq, ShowPretty) sealed trait Content extends ADT - object Content { - final case class Url(url: Post.URL) extends Content - final case class Text(text: Post.Text) extends Content - - @Semi(FastEq, ShowPretty) sealed trait Type extends EnumEntry with Hyphencase - object Type extends Enum[Type] { - case object Url extends Type - case object Text extends Type - - val values: IndexedSeq[Type] = findValues - } - @newtype final case class Raw(string: String) - object Raw { - def unapply(raw: Raw): Some[String] = Some(raw.string) - - implicit val show: Show[Raw] = Show.wrap(_.string) - implicit val order: Order[Raw] = Order.by(_.string) - } - - object Tupled { - def apply(contentType: Type, contentText: Raw): Content = contentType match { - case Type.Url => Content.Url(Post.URL(URI.create(contentText.string))) - case Type.Text => Content.Text(Post.Text(contentText.string)) - } - - def unpack(content: Content): (Type, Raw) = content match { - case Content.Url(url) => Type.Url -> Raw(url.uri.toString) - case Content.Text(text) => Type.Text -> Raw(text.string) - } - - def unapply(content: Content): Some[(Type, Raw)] = Some(unpack(content)) - } - } - - @newtype final case class CommentsNr(toNonNegativeInt: Int Refined NonNegative) - object CommentsNr { - def unapply(commentsNr: CommentsNr): Some[Int Refined NonNegative] = Some(commentsNr.toNonNegativeInt) - - implicit val show: Show[CommentsNr] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[CommentsNr] = Order.by(_.toNonNegativeInt.value) - } - - @newtype final case class Upvotes(toNonNegativeInt: Int Refined NonNegative) - object Upvotes { - def unapply(upvotes: Upvotes): Some[Int Refined NonNegative] = Some(upvotes.toNonNegativeInt) - - implicit val show: Show[Upvotes] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[Upvotes] = Order.by(_.toNonNegativeInt.value) - } - - @newtype final case class Downvotes(toNonNegativeInt: Int Refined NonNegative) - object Downvotes { - def unapply(downvotes: Downvotes): Some[Int Refined NonNegative] = Some(downvotes.toNonNegativeInt) - - implicit val show: Show[Downvotes] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[Downvotes] = Order.by(_.toNonNegativeInt.value) - } - - @newtype final case class TotalScore(toInt: Int) - object TotalScore { - def unapply(totalScore: TotalScore): Some[Int] = Some(totalScore.toInt) - - implicit val show: Show[TotalScore] = Show.wrap(_.toInt) - implicit val order: Order[TotalScore] = Order.by(_.toInt) - } - - @newtype final case class ControversialScore(toNonNegativeInt: Int Refined NonNegative) - object ControversialScore { - def unapply(controversialScore: ControversialScore): Some[Int Refined NonNegative] = - Some(controversialScore.toNonNegativeInt) - - implicit val show: Show[ControversialScore] = Show.wrap(_.toNonNegativeInt.value) - implicit val order: Order[ControversialScore] = Order.by(_.toNonNegativeInt.value) - } - - sealed trait Sorting extends EnumEntry - object Sorting extends Enum[Sorting] { - case object Newest extends Sorting - case object Hottest extends Sorting - case object Controversial extends Sorting - - val values = findValues - } -} diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Subscription.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Subscription.scala index 337e4c78..0ffcd0b4 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Subscription.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/Subscription.scala @@ -1,20 +1,39 @@ package io.branchtalk.discussions.model import io.branchtalk.shared.model.{ FastEq, ID, ShowPretty } -import io.scalaland.catnip.Semi -@Semi(FastEq, ShowPretty) final case class Subscription( +import scala.annotation.targetName + +final case class Subscription( subscriberID: ID[User], subscriptions: Set[ID[Channel]] -) { +) derives FastEq, + ShowPretty { - def ++(subscriptions: Set[ID[Channel]]): Subscription = // scalastyle:ignore method.name + @targetName("addAll") + def ++(subscriptions: Set[ID[Channel]]): Subscription = Subscription(subscriberID = subscriberID, subscriptions = this.subscriptions ++ subscriptions) - def --(subscriptions: Set[ID[Channel]]): Subscription = // scalastyle:ignore method.name + @targetName("removeAll") + def --(subscriptions: Set[ID[Channel]]): Subscription = Subscription(subscriberID = subscriberID, subscriptions = this.subscriptions -- subscriptions) } -object Subscription extends SubscriptionCommands { +object Subscription { + + final case class Scheduled( + subscription: Subscription + ) derives FastEq, + ShowPretty + + final case class Subscribe( + subscriberID: ID[User], + subscriptions: Set[ID[Channel]] + ) derives FastEq, + ShowPretty - @Semi(FastEq, ShowPretty) final case class Scheduled(subscription: Subscription) + final case class Unsubscribe( + subscriberID: ID[User], + subscriptions: Set[ID[Channel]] + ) derives FastEq, + ShowPretty } diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/SubscriptionCommands.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/model/SubscriptionCommands.scala deleted file mode 100644 index 0399cef6..00000000 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/model/SubscriptionCommands.scala +++ /dev/null @@ -1,23 +0,0 @@ -package io.branchtalk.discussions.model - -import io.branchtalk.shared.model.{ FastEq, ID, ShowPretty } -import io.scalaland.catnip.Semi - -trait SubscriptionCommands { - type Subscribe = SubscriptionCommands.Subscribe - type Unsubscribe = SubscriptionCommands.Unsubscribe - val Subscribe = SubscriptionCommands.Subscribe - val Unsubscribe = SubscriptionCommands.Unsubscribe -} -object SubscriptionCommands { - - @Semi(FastEq, ShowPretty) final case class Subscribe( - subscriberID: ID[User], - subscriptions: Set[ID[Channel]] - ) - - @Semi(FastEq, ShowPretty) final case class Unsubscribe( - subscriberID: ID[User], - subscriptions: Set[ID[Channel]] - ) -} diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/ChannelReads.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/ChannelReads.scala index 4c4238a1..9809be88 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/ChannelReads.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/ChannelReads.scala @@ -1,7 +1,5 @@ package io.branchtalk.discussions.reads -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } import io.branchtalk.discussions.model.Channel import io.branchtalk.shared.model.{ ID, Paginated } @@ -9,8 +7,8 @@ trait ChannelReads[F[_]] { def paginate( sortBy: Channel.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Channel]] def exists(id: ID[Channel]): F[Boolean] diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/CommentReads.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/CommentReads.scala index 4527a51a..4f34f375 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/CommentReads.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/CommentReads.scala @@ -1,7 +1,5 @@ package io.branchtalk.discussions.reads -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } import io.branchtalk.discussions.model.{ Comment, Post } import io.branchtalk.shared.model.{ ID, Paginated } @@ -11,8 +9,8 @@ trait CommentReads[F[_]] { post: ID[Post], repliesTo: Option[ID[Comment]], sorting: Comment.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Comment]] def exists(id: ID[Comment]): F[Boolean] diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/PostReads.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/PostReads.scala index 55a4b0f9..8b73c348 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/PostReads.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/reads/PostReads.scala @@ -1,8 +1,6 @@ package io.branchtalk.discussions.reads import cats.data.NonEmptySet -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } import io.branchtalk.discussions.model.{ Channel, Post } import io.branchtalk.shared.model.{ ID, Paginated } @@ -11,8 +9,8 @@ trait PostReads[F[_]] { def paginate( channels: NonEmptySet[ID[Channel]], sortBy: Post.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Post]] def exists(id: ID[Post]): F[Boolean] diff --git a/modules/discussions/src/main/scala/io/branchtalk/discussions/writes/PostWrites.scala b/modules/discussions/src/main/scala/io/branchtalk/discussions/writes/PostWrites.scala index adcdf586..0cd8016b 100644 --- a/modules/discussions/src/main/scala/io/branchtalk/discussions/writes/PostWrites.scala +++ b/modules/discussions/src/main/scala/io/branchtalk/discussions/writes/PostWrites.scala @@ -1,7 +1,7 @@ package io.branchtalk.discussions.writes -import io.branchtalk.discussions.model._ -import io.branchtalk.shared.model._ +import io.branchtalk.discussions.model.* +import io.branchtalk.shared.model.* trait PostWrites[F[_]] { diff --git a/modules/server/src/main/scala/io/branchtalk/api/AppServer.scala b/modules/server/src/main/scala/io/branchtalk/api/AppServer.scala index 058796b3..b78fd8b1 100644 --- a/modules/server/src/main/scala/io/branchtalk/api/AppServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/api/AppServer.scala @@ -3,15 +3,15 @@ package io.branchtalk.api import cats.arrow.FunctionK import cats.data.NonEmptyList import cats.effect.{ Async, Resource } -import com.softwaremill.macwire.wire +import fs2.compression.Compression import io.branchtalk.auth.{ AuthServices, AuthServicesImpl } import io.branchtalk.configs.{ APIConfig, APIPart, AppArguments, PaginationConfig } import io.branchtalk.discussions.api.{ ChannelServer, CommentServer, PostServer, SubscriptionServer } -import io.branchtalk.discussions.reads._ -import io.branchtalk.discussions.writes._ +import io.branchtalk.discussions.reads.* +import io.branchtalk.discussions.writes.* import io.branchtalk.logging.MDC import io.branchtalk.openapi.OpenAPIServer -import io.branchtalk.shared.model.UUIDGenerator +import io.branchtalk.shared.model.UUID import io.branchtalk.users.api.{ ChannelBanServer, ChannelModerationServer, @@ -19,22 +19,22 @@ import io.branchtalk.users.api.{ UserModerationServer, UserServer } -import io.branchtalk.users.reads._ -import io.branchtalk.users.writes._ +import io.branchtalk.users.reads.* +import io.branchtalk.users.writes.* import io.prometheus.client.CollectorRegistry -import org.http4s._ +import org.http4s.* import org.http4s.blaze.server.BlazeServerBuilder -import org.http4s.implicits._ +import org.http4s.implicits.* import org.http4s.metrics.MetricsOps import org.http4s.metrics.prometheus.Prometheus import org.http4s.server.Server -import org.http4s.server.middleware._ +import org.http4s.server.middleware.* import sttp.tapir.server.ServerEndpoint import scala.annotation.nowarn final class AppServer[F[_]: Async: MDC]( - usesServer: UserServer[F], + userServer: UserServer[F], userModerationServer: UserModerationServer[F], channelModerationServer: ChannelModerationServer[F], userBanServer: UserBanServer[F], @@ -55,7 +55,7 @@ final class AppServer[F[_]: Async: MDC]( .withAllowCredentials(apiConfig.http.corsAllowCredentials) .withMaxAge(apiConfig.http.corsMaxAge) - private val logger = io.branchtalk.shared.model.Logger.getLogger[F] + private val logger = io.branchtalk.logging.Logger.getLogger[F] private val logRoutes = Logger[F, F]( logHeaders = apiConfig.http.logHeaders, @@ -64,10 +64,12 @@ final class AppServer[F[_]: Async: MDC]( logAction = ((s: String) => logger.info(s)).some )(_) + private given Compression[F] = Compression.forSync[F] + val routes: HttpApp[F] = NonEmptyList .of( - usesServer.routes, + userServer.routes, userModerationServer.routes, channelModerationServer.routes, userBanServer.routes, @@ -89,93 +91,107 @@ final class AppServer[F[_]: Async: MDC]( } object AppServer { - // scalastyle:off method.length parameter.number - @nowarn("cat=unused") // macwire @SuppressWarnings(Array("org.wartremover.warts.GlobalExecutionContext")) // for BlazeServer def asResource[F[_]: Async: MDC]( - appArguments: AppArguments, - apiConfig: APIConfig, - registry: CollectorRegistry, - userReads: UserReads[F], - sessionReads: SessionReads[F], - banReads: BanReads[F], - userWrites: UserWrites[F], - sessionWrites: SessionWrites[F], - banWrites: BanWrites[F], - channelReads: ChannelReads[F], - postReads: PostReads[F], - commentReads: CommentReads[F], - subscriptionReads: SubscriptionReads[F], - commentWrites: CommentWrites[F], - postWrites: PostWrites[F], - channelWrites: ChannelWrites[F], - subscriptionWrites: SubscriptionWrites[F] - )(implicit uuidGenerator: UUIDGenerator): Resource[F, Server] = + appArguments: AppArguments, + apiConfig: APIConfig, + registry: CollectorRegistry, + userReads: UserReads[F], + sessionReads: SessionReads[F], + banReads: BanReads[F], + userWrites: UserWrites[F], + sessionWrites: SessionWrites[F], + banWrites: BanWrites[F], + channelReads: ChannelReads[F], + postReads: PostReads[F], + commentReads: CommentReads[F], + subscriptionReads: SubscriptionReads[F], + commentWrites: CommentWrites[F], + postWrites: PostWrites[F], + channelWrites: ChannelWrites[F], + subscriptionWrites: SubscriptionWrites[F] + )(using UUID.Generator): Resource[F, Server] = Prometheus.metricsOps[F](registry, "server").flatMap { metricsOps => val correlationIDOps: CorrelationIDOps[F] = CorrelationIDOps[F] val requestIDOps: RequestIDOps[F] = RequestIDOps[F] - val authServices: AuthServices[F] = wire[AuthServicesImpl[F]] + val authServices: AuthServices[F] = AuthServicesImpl[F](userReads, sessionReads, banReads) - val usersServer: UserServer[F] = { - val paginationConfig: PaginationConfig = apiConfig.safePagination(APIPart.Users) - wire[UserServer[F]] - } - val userModerationServer: UserModerationServer[F] = { - val paginationConfig: PaginationConfig = apiConfig.safePagination(APIPart.Users) - wire[UserModerationServer[F]] - } - val channelModerationServer: ChannelModerationServer[F] = { - val paginationConfig: PaginationConfig = apiConfig.safePagination(APIPart.Users) - wire[ChannelModerationServer[F]] - } - val userBanServer: UserBanServer[F] = wire[UserBanServer[F]] - val channelBanServer: ChannelBanServer[F] = wire[ChannelBanServer[F]] - val channelServer: ChannelServer[F] = { - val paginationConfig: PaginationConfig = apiConfig.safePagination(APIPart.Channels) - wire[ChannelServer[F]] - } - val postServer: PostServer[F] = { - val paginationConfig: PaginationConfig = apiConfig.safePagination(APIPart.Posts) - wire[PostServer[F]] - } - val commentServer: CommentServer[F] = { - val paginationConfig: PaginationConfig = apiConfig.safePagination(APIPart.Comments) - wire[CommentServer[F]] - } - val subscriptionServer: SubscriptionServer[F] = { - val paginationConfig: PaginationConfig = apiConfig.safePagination(APIPart.Posts) - wire[SubscriptionServer[F]] - } - val openAPIServer: OpenAPIServer[F] = { - import apiConfig.info - val endpoints: NonEmptyList[ServerEndpoint[Any, F]] = - NonEmptyList - .of( - usersServer.endpoints, - userModerationServer.endpoints, - channelModerationServer.endpoints, - userBanServer.endpoints, - channelBanServer.endpoints, - channelServer.endpoints, - postServer.endpoints, - commentServer.endpoints, - subscriptionServer.endpoints - ) - .reduceK - wire[OpenAPIServer[F]] - } + val userServer: UserServer[F] = UserServer[F]( + authServices, + userReads, + sessionReads, + userWrites, + sessionWrites, + apiConfig.safePagination(APIPart.Users) + ) + val userModerationServer: UserModerationServer[F] = + UserModerationServer[F](authServices, userReads, userWrites, apiConfig.safePagination(APIPart.Users)) + val channelModerationServer: ChannelModerationServer[F] = + ChannelModerationServer[F](authServices, userReads, userWrites, apiConfig.safePagination(APIPart.Users)) + val userBanServer: UserBanServer[F] = UserBanServer[F](authServices, banReads, banWrites) + val channelBanServer: ChannelBanServer[F] = ChannelBanServer[F](authServices, banReads, banWrites) + val channelServer: ChannelServer[F] = + ChannelServer[F](authServices, channelReads, channelWrites, apiConfig.safePagination(APIPart.Channels)) + val postServer: PostServer[F] = + PostServer[F](authServices, postReads, postWrites, apiConfig.safePagination(APIPart.Posts)) + val commentServer: CommentServer[F] = CommentServer[F]( + authServices, + postReads, + commentReads, + commentWrites, + apiConfig.safePagination(APIPart.Comments) + ) + val subscriptionServer: SubscriptionServer[F] = SubscriptionServer[F]( + authServices, + postReads, + subscriptionReads, + subscriptionWrites, + apiConfig, + apiConfig.safePagination(APIPart.Posts) + ) + val openAPIServer: OpenAPIServer[F] = OpenAPIServer[F]( + apiConfig.info, + NonEmptyList + .of( + userServer.endpoints, + userModerationServer.endpoints, + channelModerationServer.endpoints, + userBanServer.endpoints, + channelBanServer.endpoints, + channelServer.endpoints, + postServer.endpoints, + commentServer.endpoints, + subscriptionServer.endpoints + ) + .reduceK + ) - val appServer = wire[AppServer[F]] + val appServer = AppServer[F]( + userServer, + userModerationServer, + channelModerationServer, + userBanServer, + channelBanServer, + channelServer, + postServer, + commentServer, + subscriptionServer, + openAPIServer, + metricsOps, + correlationIDOps, + requestIDOps, + apiConfig + ) - val logger = io.branchtalk.shared.model.Logger.getLogger[F] + val logger = io.branchtalk.logging.Logger.getLogger[F] Resource.make(logger.info("Starting up API server"))(_ => logger.info("API server shut down")) >> BlazeServerBuilder[F] .enableHttp2(apiConfig.http.http2Enabled) - .withLengthLimits(maxRequestLineLen = apiConfig.http.maxRequestLineLength.value, - maxHeadersLen = apiConfig.http.maxHeaderLineLength.value + .withLengthLimits(maxRequestLineLen = apiConfig.http.maxRequestLineLength, + maxHeadersLen = apiConfig.http.maxHeaderLineLength ) .bindHttp(port = appArguments.port, host = appArguments.host) .withHttpApp(appServer.routes) @@ -184,5 +200,4 @@ object AppServer { Resource.eval(logger.info(s"API server started at ${server.address.toString}")) } } - // scalastyle:on parameter.number method.length } diff --git a/modules/server/src/main/scala/io/branchtalk/api/CorrelationIDOps.scala b/modules/server/src/main/scala/io/branchtalk/api/CorrelationIDOps.scala index 4bdf9891..1e0657d5 100644 --- a/modules/server/src/main/scala/io/branchtalk/api/CorrelationIDOps.scala +++ b/modules/server/src/main/scala/io/branchtalk/api/CorrelationIDOps.scala @@ -3,12 +3,12 @@ package io.branchtalk.api import cats.data.{ Kleisli, OptionT } import cats.effect.Sync import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.model.UUIDGenerator -import org.http4s._ +import io.branchtalk.shared.model.UUID +import org.http4s.* -final class CorrelationIDOps[F[_]: Sync: MDC](implicit uuidGenerator: UUIDGenerator) { +final class CorrelationIDOps[F[_]: Sync: MDC](using UUID.Generator) { - def httpRoutes(service: HttpRoutes[F]): HttpRoutes[F] = Kleisli { req: Request[F] => + def httpRoutes(service: HttpRoutes[F]): HttpRoutes[F] = Kleisli { (req: Request[F]) => for { correlationID <- req.headers.get(CorrelationIDOps.correlationIDHeader) match { case None => CorrelationID.generate[OptionT[F, *]] @@ -24,5 +24,5 @@ object CorrelationIDOps { private val correlationIDHeader = org.typelevel.ci.CIString("X-Correlation-ID") - def apply[F[_]: Sync: MDC](implicit uuidGenerator: UUIDGenerator): CorrelationIDOps[F] = new CorrelationIDOps[F] + def apply[F[_]: Sync: MDC](using UUID.Generator): CorrelationIDOps[F] = new CorrelationIDOps[F] } diff --git a/modules/server/src/main/scala/io/branchtalk/api/RequestIDOps.scala b/modules/server/src/main/scala/io/branchtalk/api/RequestIDOps.scala index 72845671..81b1753b 100644 --- a/modules/server/src/main/scala/io/branchtalk/api/RequestIDOps.scala +++ b/modules/server/src/main/scala/io/branchtalk/api/RequestIDOps.scala @@ -4,13 +4,13 @@ import cats.data.{ Kleisli, OptionT } import cats.effect.Sync import io.branchtalk.logging.{ MDC, RequestID } import org.http4s.server.middleware.RequestId -import org.http4s._ +import org.http4s.* final class RequestIDOps[F[_]: Sync: MDC] { // reuses RequestId.httpRoutes but adds logging and MDC setup to it def httpRoutes(service: HttpRoutes[F]): HttpRoutes[F] = RequestId.httpRoutes( - Kleisli { req: Request[F] => + Kleisli { (req: Request[F]) => for { _ <- req.headers .get(RequestIDOps.requestIdHeader) diff --git a/modules/server/src/main/scala/io/branchtalk/api/ServerOptions.scala b/modules/server/src/main/scala/io/branchtalk/api/ServerOptions.scala index 59123447..34802bb6 100644 --- a/modules/server/src/main/scala/io/branchtalk/api/ServerOptions.scala +++ b/modules/server/src/main/scala/io/branchtalk/api/ServerOptions.scala @@ -4,6 +4,7 @@ import cats.effect.Sync import com.typesafe.scalalogging.Logger import io.branchtalk.api.JsoniterSupport.JsCodec import io.branchtalk.api.TapirSupport.jsonBody +import sttp.monad.MonadError import sttp.tapir.server.interceptor.decodefailure.{ DecodeFailureHandler, DefaultDecodeFailureHandler } import sttp.tapir.server.model.ValuedEndpointOutput import sttp.tapir.server.http4s.Http4sServerOptions @@ -18,23 +19,29 @@ object ServerOptions { onMultiple: () => E, onError: (String, Throwable) => E, onMismatch: (String, String) => E, - onValidationError: List[ValidationError[_]] => E + onValidationError: List[ValidationError[?]] => E ) - def buildErrorHandler[E: JsCodec: Schema](errorHandler: ErrorHandler[E]): DecodeFailureHandler = { - case handled if DefaultDecodeFailureHandler.default.respond(handled).isEmpty => - None - case DecodeFailureContext(_, _, DecodeResult.Missing, _) => - Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onMissing())) - case DecodeFailureContext(_, _, DecodeResult.Multiple(_), _) => - Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onMultiple())) - case DecodeFailureContext(_, _, DecodeResult.Error(original, error), _) => - Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onError(original, error))) - case DecodeFailureContext(_, _, DecodeResult.Mismatch(expected, actual), _) => - Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onMismatch(expected, actual))) - case DecodeFailureContext(_, _, DecodeResult.InvalidValue(errors), _) => - Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onValidationError(errors))) - } + def buildErrorHandler[F[_], E: JsCodec: Schema](errorHandler: ErrorHandler[E]): DecodeFailureHandler[F] = + new DecodeFailureHandler[F] { + def apply(ctx: DecodeFailureContext)(using monad: MonadError[F]): F[Option[ValuedEndpointOutput[?]]] = + monad.unit( + ctx match { + case handled if DefaultDecodeFailureHandler.respond(handled).isEmpty => + None + case DecodeFailureContext(_, _, DecodeResult.Missing, _) => + Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onMissing())) + case DecodeFailureContext(_, _, DecodeResult.Multiple(_), _) => + Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onMultiple())) + case DecodeFailureContext(_, _, DecodeResult.Error(original, error), _) => + Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onError(original, error))) + case DecodeFailureContext(_, _, DecodeResult.Mismatch(expected, actual), _) => + Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onMismatch(expected, actual))) + case DecodeFailureContext(_, _, DecodeResult.InvalidValue(errors), _) => + Some(ValuedEndpointOutput(jsonBody[E], errorHandler.onValidationError(errors))) + } + ) + } def create[F[_]: Sync, E: JsCodec: Schema]( logger: Logger, @@ -42,7 +49,7 @@ object ServerOptions { ): Http4sServerOptions[F] = Http4sServerOptions .customiseInterceptors[F] - .decodeFailureHandler(buildErrorHandler(errorHandler)) + .decodeFailureHandler(buildErrorHandler[F, E](errorHandler)) .serverLog( DefaultServerLog[F]( doLogWhenReceived = msg => Sync[F].delay(logger.debug(msg)), diff --git a/modules/server/src/main/scala/io/branchtalk/auth/AuthServices.scala b/modules/server/src/main/scala/io/branchtalk/auth/AuthServices.scala index 738734ea..7d612a3c 100644 --- a/modules/server/src/main/scala/io/branchtalk/auth/AuthServices.scala +++ b/modules/server/src/main/scala/io/branchtalk/auth/AuthServices.scala @@ -4,7 +4,8 @@ import cats.ApplicativeError import cats.effect.Sync import com.typesafe.scalalogging.Logger import io.branchtalk.{ api, users } -import io.branchtalk.mappings._ +import io.branchtalk.shared.infrastructure.* +import io.branchtalk.mappings.* import io.branchtalk.shared.model.{ CodePosition, CommonError, ID } import io.branchtalk.users.reads.{ BanReads, SessionReads, UserReads } @@ -20,7 +21,7 @@ trait AuthServices[F[_]] { } object AuthServices { - @inline def apply[F[_]](implicit authServices: AuthServices[F]): AuthServices[F] = authServices + inline def apply[F[_]](using authServices: AuthServices[F]): AuthServices[F] = authServices } final class AuthServicesImpl[F[_]: Sync](userReads: UserReads[F], sessionReads: SessionReads[F], banReads: BanReads[F]) @@ -74,7 +75,7 @@ final class AuthServicesImpl[F[_]: Sync](userReads: UserReads[F], sessionReads: val allOwnedPermissions = (allowedChannels ++ allowedOwnProfile).foldLeft(user.data.permissions)(_.append(_)) sessionOpt .map(_.data.usage) - .collect { case users.model.SessionProperties.Usage.OAuth(permissions) => permissions } + .collect { case users.model.Session.Usage.OAuth(permissions) => permissions } .fold(allOwnedPermissions)(allOwnedPermissions.intersect) } diff --git a/modules/server/src/main/scala/io/branchtalk/auth/auth.scala b/modules/server/src/main/scala/io/branchtalk/auth/auth.scala new file mode 100644 index 00000000..47d15609 --- /dev/null +++ b/modules/server/src/main/scala/io/branchtalk/auth/auth.scala @@ -0,0 +1,93 @@ +package io.branchtalk.auth + +import cats.{ Applicative, Functor } +import io.branchtalk.api.{ Authentication, Authorize, AuthorizeWithOwnership, RequiredPermissions, UserID } +import io.branchtalk.users.model.{ Session, User } + +// no owner, User and Session output +given authUserSession[F[_]: AuthServices]: Authorize[F, Authentication, (User, Option[Session])] = + (auth: Authentication, requiredPermissions: RequiredPermissions) => + AuthServices[F].authorizeUser(auth, requiredPermissions, None) +given authOptUserSession[F[_]: Applicative: AuthServices]: Authorize[F, + Option[Authentication], + (Option[User], Option[Session]) +] = (auth: Option[Authentication], requiredPermissions: RequiredPermissions) => + auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, None)).map { optUserSession => + optUserSession.map(_._1) -> optUserSession.flatMap(_._2) + } +// no owner, User output +given authUser[F[_]: Functor: AuthServices]: Authorize[F, Authentication, User] = + authUserSession[F].map(_._1) +given authOptUser[F[_]: Applicative: AuthServices]: Authorize[F, Option[Authentication], Option[User]] = + authOptUserSession[F].map(_._1) +// UserID owner, User and Session output +given authUserSessionWithUserIDOwnership[F[_]: AuthServices]: AuthorizeWithOwnership[ + F, + Authentication, + UserID, + (User, Option[Session]) +] = + (auth: Authentication, requiredPermissions: RequiredPermissions, owner: UserID) => + AuthServices[F].authorizeUser(auth, requiredPermissions, Some(owner)) +given authOptUserSessionWithUserIDOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ + F, + Option[Authentication], + UserID, + (Option[User], Option[Session]) +] = + (auth: Option[Authentication], requiredPermissions: RequiredPermissions, owner: UserID) => + auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, Some(owner))).map { optUserSession => + optUserSession.map(_._1) -> optUserSession.flatMap(_._2) + } +// UserID owner, User output +given authUserWithUserIDOwnership[F[_]: Functor: AuthServices]: AuthorizeWithOwnership[ + F, + Authentication, + UserID, + User +] = + authUserSessionWithUserIDOwnership[F].map(_._1) +given authOptUserWithUserIDOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ + F, + Option[Authentication], + UserID, + Option[User] +] = + authOptUserSessionWithUserIDOwnership[F].map(_._1) +// Unit owner, User and Session output +given authUserSessionWithUnitOwnership[F[_]: AuthServices]: AuthorizeWithOwnership[ + F, + Authentication, + Unit, + (User, Option[Session]) +] = + (auth: Authentication, requiredPermissions: RequiredPermissions, _: Unit) => + AuthServices[F].authorizeUser(auth, requiredPermissions, None) +implicit def authOptUserSessionWithUnitOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ + F, + Option[Authentication], + Unit, + (Option[User], Option[Session]) +] = + (auth: Option[Authentication], requiredPermissions: RequiredPermissions, _: Unit) => + auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, None)).map { optUserSession => + optUserSession.map(_._1) -> optUserSession.flatMap(_._2) + } +// Unit owner, User output +given authUserWithUnitOwnership[F[_]: Functor: AuthServices]: AuthorizeWithOwnership[ + F, + Authentication, + Unit, + User +] = + authUserSessionWithUnitOwnership[F].map(_._1) +given authOptUserWithUnitOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ + F, + Option[Authentication], + Unit, + Option[User] +] = + (auth: Option[Authentication], requiredPermissions: RequiredPermissions, _: Unit) => + auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, None)).map { optUserSession => + optUserSession.map(_._1) + } diff --git a/modules/server/src/main/scala/io/branchtalk/auth/package.scala b/modules/server/src/main/scala/io/branchtalk/auth/package.scala deleted file mode 100644 index 5b4b27d8..00000000 --- a/modules/server/src/main/scala/io/branchtalk/auth/package.scala +++ /dev/null @@ -1,96 +0,0 @@ -package io.branchtalk - -import cats.{ Applicative, Functor } -import io.branchtalk.api.{ Authentication, Authorize, AuthorizeWithOwnership, RequiredPermissions, UserID } -import io.branchtalk.users.model.{ Session, User } - -package object auth { - - // no owner, User and Session output - implicit def authUserSession[F[_]: AuthServices]: Authorize[F, Authentication, (User, Option[Session])] = - (auth: Authentication, requiredPermissions: RequiredPermissions) => - AuthServices[F].authorizeUser(auth, requiredPermissions, None) - implicit def authOptUserSession[F[_]: Applicative: AuthServices]: Authorize[F, Option[ - Authentication - ], (Option[User], Option[Session])] = - (auth: Option[Authentication], requiredPermissions: RequiredPermissions) => - auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, None)).map { optUserSession => - optUserSession.map(_._1) -> optUserSession.flatMap(_._2) - } - // no owner, User output - implicit def authUser[F[_]: Functor: AuthServices]: Authorize[F, Authentication, User] = - authUserSession[F].map(_._1) - implicit def authOptUser[F[_]: Applicative: AuthServices]: Authorize[F, Option[Authentication], Option[User]] = - authOptUserSession[F].map(_._1) - // UserID owner, User and Session output - implicit def authUserSessionWithUserIDOwnership[F[_]: AuthServices]: AuthorizeWithOwnership[ - F, - Authentication, - UserID, - (User, Option[Session]) - ] = - (auth: Authentication, requiredPermissions: RequiredPermissions, owner: UserID) => - AuthServices[F].authorizeUser(auth, requiredPermissions, Some(owner)) - implicit def authOptUserSessionWithUserIDOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ - F, - Option[Authentication], - UserID, - (Option[User], Option[Session]) - ] = - (auth: Option[Authentication], requiredPermissions: RequiredPermissions, owner: UserID) => - auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, Some(owner))).map { optUserSession => - optUserSession.map(_._1) -> optUserSession.flatMap(_._2) - } - // UserID owner, User output - implicit def authUserWithUserIDOwnership[F[_]: Functor: AuthServices]: AuthorizeWithOwnership[ - F, - Authentication, - UserID, - User - ] = - authUserSessionWithUserIDOwnership[F].map(_._1) - implicit def authOptUserWithUserIDOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ - F, - Option[Authentication], - UserID, - Option[User] - ] = - authOptUserSessionWithUserIDOwnership[F].map(_._1) - // Unit owner, User and Session output - implicit def authUserSessionWithUnitOwnership[F[_]: AuthServices]: AuthorizeWithOwnership[ - F, - Authentication, - Unit, - (User, Option[Session]) - ] = - (auth: Authentication, requiredPermissions: RequiredPermissions, _: Unit) => - AuthServices[F].authorizeUser(auth, requiredPermissions, None) - implicit def authOptUserSessionWithUnitOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ - F, - Option[Authentication], - Unit, - (Option[User], Option[Session]) - ] = - (auth: Option[Authentication], requiredPermissions: RequiredPermissions, _: Unit) => - auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, None)).map { optUserSession => - optUserSession.map(_._1) -> optUserSession.flatMap(_._2) - } - // Unit owner, User output - implicit def authUserWithUnitOwnership[F[_]: Functor: AuthServices]: AuthorizeWithOwnership[ - F, - Authentication, - Unit, - User - ] = - authUserSessionWithUnitOwnership[F].map(_._1) - implicit def authOptUserWithUnitOwnership[F[_]: Applicative: AuthServices]: AuthorizeWithOwnership[ - F, - Option[Authentication], - Unit, - Option[User] - ] = - (auth: Option[Authentication], requiredPermissions: RequiredPermissions, _: Unit) => - auth.traverse(AuthServices[F].authorizeUser(_, requiredPermissions, None)).map { optUserSession => - optUserSession.map(_._1) - } -} diff --git a/modules/server/src/main/scala/io/branchtalk/configs/APIConfig.scala b/modules/server/src/main/scala/io/branchtalk/configs/APIConfig.scala index 252adb24..583b1e2b 100644 --- a/modules/server/src/main/scala/io/branchtalk/configs/APIConfig.scala +++ b/modules/server/src/main/scala/io/branchtalk/configs/APIConfig.scala @@ -1,102 +1,95 @@ package io.branchtalk.configs import cats.Show -import enumeratum._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.numeric.Positive -import eu.timepit.refined.string.{ MatchesRegex, Url } -import io.branchtalk.api.{ PaginationLimit, PaginationOffset } -import io.branchtalk.discussions.model.Channel -import io.branchtalk.shared.infrastructure.PureconfigSupport._ -import io.branchtalk.shared.model.{ ID, ShowPretty, UUID } -import io.scalaland.catnip.Semi +import enumeratum.* +import io.branchtalk.api.Pagination +import io.branchtalk.discussions.model.{ Channel, Post } +import io.branchtalk.users.model.User +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } +import io.branchtalk.shared.model.* import pureconfig.error.CannotConvert -import sttp.apispec.openapi._ +import sttp.apispec.openapi.* +import java.net.URI import scala.concurrent.duration.FiniteDuration -@Semi(ConfigReader, ShowPretty) final case class APIContact( +given ConfigReader[User.Email] = ConfigReader[String].emapString("User.Email")(User.Email.make) +given ConfigReader[Post.URL] = ConfigReader[URI].emapString("Post.URL")(Post.URL.make) +given ConfigReader[Paginated.Limit] = ConfigReader[Int].emapString("Paginated.Limit")(Paginated.Limit.make) + +final case class APIContact( name: String, - email: String Refined MatchesRegex["(.+)@(.+)"], - url: String Refined Url -) { + email: User.Email, + url: Post.URL +) derives ConfigReader, + ShowPretty { def toOpenAPI: Contact = Contact( name = name.some, - email = email.value.some, - url = url.value.some + email = email.unwrap.some, + url = url.show.some ) } -object APIContact { - implicit private val showEmail: Show[String Refined MatchesRegex["(.+)@(.+)"]] = _.value - implicit private val showUrl: Show[String Refined Url] = _.value -} -@Semi(ConfigReader, ShowPretty) final case class APILicense( +final case class APILicense( name: String, - url: String Refined Url -) { + url: Post.URL +) derives ConfigReader, + ShowPretty { def toOpenAPI: License = License( name = name, - url = url.value.some + url = url.show.some ) } -object APILicense { - implicit private val showUrl: Show[String Refined Url] = _.value -} -@Semi(ConfigReader, ShowPretty) final case class APIInfo( - title: String Refined NonEmpty, - version: String Refined NonEmpty, - description: String Refined NonEmpty, - termsOfService: String Refined Url, +final case class APIInfo( + title: String, // TODO: refine + version: String, // TODO: refine + description: String, // TODO: refine + termsOfService: Post.URL, contact: APIContact, license: APILicense -) { +) derives ConfigReader, + ShowPretty { def toOpenAPI: Info = Info( - title = title.value, - version = version.value, - description = description.value.some, - termsOfService = termsOfService.value.some, + title = title, + version = version, + description = description.some, + termsOfService = termsOfService.show.some, contact = contact.toOpenAPI.some, license = license.toOpenAPI.some ) } -object APIInfo { - implicit private val showNES: Show[String Refined NonEmpty] = _.value - implicit private val showUrl: Show[String Refined Url] = _.value -} -@Semi(ConfigReader, ShowPretty) final case class APIHttp( +final case class APIHttp( logHeaders: Boolean, logBody: Boolean, http2Enabled: Boolean, corsAnyOrigin: Boolean, corsAllowCredentials: Boolean, corsMaxAge: FiniteDuration, - maxHeaderLineLength: Int Refined Positive, - maxRequestLineLength: Int Refined Positive -) -object APIHttp { - implicit private val showPositive: Show[Int Refined Positive] = _.value.toString -} - -@Semi(ConfigReader, ShowPretty) final case class PaginationConfig( - defaultLimit: PaginationLimit, - maxLimit: PaginationLimit -) { - - def resolveOffset(passedOffset: Option[PaginationOffset]): PaginationOffset = - passedOffset.getOrElse(PaginationOffset(0L)) + maxHeaderLineLength: Int, // TODO: refine + maxRequestLineLength: Int // TODO: refine +) derives ConfigReader, + ShowPretty + +final case class PaginationConfig( + defaultLimit: Paginated.Limit, + maxLimit: Paginated.Limit +) derives ConfigReader, + ShowPretty { + + def resolveOffset(passedOffset: Option[Pagination.Offset]): Paginated.Offset = + passedOffset.fold(Paginated.Offset(0L)) { value => + Paginated.Offset.unsafeMake(value.unwrap) + } - def resolveLimit(passedLimit: Option[PaginationLimit]): PaginationLimit = - passedLimit.filter(_.positiveInt.value <= maxLimit.positiveInt.value).getOrElse(defaultLimit) -} -object PaginationConfig { - implicit private val showLimit: Show[PaginationLimit] = _.positiveInt.value.toString + def resolveLimit(passedLimit: Option[Pagination.Limit]): Paginated.Limit = + passedLimit.filter(_.unwrap <= maxLimit.unwrap).fold(defaultLimit) { value => + Paginated.Limit.unsafeMake(value.unwrap: Int) + } } sealed trait APIPart extends EnumEntry @@ -109,7 +102,7 @@ object APIPart extends Enum[APIPart] { val values: IndexedSeq[APIPart] = findValues // NOTE: there is no derivation for Map[A, B] ConfigReader, only Map[String, A] - implicit def asMapKey[A](implicit mapReader: ConfigReader[Map[String, A]]): ConfigReader[Map[APIPart, A]] = + given asMapKey[A](using mapReader: ConfigReader[Map[String, A]]): ConfigReader[Map[APIPart, A]] = mapReader.emap { map => map.toList .traverse { case (key, value) => @@ -120,20 +113,23 @@ object APIPart extends Enum[APIPart] { } .map(_.toMap) } - implicit val show: Show[APIPart] = _.entryName + given Show[APIPart] = _.entryName } -@Semi(ConfigReader, ShowPretty) final case class APIConfig( +final case class APIConfig( info: APIInfo, http: APIHttp, defaultChannels: List[UUID], pagination: Map[APIPart, PaginationConfig] -) { +) derives ConfigReader, + ShowPretty { val signedOutSubscriptions: Set[ID[Channel]] = defaultChannels.map(ID[Channel]).toSet val safePagination: Map[APIPart, PaginationConfig] = pagination.withDefaultValue( - PaginationConfig(PaginationLimit(Defaults.defaultPaginationLimit), PaginationLimit(Defaults.maxPaginationLimit)) + PaginationConfig(Paginated.Limit.unsafeMake(Defaults.defaultPaginationLimit), + Paginated.Limit.unsafeMake(Defaults.maxPaginationLimit) + ) ) } diff --git a/modules/server/src/main/scala/io/branchtalk/configs/AppArguments.scala b/modules/server/src/main/scala/io/branchtalk/configs/AppArguments.scala index 4b1be499..5f6fd96e 100644 --- a/modules/server/src/main/scala/io/branchtalk/configs/AppArguments.scala +++ b/modules/server/src/main/scala/io/branchtalk/configs/AppArguments.scala @@ -1,32 +1,29 @@ package io.branchtalk.configs import cats.effect.{ ExitCode, Sync } -import com.monovore.decline._ +import com.monovore.decline.* import com.typesafe.config.{ Config, ConfigRenderOptions } import io.branchtalk.shared.model.ShowPretty -import io.scalaland.catnip.Semi -@Semi(ShowPretty) final case class AppArguments( +final case class AppArguments( host: String = Defaults.host, port: Int = Defaults.port, runAPI: Boolean = Defaults.runAPI, runUsersProjections: Boolean = Defaults.runUsersProjections, runDiscussionsProjections: Boolean = Defaults.runDiscussionsProjections -) { +) derives ShowPretty { def isAnythingRun: Boolean = runAPI || runUsersProjections || runDiscussionsProjections } object AppArguments { - implicit private class BoolOps[A](private val opts: Opts[A]) extends AnyVal { + extension [A](opts: Opts[A]) { - def orBool(bool: Boolean)(implicit isUnit: A <:< Unit): Opts[Boolean] = - if (bool) opts.orTrue else opts.orFalse + private def orBool(bool: Boolean)(using A <:< Unit): Opts[Boolean] = if (bool) opts.orTrue else opts.orFalse } final case class NoConfig(help: Help) extends Exception { - // scalastyle:off regex def printHelp(config: Config): ExitCode = { println(help.toString()) println(additionalInfo(config)) @@ -38,7 +35,6 @@ object AppArguments { println(help.errors.map(" " + _).intercalate("\n")) ExitCode.Error } - // scalastyle:on regex } private val help: Opts[Nothing] = diff --git a/modules/server/src/main/scala/io/branchtalk/configs/Configuration.scala b/modules/server/src/main/scala/io/branchtalk/configs/Configuration.scala index 0727f8f2..bb5e502e 100644 --- a/modules/server/src/main/scala/io/branchtalk/configs/Configuration.scala +++ b/modules/server/src/main/scala/io/branchtalk/configs/Configuration.scala @@ -2,7 +2,7 @@ package io.branchtalk.configs import cats.data.NonEmptyList import cats.effect.Sync -import io.branchtalk.shared.infrastructure.PureconfigSupport._ +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } import java.io.File import scala.reflect.ClassTag diff --git a/modules/server/src/main/scala/io/branchtalk/configs/Defaults.scala b/modules/server/src/main/scala/io/branchtalk/configs/Defaults.scala index 4eac6ee0..e65ffd9c 100644 --- a/modules/server/src/main/scala/io/branchtalk/configs/Defaults.scala +++ b/modules/server/src/main/scala/io/branchtalk/configs/Defaults.scala @@ -1,8 +1,5 @@ package io.branchtalk.configs -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.Positive - object Defaults { val host: String = "localhost" @@ -12,6 +9,6 @@ object Defaults { val runUsersProjections: Boolean = false val runDiscussionsProjections: Boolean = false - val defaultPaginationLimit: Int Refined Positive = 50 - val maxPaginationLimit: Int Refined Positive = 100 + val defaultPaginationLimit: Int = 50 + val maxPaginationLimit: Int = 100 } diff --git a/modules/server/src/main/scala/io/branchtalk/configs/package.scala b/modules/server/src/main/scala/io/branchtalk/configs/package.scala deleted file mode 100644 index 503c767a..00000000 --- a/modules/server/src/main/scala/io/branchtalk/configs/package.scala +++ /dev/null @@ -1,12 +0,0 @@ -package io.branchtalk - -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.Positive -import io.branchtalk.api.PaginationLimit -import io.branchtalk.shared.infrastructure.PureconfigSupport._ - -package object configs { - - implicit val paginationLimitReader: ConfigReader[PaginationLimit] = - ConfigReader[Int Refined Positive].map(PaginationLimit(_)) -} diff --git a/modules/server/src/main/scala/io/branchtalk/configs/paginations.scala b/modules/server/src/main/scala/io/branchtalk/configs/paginations.scala new file mode 100644 index 00000000..0dd670ee --- /dev/null +++ b/modules/server/src/main/scala/io/branchtalk/configs/paginations.scala @@ -0,0 +1,7 @@ +package io.branchtalk.configs + +import io.branchtalk.api.Pagination +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } + +given paginationLimitReader: ConfigReader[Pagination.Limit] = + ConfigReader[Int].emapString("Pagination.Limit")(Pagination.Limit.make) diff --git a/modules/server/src/main/scala/io/branchtalk/discussions/api/ChannelServer.scala b/modules/server/src/main/scala/io/branchtalk/discussions/api/ChannelServer.scala index 0e6b275c..c3a29b56 100644 --- a/modules/server/src/main/scala/io/branchtalk/discussions/api/ChannelServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/discussions/api/ChannelServer.scala @@ -3,19 +3,20 @@ package io.branchtalk.discussions.api import cats.data.NonEmptyList import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api._ -import io.branchtalk.auth._ +import io.branchtalk.api.* +import io.branchtalk.auth.{ *, given } import io.branchtalk.configs.PaginationConfig -import io.branchtalk.discussions.api.ChannelModels._ +import io.branchtalk.discussions.api.ChannelModels.* import io.branchtalk.discussions.model.Channel import io.branchtalk.discussions.reads.ChannelReads import io.branchtalk.discussions.writes.ChannelWrites import io.branchtalk.mappings.userIDUsers2Discussions +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.{ CommonError, CreationScheduled } import io.branchtalk.users.model.User -import io.scalaland.chimney.dsl._ -import org.http4s._ -import sttp.tapir.server.http4s._ +import io.scalaland.chimney.dsl.* +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint final class ChannelServer[F[_]: Async]( @@ -25,20 +26,20 @@ final class ChannelServer[F[_]: Async]( paginationConfig: PaginationConfig ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - implicit val serverOptions: Http4sServerOptions[F] = ChannelServer.serverOptions[F].apply(logger) + private given serverOptions: Http4sServerOptions[F] = ChannelServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, ChannelError] = ChannelServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, ChannelError] = ChannelServer.errorHandler[F](logger) private val paginate = ChannelAPIs.paginate.serverLogic[F, Option[User]] { case (optOffset, optLimit) => val sortBy = Channel.Sorting.Newest val offset = paginationConfig.resolveOffset(optOffset) val limit = paginationConfig.resolveLimit(optLimit) for { - paginated <- channelReads.paginate(sortBy, offset.nonNegativeLong, limit.positiveInt) + paginated <- channelReads.paginate(sortBy, offset, limit) } yield Pagination.fromPaginated(paginated.map(APIChannel.fromDomain), offset, limit) } @@ -98,7 +99,7 @@ final class ChannelServer[F[_]: Async]( } object ChannelServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, ChannelError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, ChannelError]( _, ServerOptions.ErrorHandler[ChannelError]( () => ChannelError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -114,7 +115,7 @@ object ChannelServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, ChannelError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, ChannelError] = ServerErrorHandler.handleCommonErrors[F, ChannelError] { case CommonError.InvalidCredentials(_) => ChannelError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/discussions/api/CommentServer.scala b/modules/server/src/main/scala/io/branchtalk/discussions/api/CommentServer.scala index f1dbf407..b1d8fd27 100644 --- a/modules/server/src/main/scala/io/branchtalk/discussions/api/CommentServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/discussions/api/CommentServer.scala @@ -3,19 +3,20 @@ package io.branchtalk.discussions.api import cats.data.NonEmptyList import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api._ -import io.branchtalk.auth._ +import io.branchtalk.api.* +import io.branchtalk.auth.{ *, given } import io.branchtalk.configs.PaginationConfig -import io.branchtalk.discussions.api.CommentModels._ +import io.branchtalk.discussions.api.CommentModels.* import io.branchtalk.discussions.model.{ Channel, Comment, Post } import io.branchtalk.discussions.reads.{ CommentReads, PostReads } import io.branchtalk.discussions.writes.CommentWrites -import io.branchtalk.mappings._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.{ CommonError, CreationScheduled, ID } import io.branchtalk.users.model.User -import io.scalaland.chimney.dsl._ -import org.http4s._ -import sttp.tapir.server.http4s._ +import io.scalaland.chimney.dsl.* +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint final class CommentServer[F[_]: Async]( @@ -26,13 +27,13 @@ final class CommentServer[F[_]: Async]( paginationConfig: PaginationConfig ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - private val serverOptions: Http4sServerOptions[F] = CommentServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = CommentServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, CommentError] = CommentServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, CommentError] = CommentServer.errorHandler[F](logger) private def testOwnership(channelID: ID[Channel], postID: ID[Post]) = postReads .requireById(postID) @@ -73,7 +74,7 @@ final class CommentServer[F[_]: Async]( val offset = paginationConfig.resolveOffset(optOffset) val limit = paginationConfig.resolveLimit(optLimit) for { - paginated <- commentReads.paginate(postID, optReply, sortBy, offset.nonNegativeLong, limit.positiveInt) + paginated <- commentReads.paginate(postID, optReply, sortBy, offset, limit) } yield Pagination.fromPaginated(paginated.map(APIComment.fromDomain), offset, limit) } @@ -84,7 +85,7 @@ final class CommentServer[F[_]: Async]( val offset = paginationConfig.resolveOffset(None) val limit = paginationConfig.resolveLimit(None) for { - paginated <- commentReads.paginate(postID, optReply, sortBy, offset.nonNegativeLong, limit.positiveInt) + paginated <- commentReads.paginate(postID, optReply, sortBy, offset, limit) } yield Pagination.fromPaginated(paginated.map(APIComment.fromDomain), offset, limit) } @@ -95,7 +96,7 @@ final class CommentServer[F[_]: Async]( val offset = paginationConfig.resolveOffset(None) val limit = paginationConfig.resolveLimit(None) for { - paginated <- commentReads.paginate(postID, optReply, sortBy, offset.nonNegativeLong, limit.positiveInt) + paginated <- commentReads.paginate(postID, optReply, sortBy, offset, limit) } yield Pagination.fromPaginated(paginated.map(APIComment.fromDomain), offset, limit) } @@ -217,7 +218,7 @@ final class CommentServer[F[_]: Async]( } object CommentServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, CommentError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, CommentError]( _, ServerOptions.ErrorHandler[CommentError]( () => CommentError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -233,7 +234,7 @@ object CommentServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, CommentError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, CommentError] = ServerErrorHandler.handleCommonErrors[F, CommentError] { case CommonError.InvalidCredentials(_) => CommentError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/discussions/api/PostServer.scala b/modules/server/src/main/scala/io/branchtalk/discussions/api/PostServer.scala index 13499706..73b456fb 100644 --- a/modules/server/src/main/scala/io/branchtalk/discussions/api/PostServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/discussions/api/PostServer.scala @@ -3,19 +3,20 @@ package io.branchtalk.discussions.api import cats.data.{ NonEmptyList, NonEmptySet } import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api._ -import io.branchtalk.auth._ +import io.branchtalk.api.* +import io.branchtalk.auth.{ *, given } import io.branchtalk.configs.PaginationConfig -import io.branchtalk.discussions.api.PostModels._ +import io.branchtalk.discussions.api.PostModels.* import io.branchtalk.discussions.model.{ Channel, Post } import io.branchtalk.discussions.reads.PostReads import io.branchtalk.discussions.writes.PostWrites -import io.branchtalk.mappings._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.{ CommonError, CreationScheduled, ID, Paginated } import io.branchtalk.users.model.User -import io.scalaland.chimney.dsl._ -import org.http4s._ -import sttp.tapir.server.http4s._ +import io.scalaland.chimney.dsl.* +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint import scala.collection.immutable.SortedSet @@ -27,13 +28,13 @@ final class PostServer[F[_]: Async]( paginationConfig: PaginationConfig ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - private val serverOptions: Http4sServerOptions[F] = PostServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = PostServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, PostError] = PostServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, PostError] = PostServer.errorHandler[F](logger) private def testOwnership(channelID: ID[Channel], postID: ID[Post], isDeleted: Boolean = false) = postReads .requireById(postID, isDeleted) @@ -53,7 +54,7 @@ final class PostServer[F[_]: Async]( val channelIDS = SortedSet(channelID) for { paginated <- NonEmptySet.fromSet(channelIDS) match { - case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset.nonNegativeLong, limit.positiveInt) + case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset, limit) case None => Paginated.empty[Post].pure[F] } } yield Pagination.fromPaginated(paginated.map(APIPost.fromDomain), offset, limit) @@ -66,7 +67,7 @@ final class PostServer[F[_]: Async]( val channelIDS = SortedSet(channelID) for { paginated <- NonEmptySet.fromSet(channelIDS) match { - case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset.nonNegativeLong, limit.positiveInt) + case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset, limit) case None => Paginated.empty[Post].pure[F] } } yield Pagination.fromPaginated(paginated.map(APIPost.fromDomain), offset, limit) @@ -79,7 +80,7 @@ final class PostServer[F[_]: Async]( val channelIDS = SortedSet(channelID) for { paginated <- NonEmptySet.fromSet(channelIDS) match { - case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset.nonNegativeLong, limit.positiveInt) + case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset, limit) case None => Paginated.empty[Post].pure[F] } } yield Pagination.fromPaginated(paginated.map(APIPost.fromDomain), offset, limit) @@ -199,7 +200,7 @@ final class PostServer[F[_]: Async]( } object PostServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, PostError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, PostError]( _, ServerOptions.ErrorHandler[PostError]( () => PostError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -215,7 +216,7 @@ object PostServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, PostError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, PostError] = ServerErrorHandler.handleCommonErrors[F, PostError] { case CommonError.InvalidCredentials(_) => PostError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/discussions/api/SubscriptionServer.scala b/modules/server/src/main/scala/io/branchtalk/discussions/api/SubscriptionServer.scala index a49e499d..569042df 100644 --- a/modules/server/src/main/scala/io/branchtalk/discussions/api/SubscriptionServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/discussions/api/SubscriptionServer.scala @@ -3,19 +3,20 @@ package io.branchtalk.discussions.api import cats.data.{ NonEmptyList, NonEmptySet } import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api._ -import io.branchtalk.auth._ +import io.branchtalk.api.* +import io.branchtalk.auth.{ *, given } import io.branchtalk.configs.{ APIConfig, PaginationConfig } -import io.branchtalk.discussions.api.PostModels._ -import io.branchtalk.discussions.api.SubscriptionModels._ +import io.branchtalk.discussions.api.PostModels.* +import io.branchtalk.discussions.api.SubscriptionModels.* import io.branchtalk.discussions.model.{ Post, Subscription } import io.branchtalk.discussions.reads.{ PostReads, SubscriptionReads } import io.branchtalk.discussions.writes.SubscriptionWrites -import io.branchtalk.mappings._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.{ CommonError, Paginated } import io.branchtalk.users.model.User -import org.http4s._ -import sttp.tapir.server.http4s._ +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint import scala.collection.immutable.SortedSet @@ -29,16 +30,15 @@ final class SubscriptionServer[F[_]: Async]( paginationConfig: PaginationConfig ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - private val serverOptions: Http4sServerOptions[F] = SubscriptionServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = SubscriptionServer.serverOptions[F](logger) - implicit private val postErrorHandler: ServerErrorHandler[F, PostError] = PostServer.errorHandler[F].apply(logger) + private given postErrorHandler: ServerErrorHandler[F, PostError] = PostServer.errorHandler[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, SubscriptionError] = - SubscriptionServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, SubscriptionError] = SubscriptionServer.errorHandler[F](logger) private val newest = SubscriptionAPIs.newest.serverLogic[F, Option[User]].withUser { case (optUser, (optOffset, optLimit)) => @@ -49,7 +49,7 @@ final class SubscriptionServer[F[_]: Async]( subscriptionOpt <- optUser.map(_.id).map(userIDUsers2Discussions.get).traverse(subscriptionReads.requireForUser) channelIDS = SortedSet.from(subscriptionOpt.map(_.subscriptions).getOrElse(apiConfig.signedOutSubscriptions)) paginated <- NonEmptySet.fromSet(channelIDS) match { - case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset.nonNegativeLong, limit.positiveInt) + case Some(channelIDs) => postReads.paginate(channelIDs, sortBy, offset, limit) case None => Paginated.empty[Post].pure[F] } } yield Pagination.fromPaginated(paginated.map(APIPost.fromDomain), offset, limit) @@ -88,7 +88,7 @@ final class SubscriptionServer[F[_]: Async]( } object SubscriptionServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, SubscriptionError]( _, ServerOptions.ErrorHandler[SubscriptionError]( @@ -106,7 +106,7 @@ object SubscriptionServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, SubscriptionError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, SubscriptionError] = ServerErrorHandler.handleCommonErrors[F, SubscriptionError] { case CommonError.InvalidCredentials(_) => SubscriptionError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/mappings/mappings.scala b/modules/server/src/main/scala/io/branchtalk/mappings/mappings.scala new file mode 100644 index 00000000..8dff76a4 --- /dev/null +++ b/modules/server/src/main/scala/io/branchtalk/mappings/mappings.scala @@ -0,0 +1,120 @@ +package io.branchtalk.mappings + +import cats.data.NonEmptySet +import io.branchtalk.shared.model.ID +import io.branchtalk.{ api, discussions, users } + +import scala.collection.immutable.SortedSet +import scala.util.Try + +class Iso[A, B](val get: A => B)(val reverseGet: B => A) + +// API <-> Users + +val usernameApi2Users: Iso[api.Username, users.model.User.Name] = Iso[api.Username, users.model.User.Name] { username => + users.model.User.Name.unsafeMake(username.unwrap) +}(username => api.Username.unsafeMake(username.unwrap)) + +val passwordApi2Users: Iso[api.Password, users.model.Password.Raw] = Iso[api.Password, users.model.Password.Raw] { + password => users.model.Password.Raw.unsafeMake(password.unwrap) +}(password => api.Password.unsafeMake(password.unwrap)) + +val sessionIDApi2Users: Iso[api.SessionID, ID[users.model.Session]] = Iso[api.SessionID, ID[users.model.Session]] { + sessionID => ID[users.model.Session](sessionID.unwrap) +}(sessionID => api.SessionID(sessionID.unwrap)) + +val userIDApi2Users: Iso[api.UserID, ID[users.model.User]] = Iso[api.UserID, ID[users.model.User]] { userID => + ID[users.model.User](userID.unwrap) +}(userID => api.UserID(userID.unwrap)) + +val channelIDApi2Users: Iso[api.ChannelID, ID[users.model.Channel]] = Iso[api.ChannelID, ID[users.model.Channel]] { + channelID => ID[users.model.Channel](channelID.unwrap) +}(channelID => api.ChannelID(channelID.unwrap)) + +@SuppressWarnings(Array("org.wartremover.warts.Throw")) // too PITA to do it right +def permissionApi2Users(owner: api.UserID): Iso[api.Permission, users.model.Permission] = + Iso[api.Permission, users.model.Permission] { + case api.Permission.Administrate => + users.model.Permission.Administrate + case api.Permission.IsOwner => + users.model.Permission.IsUser(userIDApi2Users.get(owner)) + case api.Permission.ModerateUsers => + users.model.Permission.ModerateUsers + case api.Permission.ModerateChannel(channelID) => + users.model.Permission.ModerateChannel(channelIDApi2Users.get(channelID)) + case api.Permission.CanPublish(channelID) => + users.model.Permission.CanPublish(channelIDApi2Users.get(channelID)) + } { + case users.model.Permission.Administrate => + api.Permission.Administrate + case users.model.Permission.IsUser(userID) if userID.unwrap === owner.unwrap && owner =!= api.UserID.empty => + api.Permission.IsOwner + case users.model.Permission.IsUser(_) => + throw new Exception("Cannot map User to Owner if ID doesn't match current Owner ID") + case users.model.Permission.ModerateUsers => + api.Permission.ModerateUsers + case users.model.Permission.ModerateChannel(channelID) => + api.Permission.ModerateChannel(channelIDApi2Users.reverseGet(channelID)) + case users.model.Permission.CanPublish(channelID) => + api.Permission.CanPublish(channelIDApi2Users.reverseGet(channelID)) + } + +def requiredPermissionsApi2Users(owner: api.UserID): Iso[api.RequiredPermissions, users.model.RequiredPermissions] = { + val permApi2Users = permissionApi2Users(owner) + def safeReverseGet(perm: users.model.Permission) = + Try(SortedSet(permApi2Users.reverseGet(perm))).getOrElse(SortedSet.empty[api.Permission]) + lazy val reqApi2Users: Iso[api.RequiredPermissions, users.model.RequiredPermissions] = + Iso[api.RequiredPermissions, users.model.RequiredPermissions] { + case api.RequiredPermissions.Empty => + users.model.RequiredPermissions.Empty + case api.RequiredPermissions.AllOf(set) => + users.model.RequiredPermissions.AllOf(set.map(permApi2Users.get)) + case api.RequiredPermissions.AnyOf(set) => + users.model.RequiredPermissions.AnyOf(set.map(permApi2Users.get)) + case api.RequiredPermissions.And(x, y) => + users.model.RequiredPermissions.And(reqApi2Users.get(x), reqApi2Users.get(y)) + case api.RequiredPermissions.Or(x, y) => + users.model.RequiredPermissions.Or(reqApi2Users.get(x), reqApi2Users.get(y)) + case api.RequiredPermissions.Not(x) => users.model.RequiredPermissions.Not(reqApi2Users.get(x)) + } { + case users.model.RequiredPermissions.Empty => + api.RequiredPermissions.Empty + case users.model.RequiredPermissions.AllOf(set) => + NonEmptySet + .fromSet(set.toSortedSet.flatMap(safeReverseGet)) + .fold[api.RequiredPermissions](api.RequiredPermissions.Empty)(api.RequiredPermissions.AllOf) + case users.model.RequiredPermissions.AnyOf(set) => + NonEmptySet + .fromSet(set.toSortedSet.flatMap(safeReverseGet)) + .fold[api.RequiredPermissions](api.RequiredPermissions.Empty)(api.RequiredPermissions.AnyOf) + case users.model.RequiredPermissions.And(x, y) => + api.RequiredPermissions.And(reqApi2Users.reverseGet(x), reqApi2Users.reverseGet(y)) + case users.model.RequiredPermissions.Or(x, y) => + api.RequiredPermissions.Or(reqApi2Users.reverseGet(x), reqApi2Users.reverseGet(y)) + case users.model.RequiredPermissions.Not(x) => api.RequiredPermissions.Not(reqApi2Users.reverseGet(x)) + } + reqApi2Users +} + +// API <-> Discussions + +val userIDApi2Discussions: Iso[api.UserID, ID[discussions.model.User]] = Iso[api.UserID, ID[discussions.model.User]] { + userID => ID[discussions.model.User](userID.unwrap) +}(id => api.UserID(id.unwrap)) + +val channelIDApi2Discussions: Iso[api.ChannelID, ID[discussions.model.Channel]] = + Iso[api.ChannelID, ID[discussions.model.Channel]](userID => ID[discussions.model.Channel](userID.unwrap))(id => + api.ChannelID(id.unwrap) + ) + +// Users <-> Discussions + +val userIDUsers2Discussions: Iso[ID[users.model.User], ID[discussions.model.User]] = + Iso[ID[users.model.User], ID[discussions.model.User]](userID => ID[discussions.model.User](userID.unwrap)) { userID => + ID[users.model.User](userID.unwrap) + } + +val channelIDUsers2Discussions: Iso[ID[users.model.Channel], ID[discussions.model.Channel]] = + Iso[ID[users.model.Channel], ID[discussions.model.Channel]] { channelID => + ID[discussions.model.Channel](channelID.unwrap) + }(channelID => ID[users.model.Channel](channelID.unwrap)) diff --git a/modules/server/src/main/scala/io/branchtalk/mappings/package.scala b/modules/server/src/main/scala/io/branchtalk/mappings/package.scala deleted file mode 100644 index 8b166112..00000000 --- a/modules/server/src/main/scala/io/branchtalk/mappings/package.scala +++ /dev/null @@ -1,126 +0,0 @@ -package io.branchtalk - -import cats.data.NonEmptySet -import io.branchtalk.api.UserID -import io.branchtalk.shared.model.ID -import monocle.Iso - -import scala.collection.immutable.SortedSet -import scala.util.Try - -package object mappings { - - // API <-> Users - - val usernameApi2Users: Iso[api.Username, users.model.User.Name] = Iso[api.Username, users.model.User.Name] { - username => users.model.User.Name(username.nonEmptyString) - }(username => api.Username(username.nonEmptyString)) - - val passwordApi2Users: Iso[api.Password, users.model.Password.Raw] = Iso[api.Password, users.model.Password.Raw] { - password => users.model.Password.Raw(password.nonEmptyBytes) - }(password => api.Password(password.nonEmptyBytes)) - - val sessionIDApi2Users: Iso[api.SessionID, ID[users.model.Session]] = Iso[api.SessionID, ID[users.model.Session]] { - sessionID => ID[users.model.Session](sessionID.uuid) - }(sessionID => api.SessionID(sessionID.uuid)) - - val userIDApi2Users: Iso[api.UserID, ID[users.model.User]] = Iso[api.UserID, ID[users.model.User]] { userID => - ID[users.model.User](userID.uuid) - }(userID => api.UserID(userID.uuid)) - - val channelIDApi2Users: Iso[api.ChannelID, ID[users.model.Channel]] = Iso[api.ChannelID, ID[users.model.Channel]] { - channelID => ID[users.model.Channel](channelID.uuid) - }(channelID => api.ChannelID(channelID.uuid)) - - // scalastyle:off cyclomatic.complexity - @SuppressWarnings(Array("org.wartremover.warts.Throw")) // too PITA to do it right - def permissionApi2Users(owner: UserID): Iso[api.Permission, users.model.Permission] = - Iso[api.Permission, users.model.Permission] { - case api.Permission.Administrate => - users.model.Permission.Administrate - case api.Permission.IsOwner => - users.model.Permission.IsUser(userIDApi2Users.get(owner)) - case api.Permission.ModerateUsers => - users.model.Permission.ModerateUsers - case api.Permission.ModerateChannel(channelID) => - users.model.Permission.ModerateChannel(channelIDApi2Users.get(channelID)) - case api.Permission.CanPublish(channelID) => - users.model.Permission.CanPublish(channelIDApi2Users.get(channelID)) - } { - case users.model.Permission.Administrate => - api.Permission.Administrate - case users.model.Permission.IsUser(userID) if userID.uuid === owner.uuid && owner =!= UserID.empty => - api.Permission.IsOwner - case users.model.Permission.IsUser(_) => - throw new Exception("Cannot map User to Owner if ID doesn't match current Owner ID") - case users.model.Permission.ModerateUsers => - api.Permission.ModerateUsers - case users.model.Permission.ModerateChannel(channelID) => - api.Permission.ModerateChannel(channelIDApi2Users.reverseGet(channelID)) - case users.model.Permission.CanPublish(channelID) => - api.Permission.CanPublish(channelIDApi2Users.reverseGet(channelID)) - } - // scalastyle:on cyclomatic.complexity - - // scalastyle:off cyclomatic.complexity - def requiredPermissionsApi2Users(owner: UserID): Iso[api.RequiredPermissions, users.model.RequiredPermissions] = { - val permApi2Users = permissionApi2Users(owner) - def safeReverseGet(perm: users.model.Permission) = - Try(SortedSet(permApi2Users.reverseGet(perm))).getOrElse(SortedSet.empty[api.Permission]) - lazy val reqApi2Users: Iso[api.RequiredPermissions, users.model.RequiredPermissions] = - Iso[api.RequiredPermissions, users.model.RequiredPermissions] { - case api.RequiredPermissions.Empty => - users.model.RequiredPermissions.Empty - case api.RequiredPermissions.AllOf(set) => - users.model.RequiredPermissions.AllOf(set.map(permApi2Users.get)) - case api.RequiredPermissions.AnyOf(set) => - users.model.RequiredPermissions.AnyOf(set.map(permApi2Users.get)) - case api.RequiredPermissions.And(x, y) => - users.model.RequiredPermissions.And(reqApi2Users.get(x), reqApi2Users.get(y)) - case api.RequiredPermissions.Or(x, y) => - users.model.RequiredPermissions.Or(reqApi2Users.get(x), reqApi2Users.get(y)) - case api.RequiredPermissions.Not(x) => users.model.RequiredPermissions.Not(reqApi2Users.get(x)) - } { - case users.model.RequiredPermissions.Empty => - api.RequiredPermissions.Empty - case users.model.RequiredPermissions.AllOf(set) => - NonEmptySet - .fromSet(set.toSortedSet.flatMap(safeReverseGet)) - .fold[api.RequiredPermissions](api.RequiredPermissions.Empty)(api.RequiredPermissions.AllOf) - case users.model.RequiredPermissions.AnyOf(set) => - NonEmptySet - .fromSet(set.toSortedSet.flatMap(safeReverseGet)) - .fold[api.RequiredPermissions](api.RequiredPermissions.Empty)(api.RequiredPermissions.AnyOf) - case users.model.RequiredPermissions.And(x, y) => - api.RequiredPermissions.And(reqApi2Users.reverseGet(x), reqApi2Users.reverseGet(y)) - case users.model.RequiredPermissions.Or(x, y) => - api.RequiredPermissions.Or(reqApi2Users.reverseGet(x), reqApi2Users.reverseGet(y)) - case users.model.RequiredPermissions.Not(x) => api.RequiredPermissions.Not(reqApi2Users.reverseGet(x)) - } - reqApi2Users - } - // scalastyle:on cyclomatic.complexity - - // API <-> Discussions - - val userIDApi2Discussions: Iso[api.UserID, ID[discussions.model.User]] = Iso[api.UserID, ID[discussions.model.User]] { - userID => ID[discussions.model.User](userID.uuid) - }(id => api.UserID(id.uuid)) - - val channelIDApi2Discussions: Iso[api.ChannelID, ID[discussions.model.Channel]] = - Iso[api.ChannelID, ID[discussions.model.Channel]](userID => ID[discussions.model.Channel](userID.uuid))(id => - api.ChannelID(id.uuid) - ) - - // Users <-> Discussions - - val userIDUsers2Discussions: Iso[ID[users.model.User], ID[discussions.model.User]] = - Iso[ID[users.model.User], ID[discussions.model.User]](userID => ID[discussions.model.User](userID.uuid)) { userID => - ID[users.model.User](userID.uuid) - } - - val channelIDUsers2Discussions: Iso[ID[users.model.Channel], ID[discussions.model.Channel]] = - Iso[ID[users.model.Channel], ID[discussions.model.Channel]] { channelID => - ID[discussions.model.Channel](channelID.uuid) - }(channelID => ID[users.model.Channel](channelID.uuid)) -} diff --git a/modules/server/src/main/scala/io/branchtalk/openapi/OpenAPIServer.scala b/modules/server/src/main/scala/io/branchtalk/openapi/OpenAPIServer.scala index 37c7c901..df1e4887 100644 --- a/modules/server/src/main/scala/io/branchtalk/openapi/OpenAPIServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/openapi/OpenAPIServer.scala @@ -2,13 +2,12 @@ package io.branchtalk.openapi import cats.data.NonEmptyList import cats.effect.Sync -import com.github.plokhotnyuk.jsoniter_scala.core._ -import com.github.plokhotnyuk.jsoniter_scala.macros._ +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* import io.branchtalk.api -import io.branchtalk.api.JsoniterSupport._ +import io.branchtalk.api.JsoniterSupport.* import io.branchtalk.configs.APIInfo import org.http4s.HttpRoutes -import monocle.macros.syntax.lens._ import sttp.apispec.{ Discriminator, ExampleMultipleValue, @@ -18,16 +17,17 @@ import sttp.apispec.{ ExternalDocumentation, OAuthFlow, OAuthFlows, - Reference, - ReferenceOr, + Pattern, Schema, + SchemaLike, SchemaType, SecurityRequirement, SecurityScheme, Tag } -import sttp.apispec.openapi._ -import sttp.tapir.docs.openapi._ +import sttp.apispec.openapi.ReferenceOr +import sttp.apispec.openapi.* +import sttp.tapir.docs.openapi.* import sttp.tapir.server.ServerEndpoint import sttp.tapir.swagger.http4s.SwaggerHttp4s @@ -39,16 +39,18 @@ final class OpenAPIServer[F[_]: Sync]( endpoints: NonEmptyList[ServerEndpoint[Any, F]] ) { - import OpenAPIServer._ + import OpenAPIServer.{ *, given } private val removedName = classOf[api.RequiredPermissions].getName private def fixPathItem(pathItem: PathItem) = - pathItem.focus(_.parameters).modify(_.filterNot(_.fold(_.$ref.contains(removedName), _.name.contains(removedName)))) + pathItem.copy(parameters = + pathItem.parameters.filterNot(_.fold(_.$ref.contains(removedName), _.name.contains(removedName))) + ) def openAPI: OpenAPI = OpenAPIDocsInterpreter(OpenAPIServer.openAPIDocsOptions) .toOpenAPI(endpoints.map(_.endpoint).toList, apiInfo.toOpenAPI) - .focus(_.paths.pathItems) - .modify(_.view.mapValues(fixPathItem).to(ListMap)) + // TODO: quicklens + .pipe(oa => oa.copy(paths = oa.paths.copy(pathItems = oa.paths.pathItems.view.mapValues(fixPathItem).to(ListMap)))) val openAPIJson: String = writeToString(openAPI) @@ -57,19 +59,19 @@ final class OpenAPIServer[F[_]: Sync]( @SuppressWarnings(Array("org.wartremover.warts.All")) // macros object OpenAPIServer { - implicit private val openAPIDocsOptions: OpenAPIDocsOptions = OpenAPIDocsOptions.default + private given openAPIDocsOptions: OpenAPIDocsOptions = OpenAPIDocsOptions.default // technically, we only need encoder part so we can mock all the rest and call it a day trait JsEncoderOnly[T] extends JsCodec[T] { - override def decodeValue(in: JsonReader, default: T): T = ??? - override def nullValue: T = null.asInstanceOf[T] // scalastyle:ignore null - def encodeValue(x: T, out: JsonWriter): Unit + override def decodeValue(in: JsonReader, default: T): T = ??? + override def nullValue: T = null.asInstanceOf[T] + def encodeValue(x: T, out: JsonWriter): Unit } object JsEncoderOnly { def apply[T](f: (T, JsonWriter) => Unit): JsEncoderOnly[T] = (value: T, out: JsonWriter) => f(value, out) } - implicit val encoderReference: JsCodec[Reference] = JsonCodecMaker.make + given encoderReference: JsCodec[Reference] = JsonCodecMaker.make // apparently Jsoniter cannot find this ... def encoderReferenceOr[T: JsCodec]: JsCodec[ReferenceOr[T]] = JsEncoderOnly[ReferenceOr[T]] { (x, out) => @@ -77,100 +79,100 @@ object OpenAPIServer { } // so I have to apply this manually @nowarn("msg=Implicit resolves to enclosing value") // here this is just because of recursion - implicit lazy val encoderReferenceOrSchema: JsCodec[ReferenceOr[Schema]] = encoderReferenceOr - implicit lazy val encoderReferenceOrParameterCodec: JsCodec[ReferenceOr[Parameter]] = encoderReferenceOr - implicit lazy val encoderReferenceOrRequestBodyCodec: JsCodec[ReferenceOr[RequestBody]] = encoderReferenceOr - implicit lazy val encoderReferenceOrResponseCodec: JsCodec[ReferenceOr[Response]] = encoderReferenceOr - implicit lazy val encoderReferenceOrExampleCodec: JsCodec[ReferenceOr[Example]] = encoderReferenceOr - implicit lazy val encoderReferenceOrHeaderCodec: JsCodec[ReferenceOr[Header]] = encoderReferenceOr + given encoderReferenceOrSchema: JsCodec[ReferenceOr[Schema]] = encoderReferenceOr + given encoderReferenceOrParameterCodec: JsCodec[ReferenceOr[Parameter]] = encoderReferenceOr + given encoderReferenceOrRequestBodyCodec: JsCodec[ReferenceOr[RequestBody]] = encoderReferenceOr + given encoderReferenceOrResponseCodec: JsCodec[ReferenceOr[Response]] = encoderReferenceOr + given encoderReferenceOrExampleCodec: JsCodec[ReferenceOr[Example]] = encoderReferenceOr + given encoderReferenceOrHeaderCodec: JsCodec[ReferenceOr[Header]] = encoderReferenceOr // TODO: support extension at all - implicit val extensionValue: JsCodec[ExtensionValue] = JsEncoderOnly[ExtensionValue] { (x, out) => + given extensionValue: JsCodec[ExtensionValue] = JsEncoderOnly[ExtensionValue] { (x, out) => JsonCodecMaker.make[String].encodeValue(x.toString, out) } - implicit val encoderOAuthFlow: JsCodec[OAuthFlow] = JsonCodecMaker.make - implicit val encoderOAuthFlows: JsCodec[OAuthFlows] = JsonCodecMaker.make - implicit val encoderSecurityScheme: JsCodec[SecurityScheme] = JsonCodecMaker.make - implicit val encoderExampleSingleValue: JsCodec[ExampleSingleValue] = JsEncoderOnly { + given encoderOAuthFlow: JsCodec[OAuthFlow] = JsonCodecMaker.make + given encoderOAuthFlows: JsCodec[OAuthFlows] = JsonCodecMaker.make + given encoderSecurityScheme: JsCodec[SecurityScheme] = JsonCodecMaker.make + given encoderExampleSingleValue: JsCodec[ExampleSingleValue] = JsEncoderOnly { // TODO: handle parse -> encode JSON - case (ExampleSingleValue(value: String), out) => JsonCodecMaker.make[String].encodeValue(value, out) - case (ExampleSingleValue(value: Int), out) => JsonCodecMaker.make[Int].encodeValue(value, out) - case (ExampleSingleValue(value: Long), out) => JsonCodecMaker.make[Long].encodeValue(value, out) - case (ExampleSingleValue(value: Float), out) => JsonCodecMaker.make[Float].encodeValue(value, out) - case (ExampleSingleValue(value: Double), out) => JsonCodecMaker.make[Double].encodeValue(value, out) - case (ExampleSingleValue(value: Boolean), out) => JsonCodecMaker.make[Boolean].encodeValue(value, out) + case (ExampleSingleValue(value: String), out) => JsonCodecMaker.make[String].encodeValue(value, out) + case (ExampleSingleValue(value: Int), out) => JsonCodecMaker.make[Int].encodeValue(value, out) + case (ExampleSingleValue(value: Long), out) => JsonCodecMaker.make[Long].encodeValue(value, out) + case (ExampleSingleValue(value: Float), out) => JsonCodecMaker.make[Float].encodeValue(value, out) + case (ExampleSingleValue(value: Double), out) => JsonCodecMaker.make[Double].encodeValue(value, out) + case (ExampleSingleValue(value: Boolean), out) => JsonCodecMaker.make[Boolean].encodeValue(value, out) case (ExampleSingleValue(value: BigDecimal), out) => JsonCodecMaker.make[BigDecimal].encodeValue(value, out) - case (ExampleSingleValue(value: BigInt), out) => JsonCodecMaker.make[BigInt].encodeValue(value, out) - case (ExampleSingleValue(null), out) => // scalastyle:ignore null - JsonCodecMaker.make[Option[String]].encodeValue(None, out) - case (ExampleSingleValue(value), out) => JsonCodecMaker.make[String].encodeValue(value.toString, out) + case (ExampleSingleValue(value: BigInt), out) => JsonCodecMaker.make[BigInt].encodeValue(value, out) + case (ExampleSingleValue(null), out) => JsonCodecMaker.make[Option[String]].encodeValue(None, out) + case (ExampleSingleValue(value), out) => JsonCodecMaker.make[String].encodeValue(value.toString, out) } val encodeExampleMultipleValues: JsCodec[ExampleMultipleValue] = - summonCodec[List[ExampleSingleValue]](JsonCodecMaker.make).map[ExampleMultipleValue](_ => ???) { + JsonCodecMaker.make[List[ExampleSingleValue]].map[ExampleMultipleValue](_ => ???) { case ExampleMultipleValue(values) => values.map(ExampleSingleValue) } - implicit val encodeExampleValue: JsCodec[ExampleValue] = JsEncoderOnly[ExampleValue] { - case (e: ExampleSingleValue, out) => encoderExampleSingleValue.encodeValue(e, out) + given encodeExampleValue: JsCodec[ExampleValue] = JsEncoderOnly[ExampleValue] { + case (e: ExampleSingleValue, out) => encoderExampleSingleValue.encodeValue(e, out) case (e: ExampleMultipleValue, out) => encodeExampleMultipleValues.encodeValue(e, out) } - implicit val encoderSchemaType: JsCodec[SchemaType] = summonCodec[String](JsonCodecMaker.make).map(_ => ???)(_.value) - implicit val encoderSchema: JsCodec[Schema] = JsonCodecMaker.make - implicit val encoderHeader: JsCodec[Header] = JsonCodecMaker.make - implicit val encoderExample: JsCodec[Example] = JsonCodecMaker.make - implicit val encoderResponse: JsCodec[Response] = JsonCodecMaker.make - implicit val encoderLink: JsCodec[Link] = JsonCodecMaker.make - implicit lazy val encoderCallback: JsCodec[Callback] = + given encoderPattern: JsonKeyCodec[Pattern] = new JsonKeyCodec[Pattern] { + private val impl = JsonCodecMaker.make[String] + override def decodeKey(in: JsonReader): Pattern = Pattern(impl.decodeValue(in, "")) + override def encodeKey(x: Pattern, out: JsonWriter): Unit = impl.encodeValue(x.value, out) + } + given encoderSchemaType: JsCodec[SchemaType] = JsonCodecMaker.make[String].map(_ => ???)(_.value) + given encoderSchemaLike: JsCodec[SchemaLike] = JsonCodecMaker.make + given encoderSchema: JsCodec[Schema] = JsonCodecMaker.makeWithoutDiscriminator + given encoderHeader: JsCodec[Header] = JsonCodecMaker.make + given encoderExample: JsCodec[Example] = JsonCodecMaker.make + given encoderResponse: JsCodec[Response] = JsonCodecMaker.make + given encoderLink: JsCodec[Link] = JsonCodecMaker.make + given encoderCallback: JsCodec[Callback] = encodeListMap(encoderReferenceOr[PathItem]).map[Callback](_ => ???)(_.pathItems) - implicit val encoderEncoding: JsCodec[Encoding] = JsonCodecMaker.make - implicit val encoderMediaType: JsCodec[MediaType] = JsonCodecMaker.make - implicit val encoderRequestBody: JsCodec[RequestBody] = JsonCodecMaker.make - implicit val encoderParameterStyle: JsCodec[ParameterStyle] = - summonCodec[String](JsonCodecMaker.make).map(_ => ???)(_.value) - implicit val encoderParameterIn: JsCodec[ParameterIn] = JsonCodecMaker.make - implicit val encoderParameter: JsCodec[Parameter] = JsonCodecMaker.make - implicit val encoderResponseMap: JsCodec[ListMap[ResponsesKey, ReferenceOr[Response]]] = - summonCodec[Map[String, ReferenceOr[Response]]]( - JsonCodecMaker.make(CodecMakerConfig.withAllowRecursiveTypes(true)) - ).map[ListMap[ResponsesKey, ReferenceOr[Response]]](_ => ???)( - _.map { - case (ResponsesDefaultKey, r) => ("default", r) - case (ResponsesCodeKey(code), r) => (code.toString, r) - case (ResponsesRangeKey(range), r) => (s"${range}XX", r) - } - ) + given encoderEncoding: JsCodec[Encoding] = JsonCodecMaker.make + given encoderMediaType: JsCodec[MediaType] = JsonCodecMaker.make + given encoderRequestBody: JsCodec[RequestBody] = JsonCodecMaker.make + given encoderParameterStyle: JsCodec[ParameterStyle] = JsonCodecMaker.make[String].map(_ => ???)(_.value) + given encoderParameterIn: JsCodec[ParameterIn] = JsonCodecMaker.make + given encoderParameter: JsCodec[Parameter] = JsonCodecMaker.make + given encoderResponseMap: JsCodec[ListMap[ResponsesKey, ReferenceOr[Response]]] = + JsonCodecMaker + .make[Map[String, ReferenceOr[Response]]](CodecMakerConfig.withAllowRecursiveTypes(true)) + .map[ListMap[ResponsesKey, ReferenceOr[Response]]](_ => ???)( + _.map { + case (ResponsesDefaultKey, r) => ("default", r) + case (ResponsesCodeKey(code), r) => (code.toString, r) + case (ResponsesRangeKey(range), r) => (s"${range}XX", r) + } + ) // TODO: handle extensions one day - implicit val encoderResponses: JsCodec[Responses] = encoderResponseMap.map[Responses](_ => ???) { + given encoderResponses: JsCodec[Responses] = encoderResponseMap.map[Responses](_ => ???) { case Responses(responses, _) => responses } // this is needed to override the encoding of `security: List[SecurityRequirement]`. An empty security requirement // should be represented as an empty object (`{}`), not `null`, which is the default encoding of `ListMap`s. - implicit def encodeSecurityRequirement: JsCodec[List[SecurityRequirement]] = + given encodeSecurityRequirement: JsCodec[List[SecurityRequirement]] = JsonCodecMaker.make(CodecMakerConfig.withAllowRecursiveTypes(true).withTransientEmpty(true)) - implicit val operationCodec: JsCodec[Operation] = JsonCodecMaker.make(CodecMakerConfig.withAllowRecursiveTypes(true)) - implicit val encoderPathItem: JsCodec[PathItem] = JsonCodecMaker.make - implicit val encoderPaths: JsCodec[Paths] = - summonCodec[ListMap[String, PathItem]](JsonCodecMaker.make).map(_ => ???) { case Paths(pathItems, _) => - pathItems - } - implicit val encoderComponents: JsCodec[Components] = JsonCodecMaker.make - implicit val encoderServerVariable: JsCodec[ServerVariable] = JsonCodecMaker.make - implicit val encoderServer: JsCodec[Server] = JsonCodecMaker.make - implicit val encoderExternalDocumentation: JsCodec[ExternalDocumentation] = JsonCodecMaker.make - implicit val encoderTag: JsCodec[Tag] = JsonCodecMaker.make - implicit val encoderInfo: JsCodec[Info] = JsonCodecMaker.make - implicit val encoderContact: JsCodec[Contact] = JsonCodecMaker.make - implicit val encoderLicense: JsCodec[License] = JsonCodecMaker.make - implicit val encoderOpenAPI: JsCodec[OpenAPI] = + given operationCodec: JsCodec[Operation] = JsonCodecMaker.make(CodecMakerConfig.withAllowRecursiveTypes(true)) + given encoderPathItem: JsCodec[PathItem] = JsonCodecMaker.make + given encoderPaths: JsCodec[Paths] = + JsonCodecMaker.make[ListMap[String, PathItem]].map(_ => ???) { case Paths(pathItems, _) => pathItems } + given encoderComponents: JsCodec[Components] = JsonCodecMaker.make + given encoderServerVariable: JsCodec[ServerVariable] = JsonCodecMaker.make + given encoderServer: JsCodec[Server] = JsonCodecMaker.make + given encoderExternalDocumentation: JsCodec[ExternalDocumentation] = JsonCodecMaker.make + given encoderTag: JsCodec[Tag] = JsonCodecMaker.make + given encoderInfo: JsCodec[Info] = JsonCodecMaker.make + given encoderContact: JsCodec[Contact] = JsonCodecMaker.make + given encoderLicense: JsCodec[License] = JsonCodecMaker.make + given encoderOpenAPI: JsCodec[OpenAPI] = JsonCodecMaker.make(CodecMakerConfig.withTransientDefault(false).withTransientNone(true)) - implicit val encoderDiscriminator: JsCodec[Discriminator] = JsonCodecMaker.make + given encoderDiscriminator: JsCodec[Discriminator] = JsonCodecMaker.make - implicit def encodeList[T: JsCodec]: JsCodec[List[T]] = JsEncoderOnly[List[T]] { - case (Nil, out) => - summonCodec[Option[T]](JsonCodecMaker.make(CodecMakerConfig.withTransientNone(false))).encodeValue(None, out) - case (list, out) => - summonCodec[Vector[T]](JsonCodecMaker.make).encodeValue(list.toVector, out) + given encodeList[T: JsCodec]: JsCodec[List[T]] = JsEncoderOnly[List[T]] { + case (Nil, out) => JsonCodecMaker.make[Option[T]](CodecMakerConfig.withTransientNone(false)).encodeValue(None, out) + case (list, out) => JsonCodecMaker.make[Vector[T]].encodeValue(list.toVector, out) } - implicit def encodeListMap[V: JsCodec]: JsCodec[ListMap[String, V]] = + given encodeListMap[V: JsCodec]: JsCodec[ListMap[String, V]] = JsonCodecMaker.make(CodecMakerConfig.withTransientEmpty(false)) } diff --git a/modules/server/src/main/scala/io/branchtalk/users/api/ChannelBanServer.scala b/modules/server/src/main/scala/io/branchtalk/users/api/ChannelBanServer.scala index 6165a712..f164ddf6 100644 --- a/modules/server/src/main/scala/io/branchtalk/users/api/ChannelBanServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/users/api/ChannelBanServer.scala @@ -3,15 +3,16 @@ package io.branchtalk.users.api import cats.data.NonEmptyList import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api.{ Permission => _, _ } -import io.branchtalk.auth._ +import io.branchtalk.api.{ Permission => _, * } +import io.branchtalk.auth.{ *, given } +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.CommonError -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Ban, User } import io.branchtalk.users.reads.BanReads import io.branchtalk.users.writes.BanWrites -import org.http4s._ -import sttp.tapir.server.http4s._ +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint final class ChannelBanServer[F[_]: Async]( @@ -20,14 +21,14 @@ final class ChannelBanServer[F[_]: Async]( banWrites: BanWrites[F] ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - private val serverOptions: Http4sServerOptions[F] = ChannelBanServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = ChannelBanServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, UserError] = - ChannelBanServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, UserError] = + ChannelBanServer.errorHandler[F](logger) private val list = ChannelBanAPIs.list.serverLogic[F, User] { channelID => for { @@ -62,7 +63,7 @@ final class ChannelBanServer[F[_]: Async]( } object ChannelBanServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( _, ServerOptions.ErrorHandler[UserError]( () => UserError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -78,7 +79,7 @@ object ChannelBanServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, UserError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, UserError] = ServerErrorHandler.handleCommonErrors[F, UserError] { case CommonError.InvalidCredentials(_) => UserError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/users/api/ChannelModerationServer.scala b/modules/server/src/main/scala/io/branchtalk/users/api/ChannelModerationServer.scala index 5ea1a6dd..d6b8811a 100644 --- a/modules/server/src/main/scala/io/branchtalk/users/api/ChannelModerationServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/users/api/ChannelModerationServer.scala @@ -3,16 +3,17 @@ package io.branchtalk.users.api import cats.data.NonEmptyList import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api.{ Permission => _, _ } -import io.branchtalk.auth._ +import io.branchtalk.api.{ Permission => _, * } +import io.branchtalk.auth.{ *, given } import io.branchtalk.configs.PaginationConfig +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.{ CommonError, OptionUpdatable, Updatable } -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Permission, User } import io.branchtalk.users.reads.UserReads import io.branchtalk.users.writes.UserWrites -import org.http4s._ -import sttp.tapir.server.http4s._ +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint final class ChannelModerationServer[F[_]: Async]( @@ -22,14 +23,13 @@ final class ChannelModerationServer[F[_]: Async]( paginationConfig: PaginationConfig ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - private val serverOptions: Http4sServerOptions[F] = ChannelModerationServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = ChannelModerationServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, UserError] = - ChannelModerationServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, UserError] = ChannelModerationServer.errorHandler[F](logger) private val paginate = ChannelModerationAPIs.paginate.serverLogic[F, User] { case (channelID, optOffset, optLimit) => @@ -38,7 +38,7 @@ final class ChannelModerationServer[F[_]: Async]( val limit = paginationConfig.resolveLimit(optLimit) val filters = List(User.Filter.HasPermission(Permission.ModerateChannel(channelID))) for { - paginated <- userReads.paginate(sortBy, offset.nonNegativeLong, limit.positiveInt, filters) + paginated <- userReads.paginate(sortBy, offset, limit, filters) } yield Pagination.fromPaginated(paginated.map(APIUser.fromDomain), offset, limit) } @@ -83,7 +83,7 @@ final class ChannelModerationServer[F[_]: Async]( object ChannelModerationServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( _, ServerOptions.ErrorHandler[UserError]( () => UserError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -99,7 +99,7 @@ object ChannelModerationServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, UserError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, UserError] = ServerErrorHandler.handleCommonErrors[F, UserError] { case CommonError.InvalidCredentials(_) => UserError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/users/api/UserBanServer.scala b/modules/server/src/main/scala/io/branchtalk/users/api/UserBanServer.scala index b0fc0a3b..fde4c16c 100644 --- a/modules/server/src/main/scala/io/branchtalk/users/api/UserBanServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/users/api/UserBanServer.scala @@ -3,15 +3,16 @@ package io.branchtalk.users.api import cats.data.NonEmptyList import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api.{ Permission => _, _ } -import io.branchtalk.auth._ +import io.branchtalk.api.{ Permission => _, * } +import io.branchtalk.auth.{ *, given } +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.CommonError -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Ban, User } import io.branchtalk.users.reads.BanReads import io.branchtalk.users.writes.BanWrites -import org.http4s._ -import sttp.tapir.server.http4s._ +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint final class UserBanServer[F[_]: Async]( @@ -20,14 +21,13 @@ final class UserBanServer[F[_]: Async]( banWrites: BanWrites[F] ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - private val serverOptions: Http4sServerOptions[F] = UserBanServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = UserBanServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, UserError] = - UserBanServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, UserError] = UserBanServer.errorHandler[F](logger) private val list = UserBanAPIs.list.serverLogic[F, User] { _ => for { @@ -60,7 +60,7 @@ final class UserBanServer[F[_]: Async]( } object UserBanServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( _, ServerOptions.ErrorHandler[UserError]( () => UserError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -76,7 +76,7 @@ object UserBanServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, UserError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, UserError] = ServerErrorHandler.handleCommonErrors[F, UserError] { case CommonError.InvalidCredentials(_) => UserError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/users/api/UserModerationServer.scala b/modules/server/src/main/scala/io/branchtalk/users/api/UserModerationServer.scala index 018046d1..2599bb24 100644 --- a/modules/server/src/main/scala/io/branchtalk/users/api/UserModerationServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/users/api/UserModerationServer.scala @@ -3,16 +3,17 @@ package io.branchtalk.users.api import cats.data.NonEmptyList import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api.{ Permission => _, _ } -import io.branchtalk.auth._ +import io.branchtalk.api.{ Permission => _, * } +import io.branchtalk.auth.{ *, given } import io.branchtalk.configs.PaginationConfig +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.{ CommonError, OptionUpdatable, Updatable } -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Permission, User } import io.branchtalk.users.reads.UserReads import io.branchtalk.users.writes.UserWrites -import org.http4s._ -import sttp.tapir.server.http4s._ +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint final class UserModerationServer[F[_]: Async]( @@ -22,14 +23,13 @@ final class UserModerationServer[F[_]: Async]( paginationConfig: PaginationConfig ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) - private val serverOptions: Http4sServerOptions[F] = UserModerationServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = UserModerationServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, UserError] = - UserModerationServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, UserError] = UserModerationServer.errorHandler[F](logger) private val paginate = UserModerationAPIs.paginate.serverLogic[F, User] { case (optOffset, optLimit) => val sortBy = User.Sorting.NameAlphabetically @@ -37,7 +37,7 @@ final class UserModerationServer[F[_]: Async]( val limit = paginationConfig.resolveLimit(optLimit) val filters = List(User.Filter.HasPermission(Permission.ModerateUsers)) for { - paginated <- userReads.paginate(sortBy, offset.nonNegativeLong, limit.positiveInt, filters) + paginated <- userReads.paginate(sortBy, offset, limit, filters) } yield Pagination.fromPaginated(paginated.map(APIUser.fromDomain), offset, limit) } @@ -81,7 +81,7 @@ final class UserModerationServer[F[_]: Async]( object UserModerationServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( _, ServerOptions.ErrorHandler[UserError]( () => UserError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -97,7 +97,7 @@ object UserModerationServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, UserError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, UserError] = ServerErrorHandler.handleCommonErrors[F, UserError] { case CommonError.InvalidCredentials(_) => UserError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/main/scala/io/branchtalk/users/api/UserServer.scala b/modules/server/src/main/scala/io/branchtalk/users/api/UserServer.scala index 3b11e5b9..c9eb1b7e 100644 --- a/modules/server/src/main/scala/io/branchtalk/users/api/UserServer.scala +++ b/modules/server/src/main/scala/io/branchtalk/users/api/UserServer.scala @@ -3,18 +3,19 @@ package io.branchtalk.users.api import cats.data.NonEmptyList import cats.effect.{ Async, Sync } import com.typesafe.scalalogging.Logger -import io.branchtalk.api._ -import io.branchtalk.auth._ +import io.branchtalk.api.* +import io.branchtalk.auth.{ *, given } import io.branchtalk.configs.PaginationConfig -import io.branchtalk.mappings._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.shared.model.{ CommonError, ID } -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Password, Session, User } import io.branchtalk.users.reads.{ SessionReads, UserReads } import io.branchtalk.users.writes.{ SessionWrites, UserWrites } -import io.scalaland.chimney.dsl._ -import org.http4s._ -import sttp.tapir.server.http4s._ +import io.scalaland.chimney.dsl.* +import org.http4s.* +import sttp.tapir.server.http4s.* import sttp.tapir.server.ServerEndpoint final class UserServer[F[_]: Async]( @@ -26,22 +27,22 @@ final class UserServer[F[_]: Async]( paginationConfig: PaginationConfig ) { - implicit private val as: AuthServices[F] = authServices + private given AuthServices[F] = authServices private val logger = Logger(getClass) private val sessionExpiresInDays = 7L // make it configurable - private val serverOptions: Http4sServerOptions[F] = UserServer.serverOptions[F].apply(logger) + private val serverOptions: Http4sServerOptions[F] = UserServer.serverOptions[F](logger) - implicit private val errorHandler: ServerErrorHandler[F, UserError] = UserServer.errorHandler[F].apply(logger) + private given errorHandler: ServerErrorHandler[F, UserError] = UserServer.errorHandler[F](logger) private val paginate = UserAPIs.paginate.serverLogic[F, User] { case (optOffset, optLimit) => val sortBy = User.Sorting.NameAlphabetically val offset = paginationConfig.resolveOffset(optOffset) val limit = paginationConfig.resolveLimit(optLimit) for { - paginated <- userReads.paginate(sortBy, offset.nonNegativeLong, limit.positiveInt) + paginated <- userReads.paginate(sortBy, offset, limit) } yield Pagination.fromPaginated(paginated.map(APIUser.fromDomain), offset, limit) } @@ -50,7 +51,7 @@ final class UserServer[F[_]: Async]( val offset = paginationConfig.resolveOffset(optOffset) val limit = paginationConfig.resolveLimit(optLimit) for { - paginated <- userReads.paginate(sortBy, offset.nonNegativeLong, limit.positiveInt) + paginated <- userReads.paginate(sortBy, offset, limit) } yield Pagination.fromPaginated(paginated.map(APIUser.fromDomain), offset, limit) } @@ -59,7 +60,7 @@ final class UserServer[F[_]: Async]( val offset = paginationConfig.resolveOffset(optOffset) val limit = paginationConfig.resolveLimit(optLimit) for { - paginated <- sessionReads.paginate(user.id, sortBy, offset.nonNegativeLong, limit.positiveInt) + paginated <- sessionReads.paginate(user.id, sortBy, offset, limit) } yield Pagination.fromPaginated(paginated.map(APISession.fromDomain), offset, limit) } @@ -69,7 +70,7 @@ final class UserServer[F[_]: Async]( (user, session) <- userWrites.createUser( signup.into[User.Create].withFieldConst(_.password, Password.create(signup.password)).transform ) - } yield SignUpResponse(user.id, session.id) + } yield SignUpResponse(user.unwrap, session.unwrap) } } @@ -153,7 +154,7 @@ final class UserServer[F[_]: Async]( } object UserServer { - def serverOptions[F[_]: Sync]: Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( + def serverOptions[F[_]](using Sync[F]): Logger => Http4sServerOptions[F] = ServerOptions.create[F, UserError]( _, ServerOptions.ErrorHandler[UserError]( () => UserError.ValidationFailed(NonEmptyList.one("Data missing")), @@ -169,7 +170,7 @@ object UserServer { ) ) - def errorHandler[F[_]: Sync]: Logger => ServerErrorHandler[F, UserError] = + def errorHandler[F[_]](using Sync[F]): Logger => ServerErrorHandler[F, UserError] = ServerErrorHandler.handleCommonErrors[F, UserError] { case CommonError.InvalidCredentials(_) => UserError.BadCredentials("Invalid credentials") diff --git a/modules/server/src/it/scala/io/branchtalk/TestDependencies.scala b/modules/server/src/test/scala/io/branchtalk/TestDependencies.scala similarity index 74% rename from modules/server/src/it/scala/io/branchtalk/TestDependencies.scala rename to modules/server/src/test/scala/io/branchtalk/TestDependencies.scala index 25ddd923..59b1c726 100644 --- a/modules/server/src/it/scala/io/branchtalk/TestDependencies.scala +++ b/modules/server/src/test/scala/io/branchtalk/TestDependencies.scala @@ -2,10 +2,9 @@ package io.branchtalk import cats.effect.std.Dispatcher import cats.effect.{ Async, Resource } -import com.softwaremill.macwire.wire import io.branchtalk.discussions.{ DiscussionsModule, DiscussionsReads, DiscussionsWrites, TestDiscussionsConfig } import io.branchtalk.logging.MDC -import io.branchtalk.shared.model.UUIDGenerator +import io.branchtalk.shared.model.UUID import io.branchtalk.users.{ TestUsersConfig, UsersModule, UsersReads, UsersWrites } import io.prometheus.client.CollectorRegistry @@ -19,17 +18,14 @@ final case class TestDependencies[F[_]]( ) object TestDependencies { - @nowarn("cat=unused") // macwire - def resources[F[_]: Async: MDC](registry: CollectorRegistry)(implicit - uuidGenerator: UUIDGenerator - ): Resource[F, TestDependencies[F]] = + def resources[F[_]: Async: MDC](registry: CollectorRegistry)(using UUID.Generator): Resource[F, TestDependencies[F]] = for { - implicit0(dispatcher: Dispatcher[F]) <- Dispatcher[F] + given Dispatcher[F] <- Dispatcher.parallel[F] usersConfig <- TestUsersConfig.loadDomainConfig[F] usersReads <- UsersModule.reads[F](usersConfig, registry) usersWrites <- UsersModule.writes[F](usersConfig, registry) discussionsConfig <- TestDiscussionsConfig.loadDomainConfig[F] discussionsReads <- DiscussionsModule.reads[F](discussionsConfig, registry) discussionsWrites <- DiscussionsModule.writes[F](discussionsConfig, registry) - } yield wire[TestDependencies[F]] + } yield TestDependencies[F](usersReads, usersWrites, discussionsReads, discussionsWrites) } diff --git a/modules/server/src/it/scala/io/branchtalk/api/ServerIOTest.scala b/modules/server/src/test/scala/io/branchtalk/api/ServerIOTest.scala similarity index 72% rename from modules/server/src/it/scala/io/branchtalk/api/ServerIOTest.scala rename to modules/server/src/test/scala/io/branchtalk/api/ServerIOTest.scala index ec75fdc0..2d0d3143 100644 --- a/modules/server/src/it/scala/io/branchtalk/api/ServerIOTest.scala +++ b/modules/server/src/test/scala/io/branchtalk/api/ServerIOTest.scala @@ -3,15 +3,16 @@ package io.branchtalk.api import cats.effect.{ IO, Resource } import io.branchtalk.discussions.DiscussionsIOTest import io.branchtalk.users.{ UsersIOTest, UsersModule } +import io.branchtalk.shared.infrastructure.* import org.http4s.server.Server import org.specs2.matcher.{ OptionLikeCheckedMatcher, OptionLikeMatcher, ValueCheck } import sttp.client3.{ Response, SttpBackend } import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend import sttp.model.Uri -import sttp.tapir._ -import sttp.tapir.client.sttp._ +import sttp.tapir.* +import sttp.tapir.client.sttp.* -trait ServerIOTest extends UsersIOTest with DiscussionsIOTest { +trait ServerIOTest extends UsersIOTest, DiscussionsIOTest { // populated by resources protected var server: Server = _ @@ -53,9 +54,9 @@ trait ServerIOTest extends UsersIOTest with DiscussionsIOTest { override protected def testResource: Resource[IO, Unit] = super.testResource >> serverResource - implicit class ServerTestOps[I, E, O](private val endpoint: Endpoint[Unit, I, E, O, Any]) { + extension [I, E, O](endpoint: Endpoint[Unit, I, E, O, Any]) { - val toTestCall: I => IO[Response[DecodeResult[Either[E, O]]]] = (input: I) => + def toTestCall: I => IO[Response[DecodeResult[Either[E, O]]]] = (input: I) => SttpClientInterpreter(SttpClientOptions.default) .toRequest( endpoint, @@ -66,9 +67,9 @@ trait ServerIOTest extends UsersIOTest with DiscussionsIOTest { .send(client) } - implicit class AuthServerTestOps[A, I, E, O](private val authEndpoint: AuthedEndpoint[A, I, E, O, Any]) { + extension [A, I, E, O](authEndpoint: AuthedEndpoint[A, I, E, O, Any]) { - val toTestCall: (A, I) => IO[Response[DecodeResult[Either[E, O]]]] = (auth: A, input: I) => + def toTestCall: (A, I) => IO[Response[DecodeResult[Either[E, O]]]] = (auth: A, input: I) => SttpClientInterpreter(SttpClientOptions.default) .toSecureRequest( authEndpoint.endpoint, @@ -80,9 +81,9 @@ trait ServerIOTest extends UsersIOTest with DiscussionsIOTest { .send(client) } - implicit class AuthOnlyServerTestOps[A, E, O](private val authEndpoint: AuthedEndpoint[A, Unit, E, O, Any]) { + extension [A, E, O](authEndpoint: AuthedEndpoint[A, Unit, E, O, Any]) { - val toTestCall: A => IO[Response[DecodeResult[Either[E, O]]]] = (auth: A) => + def toTestCall: A => IO[Response[DecodeResult[Either[E, O]]]] = (auth: A) => SttpClientInterpreter(SttpClientOptions.default) .toSecureRequest( authEndpoint.endpoint, @@ -94,19 +95,16 @@ trait ServerIOTest extends UsersIOTest with DiscussionsIOTest { .send(client) } - import ServerIOTest._ + import ServerIOTest.* + export ServerIOTest.toValidOpt - implicit def toDecoderResultOps[A](result: DecodeResult[A]): DecodeResultOps[A] = new DecodeResultOps[A](result) - - import org.specs2.control.ImplicitParameters._ - def beValid[T](t: ValueCheck[T]): ValidResultCheckedMatcher[T] = ValidResultCheckedMatcher(t) - def beValid[T](implicit p: ImplicitParam = implicitParameter): ValidResultMatcher[T] = use(p)(ValidResultMatcher[T]()) + def beValid[T](t: ValueCheck[T]): ValidResultCheckedMatcher[T] = ValidResultCheckedMatcher(t) + def beValid[T](using DummyImplicit): ValidResultMatcher[T] = ValidResultMatcher[T]() } object ServerIOTest { - implicit class DecodeResultOps[A](private val result: DecodeResult[A]) extends AnyVal { - + extension [A](result: DecodeResult[A]) { def toValidOpt: Option[A] = result match { case DecodeResult.Value(t) => t.some case _ => none[A] @@ -114,8 +112,8 @@ object ServerIOTest { } final case class ValidResultMatcher[T]() - extends OptionLikeMatcher[DecodeResult, T, T]("DecodeResult.Value", (_: DecodeResult[T]).toValidOpt) + extends OptionLikeMatcher[DecodeResult[T], T]("DecodeResult.Value", (_: DecodeResult[T]).toValidOpt) final case class ValidResultCheckedMatcher[T](check: ValueCheck[T]) - extends OptionLikeCheckedMatcher[DecodeResult, T, T]("DecodeResult.Value", (_: DecodeResult[T]).toValidOpt, check) + extends OptionLikeCheckedMatcher[DecodeResult[T], T]("DecodeResult.Value", (_: DecodeResult[T]).toValidOpt, check) } diff --git a/modules/server/src/it/scala/io/branchtalk/api/TestApiConfigs.scala b/modules/server/src/test/scala/io/branchtalk/api/TestApiConfigs.scala similarity index 72% rename from modules/server/src/it/scala/io/branchtalk/api/TestApiConfigs.scala rename to modules/server/src/test/scala/io/branchtalk/api/TestApiConfigs.scala index cdcf9920..2d977c60 100644 --- a/modules/server/src/it/scala/io/branchtalk/api/TestApiConfigs.scala +++ b/modules/server/src/test/scala/io/branchtalk/api/TestApiConfigs.scala @@ -1,11 +1,14 @@ package io.branchtalk.api +import java.net.URI import cats.effect.{ Async, Resource, Sync } import io.branchtalk.configs.{ APIConfig, APIContact, APIHttp, APIInfo, APILicense, AppArguments } -import io.branchtalk.shared.model.UUIDGenerator +import io.branchtalk.discussions.model.Post +import io.branchtalk.shared.model.UUID +import io.branchtalk.users.model.User import scala.collection.mutable -import scala.concurrent.duration._ +import scala.concurrent.duration.* object TestApiConfigs { @@ -30,7 +33,7 @@ object TestApiConfigs { private def portResource[F[_]: Async]: Resource[F, Int] = Resource.make(acquirePort[F])(releasePort[F](_)) - def asResource[F[_]: Async](implicit uuidGenerator: UUIDGenerator): Resource[F, (AppArguments, APIConfig)] = + def asResource[F[_]: Async](using uuidGenerator: UUID.Generator): Resource[F, (AppArguments, APIConfig)] = (Resource.eval(uuidGenerator.create[F]), portResource[F]).mapN { (defaultChannelID, port) => val host = "localhost" val app = AppArguments( @@ -45,9 +48,13 @@ object TestApiConfigs { title = "test", version = "test", description = "test", - termsOfService = "http://branchtalk.io", - contact = APIContact(name = "test", email = "test@brachtalk.io", url = "http://branchtalk.io"), - license = APILicense(name = "test", url = "http://branchtalk.io") + termsOfService = Post.URL.unsafeMake(URI.create("http://branchtalk.io")), + contact = APIContact( + name = "test", + email = User.Email.unsafeMake("test@brachtalk.io"), + url = Post.URL.unsafeMake(URI.create("http://branchtalk.io")) + ), + license = APILicense(name = "test", url = Post.URL.unsafeMake(URI.create("http://branchtalk.io"))) ), http = APIHttp( logHeaders = true, diff --git a/modules/server/src/it/scala/io/branchtalk/discussions/api/ChannelServerPaginationSpec.scala b/modules/server/src/test/scala/io/branchtalk/discussions/api/ChannelServerPaginationSpec.scala similarity index 64% rename from modules/server/src/it/scala/io/branchtalk/discussions/api/ChannelServerPaginationSpec.scala rename to modules/server/src/test/scala/io/branchtalk/discussions/api/ChannelServerPaginationSpec.scala index 3ea8dd94..74f76914 100644 --- a/modules/server/src/it/scala/io/branchtalk/discussions/api/ChannelServerPaginationSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/discussions/api/ChannelServerPaginationSpec.scala @@ -2,22 +2,19 @@ package io.branchtalk.discussions.api import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.discussions.api.ChannelModels._ -import io.branchtalk.shared.model._ +import io.branchtalk.discussions.api.ChannelModels.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures import org.specs2.mutable.Specification import sttp.model.StatusCode -final class ChannelServerPaginationSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { +final class ChannelServerPaginationSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { // Channel pagination tests cannot be run in parallel to other Channel tests (no parent to filter other tests) sequential - implicit protected lazy val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "ChannelServer-provided pagination endpoints" should { @@ -27,26 +24,24 @@ final class ChannelServerPaginationSpec for { // given channelIDs <- (0 until 10).toList.traverse(_ => - channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.id) + channelCreate.flatMap(discussionsWrites.channelWrites.createChannel).map(_.unwrap) ) channels <- channelIDs.traverse(discussionsReads.channelReads.requireById(_)).eventually() // when - response1 <- ChannelAPIs.paginate.toTestCall.untupled(None, None, PaginationLimit(5).some) + response1 <- ChannelAPIs.paginate.toTestCall.untupled(None, None, Pagination.Limit(5).some) response2 <- ChannelAPIs.paginate.toTestCall.untupled(None, - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIChannel]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIChannel]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIChannel]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIChannel]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== channels - .map(APIChannel.fromDomain) - .toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === channels.map(APIChannel.fromDomain).toSet } .getOrElse(pass) } diff --git a/modules/server/src/it/scala/io/branchtalk/discussions/api/ChannelServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/discussions/api/ChannelServerSpec.scala similarity index 78% rename from modules/server/src/it/scala/io/branchtalk/discussions/api/ChannelServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/discussions/api/ChannelServerSpec.scala index 04c2e0ea..5f49a8b9 100644 --- a/modules/server/src/it/scala/io/branchtalk/discussions/api/ChannelServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/discussions/api/ChannelServerSpec.scala @@ -1,22 +1,23 @@ package io.branchtalk.discussions.api import cats.effect.IO -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import com.softwaremill.quicklens.* +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.discussions.api.ChannelModels._ +import io.branchtalk.discussions.api.ChannelModels.* import io.branchtalk.discussions.model.Channel -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures import io.branchtalk.users.model.{ Permission, RequiredPermissions } -import io.scalaland.chimney.dsl._ -import monocle.macros.syntax.lens._ +import io.scalaland.chimney.dsl.* import org.specs2.mutable.Specification import sttp.model.StatusCode -final class ChannelServerSpec extends Specification with ServerIOTest with UsersFixtures with DiscussionsFixtures { +final class ChannelServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected lazy val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "ChannelServer-provided endpoints" should { @@ -41,8 +42,8 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users // TODO: check that this creates a new channel eventually! } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[CreateChannelResponse])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[CreateChannelResponse])) } } } @@ -66,8 +67,8 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users ) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(APIChannel.fromDomain(channel)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(APIChannel.fromDomain(channel)))) } } } @@ -83,7 +84,7 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users _ <- usersReads.userReads.requireById(userID).eventually() _ <- usersReads.sessionReads.requireById(sessionID).eventually() CreationScheduled(channelID) <- channelCreate - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // make User Channels' owner + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // make User Channels' owner .flatMap(discussionsWrites.channelWrites.createChannel) channel <- discussionsReads.channelReads.requireById(channelID).eventually() _ <- usersReads.userReads @@ -94,9 +95,9 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users ) ) .eventually() - newUrlName <- Channel.UrlName.parse[IO]("new-name") - newName <- Channel.Name.parse[IO]("new name") - newDescription <- Channel.Description.parse[IO]("lorem ipsum") + newUrlName <- ParseNewtype[IO].parse[Channel.UrlName]("new-name") + newName <- ParseNewtype[IO].parse[Channel.Name]("new name") + newDescription <- ParseNewtype[IO].parse[Channel.Description]("lorem ipsum") // when response <- ChannelAPIs.update.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), @@ -113,17 +114,17 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(UpdateChannelResponse(channelID)))) - updatedChannel must_=== channel - .focus(_.data.urlName) - .replace(newUrlName) - .focus(_.data.name) - .replace(newName) - .focus(_.data.description) - .replace(newDescription.some) - .focus(_.data.lastModifiedAt) - .replace(updatedChannel.data.lastModifiedAt) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(UpdateChannelResponse(channelID)))) + updatedChannel === channel + .modify(_.data.urlName) + .setTo(newUrlName) + .modify(_.data.name) + .setTo(newName) + .modify(_.data.description) + .setTo(newDescription.some) + .modify(_.data.lastModifiedAt) + .setTo(updatedChannel.data.lastModifiedAt) } } } @@ -139,7 +140,7 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users _ <- usersReads.userReads.requireById(userID).eventually() _ <- usersReads.sessionReads.requireById(sessionID).eventually() CreationScheduled(channelID) <- channelCreate - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // make User Channels' owner + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // make User Channels' owner .flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() _ <- usersReads.userReads @@ -161,8 +162,8 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(DeleteChannelResponse(channelID)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(DeleteChannelResponse(channelID)))) } } } @@ -178,7 +179,7 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users _ <- usersReads.userReads.requireById(userID).eventually() _ <- usersReads.sessionReads.requireById(sessionID).eventually() CreationScheduled(channelID) <- channelCreate - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // make User Channels' owner + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // make User Channels' owner .flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() _ <- usersReads.userReads @@ -204,8 +205,8 @@ final class ChannelServerSpec extends Specification with ServerIOTest with Users .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(RestoreChannelResponse(channelID)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(RestoreChannelResponse(channelID)))) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/discussions/api/CommentServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/discussions/api/CommentServerSpec.scala similarity index 85% rename from modules/server/src/it/scala/io/branchtalk/discussions/api/CommentServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/discussions/api/CommentServerSpec.scala index 477fece1..3b521f24 100644 --- a/modules/server/src/it/scala/io/branchtalk/discussions/api/CommentServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/discussions/api/CommentServerSpec.scala @@ -1,20 +1,21 @@ package io.branchtalk.discussions.api -import io.branchtalk.api.{ Authentication, Pagination, PaginationLimit, PaginationOffset, ServerIOTest } +import com.softwaremill.quicklens.* +import io.branchtalk.api.{ Authentication, Pagination, ServerIOTest } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.discussions.api.CommentModels._ +import io.branchtalk.discussions.api.CommentModels.* import io.branchtalk.discussions.model.Comment -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.scalaland.chimney.dsl._ -import monocle.macros.syntax.lens._ +import io.scalaland.chimney.dsl.* import org.specs2.mutable.Specification import sttp.model.StatusCode -final class CommentServerSpec extends Specification with ServerIOTest with UsersFixtures with DiscussionsFixtures { +final class CommentServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected lazy val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "CommentServer-provided endpoints" should { @@ -28,7 +29,7 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users CreationScheduled(postID) <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() commentIDs <- (0 until 10).toList.traverse(_ => - commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.id) + commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.unwrap) ) comments <- commentIDs.traverse(discussionsReads.commentReads.requireById(_)).eventually() // when @@ -36,27 +37,25 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users channelID, postID, None, - PaginationLimit(5).some, + Pagination.Limit(5).some, None ) response2 <- CommentAPIs.newest.toTestCall.untupled(None, channelID, postID, - PaginationOffset(5L).some, - PaginationLimit(5).some, + Pagination.Offset(5L).some, + Pagination.Limit(5).some, None ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIComment]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIComment]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIComment]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIComment]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== comments - .map(APIComment.fromDomain) - .toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === comments.map(APIComment.fromDomain).toSet } .getOrElse(pass) } @@ -73,19 +72,19 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users CreationScheduled(postID) <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() commentIDs <- (0 until 10).toList.traverse(_ => - commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.id) + commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.unwrap) ) comments <- commentIDs.traverse(discussionsReads.commentReads.requireById(_)).eventually() // when response <- CommentAPIs.hottest.toTestCall.untupled(None, channelID, postID, None) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[Pagination[APIComment]])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[Pagination[APIComment]])) response.body.toValidOpt .flatMap(_.toOption) .map { pagination => - pagination.entities.toSet must_=== comments.map(APIComment.fromDomain).toSet + pagination.entities.toSet === comments.map(APIComment.fromDomain).toSet } .getOrElse(pass) } @@ -102,18 +101,18 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users CreationScheduled(postID) <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() commentIDs <- (0 until 10).toList.traverse(_ => - commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.id) + commentCreate(postID).flatMap(discussionsWrites.commentWrites.createComment).map(_.unwrap) ) comments <- commentIDs.traverse(discussionsReads.commentReads.requireById(_)).eventually() // when response <- CommentAPIs.controversial.toTestCall.untupled(None, channelID, postID, None) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[Pagination[APIComment]])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[Pagination[APIComment]])) response.body.toValidOpt .flatMap(_.toOption) - .map(_.entities.toSet must_=== comments.map(APIComment.fromDomain).toSet) + .map(_.entities.toSet === comments.map(APIComment.fromDomain).toSet) .getOrElse(pass) } } @@ -144,8 +143,8 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users // TODO: check that this creates a new comment eventually! } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[CreateCommentResponse])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[CreateCommentResponse])) } } } @@ -177,8 +176,8 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users ) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(APIComment.fromDomain(comment)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(APIComment.fromDomain(comment)))) } } } @@ -198,7 +197,7 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users CreationScheduled(postID) <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() CreationScheduled(commentID) <- commentCreate(postID) - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // to own the Comment + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // to own the Comment .flatMap(discussionsWrites.commentWrites.createComment) comment <- discussionsReads.commentReads.requireById(commentID).eventually() newContent = Comment.Content("lorem ipsum") @@ -218,13 +217,13 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(UpdateCommentResponse(commentID)))) - updatedComment must_=== comment - .focus(_.data.content) - .replace(newContent) - .focus(_.data.lastModifiedAt) - .replace(updatedComment.data.lastModifiedAt) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(UpdateCommentResponse(commentID)))) + updatedComment === comment + .modify(_.data.content) + .setTo(newContent) + .modify(_.data.lastModifiedAt) + .setTo(updatedComment.data.lastModifiedAt) } } } @@ -244,7 +243,7 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users CreationScheduled(postID) <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() CreationScheduled(commentID) <- commentCreate(postID) - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // to own the Comment + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // to own the Comment .flatMap(discussionsWrites.commentWrites.createComment) _ <- discussionsReads.commentReads.requireById(commentID).eventually() // when @@ -260,8 +259,8 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(DeleteCommentResponse(commentID)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(DeleteCommentResponse(commentID)))) } } } @@ -281,7 +280,7 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users CreationScheduled(postID) <- postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() CreationScheduled(commentID) <- commentCreate(postID) - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // to own the Comment + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // to own the Comment .flatMap(discussionsWrites.commentWrites.createComment) _ <- discussionsReads.commentReads.requireById(commentID).eventually() _ <- discussionsWrites.commentWrites.deleteComment( @@ -301,8 +300,8 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(RestoreCommentResponse(commentID)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(RestoreCommentResponse(commentID)))) } } } @@ -332,11 +331,11 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users ) _ <- discussionsReads.commentReads .requireById(commentID) - .assert("Upvoted entity should have changed score")(_.data.totalScore.toInt =!= 0) + .assert("Upvoted entity should have changed score")(_.data.totalScore.unwrap =!= 0) .eventually() } yield // then - response.code must_=== StatusCode.Ok + response.code === StatusCode.Ok } } @@ -365,11 +364,11 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users ) _ <- discussionsReads.commentReads .requireById(commentID) - .assert("Downvoted entity should have changed score")(_.data.totalScore.toInt =!= 0) + .assert("Downvoted entity should have changed score")(_.data.totalScore.unwrap =!= 0) .eventually() } yield // then - response.code must_=== StatusCode.Ok + response.code === StatusCode.Ok } } @@ -394,7 +393,7 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users ) _ <- discussionsReads.commentReads .requireById(commentID) - .assert("Upvoted entity should have changed score")(_.data.totalScore.toInt =!= 0) + .assert("Upvoted entity should have changed score")(_.data.totalScore.unwrap =!= 0) .eventually() // when response <- CommentAPIs.revokeVote.toTestCall.untupled( @@ -405,11 +404,11 @@ final class CommentServerSpec extends Specification with ServerIOTest with Users ) _ <- discussionsReads.commentReads .requireById(commentID) - .assert("Revoked-vote entity should have changed score")(_.data.totalScore.toInt === 0) + .assert("Revoked-vote entity should have changed score")(_.data.totalScore.unwrap eqv 0) .eventually() } yield // then - response.code must_=== StatusCode.Ok + response.code === StatusCode.Ok } } } diff --git a/modules/server/src/it/scala/io/branchtalk/discussions/api/PostServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/discussions/api/PostServerSpec.scala similarity index 82% rename from modules/server/src/it/scala/io/branchtalk/discussions/api/PostServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/discussions/api/PostServerSpec.scala index 1d8acef8..8d77e804 100644 --- a/modules/server/src/it/scala/io/branchtalk/discussions/api/PostServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/discussions/api/PostServerSpec.scala @@ -1,21 +1,22 @@ package io.branchtalk.discussions.api import cats.effect.IO -import io.branchtalk.api.{ Authentication, Pagination, PaginationLimit, PaginationOffset, ServerIOTest } +import com.softwaremill.quicklens.* +import io.branchtalk.api.{ Authentication, Pagination, ServerIOTest } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.discussions.api.PostModels._ +import io.branchtalk.discussions.api.PostModels.* import io.branchtalk.discussions.model.Post -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.scalaland.chimney.dsl._ -import monocle.macros.syntax.lens._ +import io.scalaland.chimney.dsl.* import org.specs2.mutable.Specification import sttp.model.StatusCode -final class PostServerSpec extends Specification with ServerIOTest with UsersFixtures with DiscussionsFixtures { +final class PostServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected lazy val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "PostServer-provided endpoints" should { @@ -27,25 +28,25 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix CreationScheduled(channelID) <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() postIDs <- (0 until 10).toList.traverse(_ => - postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) ) posts <- postIDs.traverse(discussionsReads.postReads.requireById(_)).eventually() // when - response1 <- PostAPIs.newest.toTestCall.untupled(None, channelID, None, PaginationLimit(5).some) + response1 <- PostAPIs.newest.toTestCall.untupled(None, channelID, None, Pagination.Limit(5).some) response2 <- PostAPIs.newest.toTestCall.untupled(None, channelID, - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== posts.map(APIPost.fromDomain).toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === posts.map(APIPost.fromDomain).toSet } .getOrElse(pass) } @@ -60,18 +61,18 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix CreationScheduled(channelID) <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() postIDs <- (0 until 10).toList.traverse(_ => - postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) ) posts <- postIDs.traverse(discussionsReads.postReads.requireById(_)).eventually() // when response <- PostAPIs.hottest.toTestCall.untupled(None, channelID) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) response.body.toValidOpt .flatMap(_.toOption) - .map(pagination => pagination.entities.toSet must_=== posts.map(APIPost.fromDomain).toSet) + .map(pagination => pagination.entities.toSet === posts.map(APIPost.fromDomain).toSet) .getOrElse(pass) } } @@ -85,18 +86,18 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix CreationScheduled(channelID) <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() postIDs <- (0 until 10).toList.traverse(_ => - postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) ) posts <- postIDs.traverse(discussionsReads.postReads.requireById(_)).eventually() // when response <- PostAPIs.controversial.toTestCall.untupled(None, channelID) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) response.body.toValidOpt .flatMap(_.toOption) - .map(_.entities.toSet must_=== posts.map(APIPost.fromDomain).toSet) + .map(_.entities.toSet === posts.map(APIPost.fromDomain).toSet) .getOrElse(pass) } } @@ -124,8 +125,8 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix // TODO: check that this creates a new post eventually! } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[CreatePostResponse])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[CreatePostResponse])) } } } @@ -152,8 +153,8 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix ) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(APIPost.fromDomain(post)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(APIPost.fromDomain(post)))) } } } @@ -171,10 +172,10 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix CreationScheduled(channelID) <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() CreationScheduled(postID) <- postCreate(channelID) - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // to own the Post + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // to own the Post .flatMap(discussionsWrites.postWrites.createPost) post <- discussionsReads.postReads.requireById(postID).eventually() - newTitle <- Post.Title.parse[IO]("new title") + newTitle <- ParseNewtype[IO].parse[Post.Title]("new title") newContent = Post.Content.Text(Post.Text("lorem ipsum")) // when response <- PostAPIs.update.toTestCall.untupled( @@ -192,17 +193,17 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(UpdatePostResponse(postID)))) - updatedPost must_=== post - .focus(_.data.title) - .replace(newTitle) - .focus(_.data.content) - .replace(newContent) - .focus(_.data.urlTitle) - .replace(Post.UrlTitle("new-title")) - .focus(_.data.lastModifiedAt) - .replace(updatedPost.data.lastModifiedAt) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(UpdatePostResponse(postID)))) + updatedPost === post + .modify(_.data.title) + .setTo(newTitle) + .modify(_.data.content) + .setTo(newContent) + .modify(_.data.urlTitle) + .setTo(Post.UrlTitle("new-title")) + .modify(_.data.lastModifiedAt) + .setTo(updatedPost.data.lastModifiedAt) } } } @@ -220,7 +221,7 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix CreationScheduled(channelID) <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() CreationScheduled(postID) <- postCreate(channelID) - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // to own the Post + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // to own the Post .flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() // when @@ -235,8 +236,8 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(DeletePostResponse(postID)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(DeletePostResponse(postID)))) } } } @@ -254,7 +255,7 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix CreationScheduled(channelID) <- channelCreate.flatMap(discussionsWrites.channelWrites.createChannel) _ <- discussionsReads.channelReads.requireById(channelID).eventually() CreationScheduled(postID) <- postCreate(channelID) - .map(_.focus(_.authorID).replace(userIDUsers2Discussions.get(userID))) // to own the Post + .map(_.modify(_.authorID).setTo(userIDUsers2Discussions.get(userID))) // to own the Post .flatMap(discussionsWrites.postWrites.createPost) _ <- discussionsReads.postReads.requireById(postID).eventually() _ <- discussionsWrites.postWrites.deletePost(Post.Delete(postID, userIDUsers2Discussions.get(userID))) @@ -271,8 +272,8 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(RestorePostResponse(postID)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(RestorePostResponse(postID)))) } } } @@ -299,11 +300,11 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix ) _ <- discussionsReads.postReads .requireById(postID) - .assert("Upvoted entity should have changed score")(_.data.totalScore.toInt =!= 0) + .assert("Upvoted entity should have changed score")(_.data.totalScore.unwrap =!= 0) .eventually() } yield // then - response.code must_=== StatusCode.Ok + response.code === StatusCode.Ok } } @@ -329,11 +330,11 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix ) _ <- discussionsReads.postReads .requireById(postID) - .assert("Downvoted entity should have changed score")(_.data.totalScore.toInt =!= 0) + .assert("Downvoted entity should have changed score")(_.data.totalScore.unwrap =!= 0) .eventually() } yield // then - response.code must_=== StatusCode.Ok + response.code === StatusCode.Ok } } @@ -355,7 +356,7 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix _ <- discussionsWrites.postWrites.upvotePost(Post.Upvote(postID, userIDUsers2Discussions.get(userID))) _ <- discussionsReads.postReads .requireById(postID) - .assert("Upvoted entity should have changed score")(_.data.totalScore.toInt =!= 0) + .assert("Upvoted entity should have changed score")(_.data.totalScore.unwrap =!= 0) .eventually() // when response <- PostAPIs.revokeVote.toTestCall.untupled( @@ -365,11 +366,11 @@ final class PostServerSpec extends Specification with ServerIOTest with UsersFix ) _ <- discussionsReads.postReads .requireById(postID) - .assert("Revoked-vote entity should have changed score")(_.data.totalScore.toInt === 0) + .assert("Revoked-vote entity should have changed score")(_.data.totalScore.unwrap eqv 0) .eventually() } yield // then - response.code must_=== StatusCode.Ok + response.code === StatusCode.Ok } } } diff --git a/modules/server/src/it/scala/io/branchtalk/discussions/api/SubscriptionServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/discussions/api/SubscriptionServerSpec.scala similarity index 78% rename from modules/server/src/it/scala/io/branchtalk/discussions/api/SubscriptionServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/discussions/api/SubscriptionServerSpec.scala index 168eb236..200e2b90 100644 --- a/modules/server/src/it/scala/io/branchtalk/discussions/api/SubscriptionServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/discussions/api/SubscriptionServerSpec.scala @@ -1,9 +1,9 @@ package io.branchtalk.discussions.api import cats.effect.IO -import io.branchtalk.api.{ Authentication, Pagination, PaginationLimit, PaginationOffset, ServerIOTest } +import io.branchtalk.api.{ Authentication, Pagination, ServerIOTest } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.discussions.api.PostModels._ +import io.branchtalk.discussions.api.PostModels.* import io.branchtalk.discussions.api.SubscriptionModels.{ APISubscriptions, SubscribeRequest, @@ -12,17 +12,18 @@ import io.branchtalk.discussions.api.SubscriptionModels.{ UnsubscribeResponse } import io.branchtalk.discussions.model.{ Channel, Subscription } -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures import org.specs2.mutable.Specification import sttp.model.StatusCode -final class SubscriptionServerSpec extends Specification with ServerIOTest with UsersFixtures with DiscussionsFixtures { +final class SubscriptionServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { private val defaultChannelID = ID[Channel](java.util.UUID.randomUUID()) - implicit protected lazy val uuidGenerator: TestUUIDGenerator = - (new TestUUIDGenerator).tap(_.stubNext(defaultChannelID.uuid)) // stub generation in ServerIOTest resources + protected given uuidGenerator: TestUUIDGenerator = + (new TestUUIDGenerator).tap(_.stubNext(defaultChannelID.unwrap)) // stub generation in ServerIOTest resources "SubscriptionServer-provided endpoints" should { @@ -32,29 +33,29 @@ final class SubscriptionServerSpec extends Specification with ServerIOTest with for { // given CreationScheduled(channelID) <- channelCreate - .flatTap(_ => IO(uuidGenerator.stubNext(defaultChannelID.uuid))) // create Channel with default ID + .flatTap(_ => IO(uuidGenerator.stubNext(defaultChannelID.unwrap))) // create Channel with default ID .flatMap(discussionsWrites.channelWrites.createChannel) // NOTE: ID generation must come before CID - .assert("Created Channel should have predefined ID")(_.id === defaultChannelID) + .assert("Created Channel should have predefined ID")(_.unwrap eqv defaultChannelID) _ <- discussionsReads.channelReads.requireById(channelID).eventually() postIDs <- (0 until 10).toList.traverse(_ => - postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) ) posts <- postIDs.traverse(discussionsReads.postReads.requireById(_)).eventually() // when - response1 <- SubscriptionAPIs.newest.toTestCall.untupled(None, None, PaginationLimit(5).some) + response1 <- SubscriptionAPIs.newest.toTestCall.untupled(None, None, Pagination.Limit(5).some) response2 <- SubscriptionAPIs.newest.toTestCall.untupled(None, - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== posts.map(APIPost.fromDomain).toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === posts.map(APIPost.fromDomain).toSet } .getOrElse(pass) } @@ -79,29 +80,29 @@ final class SubscriptionServerSpec extends Specification with ServerIOTest with .assert("Subscriptions should contain added Channel ID")(_.subscriptions(channelID)) .eventually() postIDs <- (0 until 10).toList.traverse(_ => - postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.id) + postCreate(channelID).flatMap(discussionsWrites.postWrites.createPost).map(_.unwrap) ) posts <- postIDs.traverse(discussionsReads.postReads.requireById(_)).eventually() // when response1 <- SubscriptionAPIs.newest.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)).some, None, - PaginationLimit(5).some + Pagination.Limit(5).some ) response2 <- SubscriptionAPIs.newest.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)).some, - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIPost]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIPost]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== posts.map(APIPost.fromDomain).toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === posts.map(APIPost.fromDomain).toSet } .getOrElse(pass) } @@ -134,11 +135,11 @@ final class SubscriptionServerSpec extends Specification with ServerIOTest with ) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[APISubscriptions])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[APISubscriptions])) response.body.toValidOpt .flatMap(_.toOption) - .map(subscriptions => subscriptions must_=== APISubscriptions(List(channelID))) + .map(subscriptions => subscriptions === APISubscriptions(List(channelID))) .getOrElse(pass) } } @@ -168,13 +169,13 @@ final class SubscriptionServerSpec extends Specification with ServerIOTest with .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[SubscribeResponse])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[SubscribeResponse])) response.body.toValidOpt .flatMap(_.toOption) - .map(subscribed => subscribed.channels must_=== List(channelID)) + .map(subscribed => subscribed.channels === List(channelID)) .getOrElse(pass) - result must_=== Subscription(subscriberID, Set(channelID)) + result === Subscription(subscriberID, Set(channelID)) } } } @@ -210,13 +211,13 @@ final class SubscriptionServerSpec extends Specification with ServerIOTest with .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[UnsubscribeResponse])) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[UnsubscribeResponse])) response.body.toValidOpt .flatMap(_.toOption) - .map(unsubscribed => unsubscribed must_=== UnsubscribeResponse(List())) + .map(unsubscribed => unsubscribed === UnsubscribeResponse(List())) .getOrElse(pass) - result must_=== Subscription(subscriberID, Set.empty) + result === Subscription(subscriberID, Set.empty) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/openapi/OpenAPIServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/openapi/OpenAPIServerSpec.scala similarity index 63% rename from modules/server/src/it/scala/io/branchtalk/openapi/OpenAPIServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/openapi/OpenAPIServerSpec.scala index 24163490..45c6543c 100644 --- a/modules/server/src/it/scala/io/branchtalk/openapi/OpenAPIServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/openapi/OpenAPIServerSpec.scala @@ -4,18 +4,18 @@ import io.branchtalk.api.ServerIOTest import io.branchtalk.shared.model.TestUUIDGenerator import org.specs2.mutable.Specification import sttp.model.StatusCode -import sttp.client3._ +import sttp.client3.* -final class OpenAPIServerSpec extends Specification with ServerIOTest { +final class OpenAPIServerSpec extends Specification, ServerIOTest { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "OpenAPIServer" should { "return valid OpenAPI v3 specification" in { for { result <- basicRequest.get(sttpBaseUri.withWholePath("docs/swagger.json")).send(client) - } yield result.code must_=== StatusCode.Ok + } yield result.code === StatusCode.Ok } } } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelBanServerListingSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelBanServerListingSpec.scala similarity index 75% rename from modules/server/src/it/scala/io/branchtalk/users/api/ChannelBanServerListingSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/ChannelBanServerListingSpec.scala index 57586196..241e9b1a 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelBanServerListingSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelBanServerListingSpec.scala @@ -1,25 +1,22 @@ package io.branchtalk.users.api -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Ban, Permission, User } import org.specs2.mutable.Specification import sttp.model.StatusCode -final class ChannelBanServerListingSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { +final class ChannelBanServerListingSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { // User pagination tests cannot be run in parallel to other User tests (no parent to filter other tests) sequential - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "ChannelBanServer-provided pagination endpoints" should { @@ -43,7 +40,7 @@ final class ChannelBanServerListingSpec ) ) bannedUserIDs <- (0 until 9).toList.traverse(_ => - userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) ) _ <- bannedUserIDs.traverse(usersReads.userReads.requireById(_)).eventually() channelID <- channelIDCreate @@ -53,7 +50,7 @@ final class ChannelBanServerListingSpec .traverse(usersWrites.banWrites.orderBan) _ <- usersReads.banReads .findForChannel(channelID) - .assert("All Users should be Banned")(_.map(_.bannedUserID).toSet === bannedUserIDs.toSet) + .assert("All Users should be Banned")(_.map(_.bannedUserID).toSet eqv bannedUserIDs.toSet) .eventually() // when response <- ChannelBanAPIs.list.toTestCall.untupled( @@ -62,12 +59,9 @@ final class ChannelBanServerListingSpec ) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[BansResponse])) - response.body.toValidOpt - .flatMap(_.toOption) - .map(_.bannedIDs.toSet must_=== bannedUserIDs.toSet) - .getOrElse(pass) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[BansResponse])) + response.body.toValidOpt.flatMap(_.toOption).map(_.bannedIDs.toSet === bannedUserIDs.toSet).getOrElse(pass) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelBanServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelBanServerSpec.scala similarity index 85% rename from modules/server/src/it/scala/io/branchtalk/users/api/ChannelBanServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/ChannelBanServerSpec.scala index 32fcb9eb..ea1dcd8c 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelBanServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelBanServerSpec.scala @@ -1,17 +1,18 @@ package io.branchtalk.users.api -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Ban, Permission, User } import org.specs2.mutable.Specification -final class ChannelBanServerSpec extends Specification with ServerIOTest with UsersFixtures with DiscussionsFixtures { +final class ChannelBanServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "ChannelBanServer-provided endpoints" should { @@ -52,8 +53,8 @@ final class ChannelBanServerSpec extends Specification with ServerIOTest with Us .eventually() } yield { // then - response.body must beValid(beRight(be_===(BanOrderResponse(bannedUserID)))) - bans must_=== Set(Ban(bannedUserID, reason, Ban.Scope.ForChannel(channelID))) + response.body must beValid(beRight(be_==(BanOrderResponse(bannedUserID)))) + bans === Set(Ban(bannedUserID, reason, Ban.Scope.ForChannel(channelID))) } } @@ -99,7 +100,7 @@ final class ChannelBanServerSpec extends Specification with ServerIOTest with Us .eventually() } yield // then - response.body must beValid(beRight(be_===(BanLiftResponse(bannedUserID)))) + response.body must beValid(beRight(be_==(BanLiftResponse(bannedUserID)))) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelModerationServerPaginationSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelModerationServerPaginationSpec.scala similarity index 79% rename from modules/server/src/it/scala/io/branchtalk/users/api/ChannelModerationServerPaginationSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/ChannelModerationServerPaginationSpec.scala index de099656..341d04fd 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelModerationServerPaginationSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelModerationServerPaginationSpec.scala @@ -1,25 +1,26 @@ package io.branchtalk.users.api -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Permission, RequiredPermissions, User } import org.specs2.mutable.Specification import sttp.model.StatusCode final class ChannelModerationServerPaginationSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { + extends Specification, + ServerIOTest, + UsersFixtures, + DiscussionsFixtures { // User pagination tests cannot be run in parallel to other User tests (no parent to filter other tests) sequential - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "ChannelModerationServer-provided pagination endpoints" should { @@ -43,7 +44,7 @@ final class ChannelModerationServerPaginationSpec ) ) userIDs <- (0 until 9).toList - .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id)) + .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap)) .map(_ :+ userID) _ <- userIDs.traverse(usersReads.userReads.requireById(_)).eventually() channelID <- channelIDCreate @@ -71,23 +72,23 @@ final class ChannelModerationServerPaginationSpec Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), channelID, None, - PaginationLimit(5).some + Pagination.Limit(5).some ) response2 <- ChannelModerationAPIs.paginate.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), channelID, - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== users.map(APIUser.fromDomain).toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === users.map(APIUser.fromDomain).toSet } .getOrElse(pass) } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelModerationServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelModerationServerSpec.scala similarity index 89% rename from modules/server/src/it/scala/io/branchtalk/users/api/ChannelModerationServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/ChannelModerationServerSpec.scala index 9dbb6f58..7eb81c89 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/ChannelModerationServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/ChannelModerationServerSpec.scala @@ -1,21 +1,18 @@ package io.branchtalk.users.api -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Permission, RequiredPermissions, User } import org.specs2.mutable.Specification -final class ChannelModerationServerSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { +final class ChannelModerationServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "ChannelModerationServer-provided endpoints" should { @@ -58,7 +55,7 @@ final class ChannelModerationServerSpec .eventually() } yield // then - response.body must beValid(beRight(be_===(GrantModerationResponse(updatedUserID)))) + response.body must beValid(beRight(be_==(GrantModerationResponse(updatedUserID)))) } "on DELETE /discussions/channels/{channelID}/moderation" in { @@ -114,7 +111,7 @@ final class ChannelModerationServerSpec .eventually() } yield // then - response.body must beValid(beRight(be_===(RevokeModerationResponse(updatedUserID)))) + response.body must beValid(beRight(be_==(RevokeModerationResponse(updatedUserID)))) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/UserBanServerListingSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/UserBanServerListingSpec.scala similarity index 75% rename from modules/server/src/it/scala/io/branchtalk/users/api/UserBanServerListingSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/UserBanServerListingSpec.scala index 6d76ae42..766d64db 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/UserBanServerListingSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/UserBanServerListingSpec.scala @@ -2,24 +2,21 @@ package io.branchtalk.users.api import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Ban, Permission, User } import org.specs2.mutable.Specification import sttp.model.StatusCode -final class UserBanServerListingSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { +final class UserBanServerListingSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { // User pagination tests cannot be run in parallel to other User tests (no parent to filter other tests) sequential - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "UserBanServer-provided pagination endpoints" should { @@ -43,7 +40,7 @@ final class UserBanServerListingSpec ) ) bannedUserIDs <- (0 until 9).toList.traverse(_ => - userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) ) _ <- bannedUserIDs.traverse(usersReads.userReads.requireById(_)).eventually() reason = Ban.Reason("test") @@ -51,7 +48,7 @@ final class UserBanServerListingSpec .map(Ban.Order(_, reason, Ban.Scope.Globally, userID.some)) .traverse(usersWrites.banWrites.orderBan) _ <- usersReads.banReads.findGlobally - .assert("All Users should be Banned")(_.map(_.bannedUserID).toSet === bannedUserIDs.toSet) + .assert("All Users should be Banned")(_.map(_.bannedUserID).toSet eqv bannedUserIDs.toSet) .eventually() // when response <- UserBanAPIs.list.toTestCall( @@ -59,12 +56,9 @@ final class UserBanServerListingSpec ) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[BansResponse])) - response.body.toValidOpt - .flatMap(_.toOption) - .map(_.bannedIDs.toSet must_=== bannedUserIDs.toSet) - .getOrElse(pass) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[BansResponse])) + response.body.toValidOpt.flatMap(_.toOption).map(_.bannedIDs.toSet === bannedUserIDs.toSet).getOrElse(pass) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/UserBanServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/UserBanServerSpec.scala similarity index 85% rename from modules/server/src/it/scala/io/branchtalk/users/api/UserBanServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/UserBanServerSpec.scala index 02ddcfab..96904e17 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/UserBanServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/UserBanServerSpec.scala @@ -2,16 +2,17 @@ package io.branchtalk.users.api import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Ban, Permission, User } import org.specs2.mutable.Specification -final class UserBanServerSpec extends Specification with ServerIOTest with UsersFixtures with DiscussionsFixtures { +final class UserBanServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "UserBanServer-provided endpoints" should { @@ -50,8 +51,8 @@ final class UserBanServerSpec extends Specification with ServerIOTest with Users .eventually() } yield { // then - response.body must beValid(beRight(be_===(BanOrderResponse(bannedUserID)))) - bans must_=== Set(Ban(bannedUserID, reason, Ban.Scope.Globally)) + response.body must beValid(beRight(be_==(BanOrderResponse(bannedUserID)))) + bans === Set(Ban(bannedUserID, reason, Ban.Scope.Globally)) } } @@ -95,7 +96,7 @@ final class UserBanServerSpec extends Specification with ServerIOTest with Users .eventually() } yield // then - response.body must beValid(beRight(be_===(BanLiftResponse(bannedUserID)))) + response.body must beValid(beRight(be_==(BanLiftResponse(bannedUserID)))) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/UserModerationServerPaginationSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/UserModerationServerPaginationSpec.scala similarity index 78% rename from modules/server/src/it/scala/io/branchtalk/users/api/UserModerationServerPaginationSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/UserModerationServerPaginationSpec.scala index 4afaccc5..07aae163 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/UserModerationServerPaginationSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/UserModerationServerPaginationSpec.scala @@ -1,25 +1,22 @@ package io.branchtalk.users.api -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Permission, RequiredPermissions, User } import org.specs2.mutable.Specification import sttp.model.StatusCode -final class UserModerationServerPaginationSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { +final class UserModerationServerPaginationSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { // User pagination tests cannot be run in parallel to other User tests (no parent to filter other tests) sequential - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "UserModerationServer-provided pagination endpoints" should { @@ -43,7 +40,7 @@ final class UserModerationServerPaginationSpec ) ) userIDs <- (0 until 9).toList - .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id)) + .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap)) .map(_ :+ userID) _ <- userIDs.traverse(usersReads.userReads.requireById(_)).eventually() permission = Permission.ModerateUsers @@ -69,22 +66,22 @@ final class UserModerationServerPaginationSpec response1 <- UserModerationAPIs.paginate.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), None, - PaginationLimit(5).some + Pagination.Limit(5).some ) response2 <- UserModerationAPIs.paginate.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== users.map(APIUser.fromDomain).toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === users.map(APIUser.fromDomain).toSet } .getOrElse(pass) } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/UserModerationServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/UserModerationServerSpec.scala similarity index 88% rename from modules/server/src/it/scala/io/branchtalk/users/api/UserModerationServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/UserModerationServerSpec.scala index c7a2441a..70f79a6a 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/UserModerationServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/UserModerationServerSpec.scala @@ -1,21 +1,18 @@ package io.branchtalk.users.api -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Permission, RequiredPermissions, User } import org.specs2.mutable.Specification -final class UserModerationServerSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { +final class UserModerationServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "UserModerationServer-provided endpoints" should { @@ -56,7 +53,7 @@ final class UserModerationServerSpec .eventually() } yield // then - response.body must beValid(beRight(be_===(GrantModerationResponse(updatedUserID)))) + response.body must beValid(beRight(be_==(GrantModerationResponse(updatedUserID)))) } "on DELETE /users/moderation" in { @@ -110,7 +107,7 @@ final class UserModerationServerSpec .eventually() } yield // then - response.body must beValid(beRight(be_===(RevokeModerationResponse(updatedUserID)))) + response.body must beValid(beRight(be_==(RevokeModerationResponse(updatedUserID)))) } } } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/UserServerPaginationSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/UserServerPaginationSpec.scala similarity index 74% rename from modules/server/src/it/scala/io/branchtalk/users/api/UserServerPaginationSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/UserServerPaginationSpec.scala index adc47ce0..c6ed8c88 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/UserServerPaginationSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/UserServerPaginationSpec.scala @@ -1,25 +1,22 @@ package io.branchtalk.users.api -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Permission, RequiredPermissions, User } import org.specs2.mutable.Specification import sttp.model.StatusCode -final class UserServerPaginationSpec - extends Specification - with ServerIOTest - with UsersFixtures - with DiscussionsFixtures { +final class UserServerPaginationSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { // User pagination tests cannot be run in parallel to other User tests (no parent to filter other tests) sequential - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "UserServer-provided pagination endpoints" should { @@ -49,29 +46,29 @@ final class UserServerPaginationSpec ) .eventually() userIDs <- (0 until 9).toList - .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id)) + .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap)) .map(_ :+ userID) users <- userIDs.traverse(usersReads.userReads.requireById(_)).eventually() // when response1 <- UserAPIs.paginate.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), None, - PaginationLimit(5).some + Pagination.Limit(5).some ) response2 <- UserAPIs.paginate.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== users.map(APIUser.fromDomain).toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === users.map(APIUser.fromDomain).toSet } .getOrElse(pass) } @@ -83,10 +80,11 @@ final class UserServerPaginationSpec "return newest Users" in { for { // given - _ <- usersReads.userReads.paginate(User.Sorting.NameAlphabetically, 0L, 1000).flatMap { - case Paginated(entities, _) => + _ <- usersReads.userReads + .paginate(User.Sorting.NameAlphabetically, Paginated.Offset(0L), Paginated.Limit(1000)) + .flatMap { case Paginated(entities, _) => entities.traverse_(user => usersWrites.userWrites.deleteUser(User.Delete(user.id, None))) - } + } (CreationScheduled(userID), CreationScheduled(sessionID)) <- userCreate.flatMap( usersWrites.userWrites.createUser ) @@ -108,29 +106,29 @@ final class UserServerPaginationSpec ) .eventually() userIDs <- (0 until 9).toList - .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id)) + .traverse(_ => userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap)) .map(_ :+ userID) users <- userIDs.traverse(usersReads.userReads.requireById(_)).eventually() // when response1 <- UserAPIs.newest.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), None, - PaginationLimit(5).some + Pagination.Limit(5).some ) response2 <- UserAPIs.newest.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APIUser]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APIUser]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== users.map(APIUser.fromDomain).toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === users.map(APIUser.fromDomain).toSet } .getOrElse(pass) } diff --git a/modules/server/src/it/scala/io/branchtalk/users/api/UserServerSpec.scala b/modules/server/src/test/scala/io/branchtalk/users/api/UserServerSpec.scala similarity index 70% rename from modules/server/src/it/scala/io/branchtalk/users/api/UserServerSpec.scala rename to modules/server/src/test/scala/io/branchtalk/users/api/UserServerSpec.scala index f5e585b2..37b5bbc3 100644 --- a/modules/server/src/it/scala/io/branchtalk/users/api/UserServerSpec.scala +++ b/modules/server/src/test/scala/io/branchtalk/users/api/UserServerSpec.scala @@ -1,21 +1,22 @@ package io.branchtalk.users.api import cats.effect.IO -import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, _ } -import io.branchtalk.api.TapirSupport._ +import com.softwaremill.quicklens.* +import io.branchtalk.api.{ Permission => _, RequiredPermissions => _, * } +import io.branchtalk.api.TapirSupport.* import io.branchtalk.discussions.DiscussionsFixtures -import io.branchtalk.mappings._ -import io.branchtalk.shared.model._ +import io.branchtalk.mappings.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.UsersFixtures -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.{ Password, Session, User } -import monocle.macros.syntax.lens._ import org.specs2.mutable.Specification import sttp.model.StatusCode -final class UserServerSpec extends Specification with ServerIOTest with UsersFixtures with DiscussionsFixtures { +final class UserServerSpec extends Specification, ServerIOTest, UsersFixtures, DiscussionsFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "UserServer-provided endpoints" should { @@ -26,10 +27,11 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix "return newest Sessions" in { for { // given - _ <- usersReads.userReads.paginate(User.Sorting.NameAlphabetically, 0L, 1000).flatMap { - case Paginated(entities, _) => + _ <- usersReads.userReads + .paginate(User.Sorting.NameAlphabetically, Paginated.Offset(0L), Paginated.Limit(1000)) + .flatMap { case Paginated(entities, _) => entities.traverse_(user => usersWrites.userWrites.deleteUser(User.Delete(user.id, None))) - } + } (CreationScheduled(userID), CreationScheduled(sessionID)) <- userCreate.flatMap( usersWrites.userWrites.createUser ) @@ -42,24 +44,22 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix response1 <- UserAPIs.sessions.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), None, - PaginationLimit(5).some + Pagination.Limit(5).some ) response2 <- UserAPIs.sessions.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), - PaginationOffset(5L).some, - PaginationLimit(5).some + Pagination.Offset(5L).some, + Pagination.Limit(5).some ) } yield { // then - response1.code must_=== StatusCode.Ok - response1.body must beValid(beRight(anInstanceOf[Pagination[APISession]])) - response2.code must_=== StatusCode.Ok - response2.body must beValid(beRight(anInstanceOf[Pagination[APISession]])) + response1.code === StatusCode.Ok + response1.body must beValid(beRight(beAnInstanceOf[Pagination[APISession]])) + response2.code === StatusCode.Ok + response2.body must beValid(beRight(beAnInstanceOf[Pagination[APISession]])) (response1.body.toValidOpt.flatMap(_.toOption), response2.body.toValidOpt.flatMap(_.toOption)) .mapN { (pagination1, pagination2) => - (pagination1.entities.toSet ++ pagination2.entities.toSet) must_=== sessions - .map(APISession.fromDomain) - .toSet + (pagination1.entities.toSet ++ pagination2.entities.toSet) === sessions.map(APISession.fromDomain).toSet } .getOrElse(pass) } @@ -71,7 +71,7 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix "schedule User and Session creation on valid JSON" in { for { // given - password <- Password.Raw.parse[IO]("password".getBytes) + password <- ParseNewtype[IO].parse[Password.Raw]("password".getBytes) creationData <- userCreate // when response <- UserAPIs.signUp.toTestCall( @@ -87,12 +87,12 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix session <- possibleResult.map(_.sessionID).traverse(usersReads.sessionReads.requireById).eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(anInstanceOf[SignUpResponse])) - user must beSome(anInstanceOf[User]) - session must beSome(anInstanceOf[Session]) + response.code === StatusCode.Ok + response.body must beValid(beRight(beAnInstanceOf[SignUpResponse])) + user must beSome(beAnInstanceOf[User]) + session must beSome(beAnInstanceOf[Session]) (user, session, response.body.toOption.flatMap(_.toOption)) - .mapN((u, s, r) => r must_=== SignUpResponse(u.id, s.id)) + .mapN((u, s, r) => r === SignUpResponse(u.id, s.id)) .getOrElse(pass) } } @@ -103,7 +103,7 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix "log User in on valid credentials" in { for { // given - password <- Password.Raw.parse[IO]("password".getBytes) + password <- ParseNewtype[IO].parse[Password.Raw]("password".getBytes) (CreationScheduled(userID), CreationScheduled(sessionID)) <- userCreate .map(_.copy(password = Password.create(password))) .flatMap(usersWrites.userWrites.createUser) @@ -121,10 +121,10 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix ) } yield { // then - sessionResponse.code must_=== StatusCode.Ok - sessionResponse.body must beValid(beRight(anInstanceOf[SignInResponse])) - credentialsResponse.code must_=== StatusCode.Ok - credentialsResponse.body must beValid(beRight(anInstanceOf[SignInResponse])) + sessionResponse.code === StatusCode.Ok + sessionResponse.body must beValid(beRight(beAnInstanceOf[SignInResponse])) + credentialsResponse.code === StatusCode.Ok + credentialsResponse.body must beValid(beRight(beAnInstanceOf[SignInResponse])) } } } @@ -134,7 +134,7 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix "log out User" in { for { // given - password <- Password.Raw.parse[IO]("password".getBytes) + password <- ParseNewtype[IO].parse[Password.Raw]("password".getBytes) (CreationScheduled(userID), CreationScheduled(sessionID)) <- userCreate .map(_.copy(password = Password.create(password))) .flatMap(usersWrites.userWrites.createUser) @@ -152,10 +152,10 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix ) } yield { // then - sessionResponse.code must_=== StatusCode.Ok - sessionResponse.body must beValid(beRight(be_===(SignOutResponse(userID, sessionID.some)))) - credentialsResponse.code must_=== StatusCode.Ok - credentialsResponse.body must beValid(beRight(be_===(SignOutResponse(userID, None)))) + sessionResponse.code === StatusCode.Ok + sessionResponse.body must beValid(beRight(be_==(SignOutResponse(userID, sessionID.some)))) + credentialsResponse.code === StatusCode.Ok + credentialsResponse.body must beValid(beRight(be_==(SignOutResponse(userID, None)))) } } } @@ -174,8 +174,8 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix response <- UserAPIs.fetchProfile.toTestCall.untupled(None, userID) } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(APIUser.fromDomain(user)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(APIUser.fromDomain(user)))) } } } @@ -190,9 +190,9 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix ) user <- usersReads.userReads.requireById(userID).eventually() _ <- usersReads.sessionReads.requireById(sessionID).eventually() - newUsername <- User.Name.parse[IO]("new test name") + newUsername <- ParseNewtype[IO].parse[User.Name]("new test name") newDescription = User.Description("new test description") - newPassword <- Password.Raw.parse[IO]("new password".getBytes) + newPassword <- ParseNewtype[IO].parse[Password.Raw]("new password".getBytes) // when response <- UserAPIs.updateProfile.toTestCall.untupled( Authentication.Session(sessionID = sessionIDApi2Users.reverseGet(sessionID)), @@ -209,17 +209,17 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix .eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(UpdateUserResponse(userID)))) - updatedUser must_=== user - .focus(_.data.username) - .replace(newUsername) - .focus(_.data.description) - .replace(newDescription.some) - .focus(_.data.password) - .replace(updatedUser.data.password) // updated Password might have a different salt... - .focus(_.data.lastModifiedAt) - .replace(updatedUser.data.lastModifiedAt) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(UpdateUserResponse(userID)))) + updatedUser === user + .modify(_.data.username) + .setTo(newUsername) + .modify(_.data.description) + .setTo(newDescription.some) + .modify(_.data.password) + .setTo(updatedUser.data.password) // updated Password might have a different salt... + .modify(_.data.lastModifiedAt) + .setTo(updatedUser.data.lastModifiedAt) updatedUser.data.password.verify(newPassword) must beTrue // ...which is why we test it separately } } @@ -243,8 +243,8 @@ final class UserServerSpec extends Specification with ServerIOTest with UsersFix _ <- usersReads.userReads.deleted(userID).assert("User should be eventually deleted")(identity).eventually() } yield { // then - response.code must_=== StatusCode.Ok - response.body must beValid(beRight(be_===(DeleteUserResponse(userID)))) + response.code === StatusCode.Ok + response.body must beValid(beRight(be_==(DeleteUserResponse(userID)))) } } } diff --git a/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelBanAPIs.scala b/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelBanAPIs.scala index 7135daa1..061a58e4 100644 --- a/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelBanAPIs.scala +++ b/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelBanAPIs.scala @@ -1,10 +1,10 @@ package io.branchtalk.users.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } import io.branchtalk.shared.model.ID -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.Channel import sttp.model.StatusCode @@ -31,7 +31,7 @@ object ChannelBanAPIs { .out(jsonBody[BansResponse]) .errorOut(errorMapping) .requiringPermissions(channelID => - RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.uuid)), Permission.Administrate) + RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.unwrap)), Permission.Administrate) ) val orderBan: AuthedEndpoint[Authentication, (ID[Channel], BanOrderRequest), UserError, BanOrderResponse, Any] = @@ -47,7 +47,7 @@ object ChannelBanAPIs { .out(jsonBody[BanOrderResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _) => - RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.uuid)), Permission.Administrate) + RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.unwrap)), Permission.Administrate) } val liftBan: AuthedEndpoint[Authentication, (ID[Channel], BanLiftRequest), UserError, BanLiftResponse, Any] = @@ -63,6 +63,6 @@ object ChannelBanAPIs { .out(jsonBody[BanLiftResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _) => - RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.uuid)), Permission.Administrate) + RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.unwrap)), Permission.Administrate) } } diff --git a/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelModerationAPIs.scala b/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelModerationAPIs.scala index fd9146ba..b2310509 100644 --- a/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelModerationAPIs.scala +++ b/modules/users-api/src/main/scala/io/branchtalk/users/api/ChannelModerationAPIs.scala @@ -1,10 +1,10 @@ package io.branchtalk.users.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } import io.branchtalk.shared.model.ID -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.Channel import sttp.model.StatusCode @@ -21,7 +21,7 @@ object ChannelModerationAPIs { val paginate: AuthedEndpoint[ Authentication, - (ID[Channel], Option[PaginationOffset], Option[PaginationLimit]), + (ID[Channel], Option[Pagination.Offset], Option[Pagination.Limit]), UserError, Pagination[APIUser], Any @@ -33,12 +33,12 @@ object ChannelModerationAPIs { .get .securityIn(authHeader) .in(prefix) - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APIUser]]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _, _) => - RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.uuid)), Permission.Administrate) + RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.unwrap)), Permission.Administrate) } val grantChannelModeration: AuthedEndpoint[ @@ -59,7 +59,7 @@ object ChannelModerationAPIs { .out(jsonBody[GrantModerationResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _) => - RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.uuid)), Permission.Administrate) + RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.unwrap)), Permission.Administrate) } val revokeChannelModeration: AuthedEndpoint[ @@ -80,6 +80,6 @@ object ChannelModerationAPIs { .out(jsonBody[RevokeModerationResponse]) .errorOut(errorMapping) .requiringPermissions { case (channelID, _) => - RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.uuid)), Permission.Administrate) + RequiredPermissions.anyOf(Permission.ModerateChannel(ChannelID(channelID.unwrap)), Permission.Administrate) } } diff --git a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserAPIs.scala b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserAPIs.scala index 98ce8413..198c4125 100644 --- a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserAPIs.scala +++ b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserAPIs.scala @@ -1,10 +1,10 @@ package io.branchtalk.users.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } import io.branchtalk.shared.model.{ ID, OptionUpdatable, Updatable } -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.users.api.UserModels.* import io.branchtalk.users.model.Password.{ Raw => RawPassword } import io.branchtalk.users.model.User import sttp.model.StatusCode @@ -22,7 +22,7 @@ object UserAPIs { val paginate: AuthedEndpoint[ Authentication, - (Option[PaginationOffset], Option[PaginationLimit]), + (Option[Pagination.Offset], Option[Pagination.Limit]), UserError, Pagination[APIUser], Any @@ -34,15 +34,15 @@ object UserAPIs { .get .securityIn(authHeader) .in(prefix) - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APIUser]]) .errorOut(errorMapping) .requiringPermissions(_ => RequiredPermissions.one(Permission.ModerateUsers)) val newest: AuthedEndpoint[ Authentication, - (Option[PaginationOffset], Option[PaginationLimit]), + (Option[Pagination.Offset], Option[Pagination.Limit]), UserError, Pagination[APIUser], Any @@ -54,15 +54,15 @@ object UserAPIs { .get .securityIn(authHeader) .in(prefix / "newest") - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APIUser]]) .errorOut(errorMapping) .requiringPermissions(_ => RequiredPermissions.one(Permission.ModerateUsers)) val sessions: AuthedEndpoint[ Authentication, - (Option[PaginationOffset], Option[PaginationLimit]), + (Option[Pagination.Offset], Option[Pagination.Limit]), UserError, Pagination[APISession], Any @@ -74,8 +74,8 @@ object UserAPIs { .get .securityIn(authHeader) .in(prefix / "sessions") - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APISession]]) .errorOut(errorMapping) .notRequiringPermissions @@ -143,7 +143,7 @@ object UserAPIs { UpdateUserRequest( newUsername = Updatable.Set(User.Name("example")), newDescription = OptionUpdatable.Set(User.Description("example")), - newPassword = Updatable.Set(RawPassword.fromString("example")) + newPassword = Updatable.Set(RawPassword.unsafeMake("example".getBytes)) ), name = "Set all".some, summary = "Assigns new value to all fields".some diff --git a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserBanAPIs.scala b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserBanAPIs.scala index 94d9bdf9..34c66aed 100644 --- a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserBanAPIs.scala +++ b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserBanAPIs.scala @@ -1,9 +1,9 @@ package io.branchtalk.users.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.users.api.UserModels.* import sttp.model.StatusCode object UserBanAPIs { diff --git a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModels.scala b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModels.scala index 099d0a0c..131b6bf6 100644 --- a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModels.scala +++ b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModels.scala @@ -2,82 +2,54 @@ package io.branchtalk.users.api import java.time.OffsetDateTime import cats.data.NonEmptyList -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.refineV -import eu.timepit.refined.string.MatchesRegex -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.ADT -import io.branchtalk.api.JsoniterSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.shared.model.{ ID, OptionUpdatable, Updatable } -import io.branchtalk.users.model.SessionProperties.Usage.Type -import io.branchtalk.users.model._ -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ +import com.github.plokhotnyuk.jsoniter_scala.macros.* +import io.branchtalk.api.JsoniterSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.shared.model.* +import io.branchtalk.users.model.* +import io.scalaland.chimney.dsl.* import sttp.tapir.Schema @SuppressWarnings(Array("org.wartremover.warts.All")) // for macros object UserModels { // properties codecs - implicit val userEmailCodec: JsCodec[User.Email] = - summonCodec[String](JsonCodecMaker.make).refine[MatchesRegex["(.+)@(.+)"]].asNewtype[User.Email] - implicit val usernameCodec: JsCodec[User.Name] = - summonCodec[String](JsonCodecMaker.make).refine[NonEmpty].asNewtype[User.Name] - implicit val userDescriptionCodec: JsCodec[User.Description] = - summonCodec[String](JsonCodecMaker.make).asNewtype[User.Description] - implicit val passwordRawCodec: JsCodec[Password.Raw] = - summonCodec[String](JsonCodecMaker.make) - .map[Array[Byte]](_.getBytes)(new String(_)) // I wanted to avoid that but the result is ugly :/ - .refine[NonEmpty] // I'll try to revisit that someday and e.g. use Base64 here? - .asNewtype[Password.Raw] - implicit val permissionsCodec: JsCodec[Permissions] = - summonCodec[Set[Permission]](JsonCodecMaker.make).asNewtype[Permissions] - implicit val sessionExpirationCodec: JsCodec[Session.ExpirationTime] = - summonCodec[OffsetDateTime](JsonCodecMaker.make).asNewtype[Session.ExpirationTime] - implicit val banReasonCodec: JsCodec[Ban.Reason] = - summonCodec[String](JsonCodecMaker.make).refine[NonEmpty].asNewtype[Ban.Reason] + given JsCodec[User.Email] = newtypeCodec + given JsCodec[User.Name] = newtypeCodec + given JsCodec[User.Description] = newtypeCodec + // I wanted to avoid that but the result is ugly :/ + // I'll try to revisit that someday and e.g. use Base64 here? + given JsCodec[Password.Raw] = + DefaultJsCodec.derived[String].map[Array[Byte]](_.getBytes)(new String(_)).asNewtypeCodec + given JsCodec[Permissions] = newtypeCodec + given JsCodec[Session.ExpirationTime] = newtypeCodec + given JsCodec[Ban.Reason] = newtypeCodec // properties schemas - implicit val userEmailSchema: JsSchema[User.Email] = - summonSchema[String Refined MatchesRegex["(.+)@(.+)"]].asNewtype[User.Email] - implicit val usernameSchema: JsSchema[User.Name] = - summonSchema[String Refined NonEmpty].asNewtype[User.Name] - implicit val userDescriptionSchema: JsSchema[User.Description] = - summonSchema[String].asNewtype[User.Description] - implicit val passwordRawSchema: JsSchema[Password.Raw] = - summonSchema[String] - .map[Array[Byte] Refined NonEmpty](_.getBytes.pipe(refineV[NonEmpty](_).toOption))(_.value.pipe(new String(_))) - .asNewtype[Password.Raw] - implicit val permissionSchema: JsSchema[Permission] = - Schema.derived[Permission] - implicit val permissionsSchema: JsSchema[Permissions] = - summonSchema[Set[Permission]].asNewtype[Permissions] - implicit val sessionExpirationSchema: JsSchema[Session.ExpirationTime] = - summonSchema[OffsetDateTime].asNewtype[Session.ExpirationTime] - implicit val banReasonSchema: JsSchema[Ban.Reason] = - summonSchema[NonEmptyString].asNewtype[Ban.Reason] - - @Semi(JsCodec, JsSchema) sealed trait UserError extends ADT + given JsSchema[Permission] = JsSchema.derived + given JsSchema[Permissions] = summonSchema[List[Permission]].as[Set[Permission]].asNewtypeSchema[Permissions] + given JsSchema[Password.Raw] = + summonSchema[String].map[Array[Byte]](_.getBytes.some)(new String(_)).asNewtypeSchema[Password.Raw] + + sealed trait UserError derives DefaultJsCodec, JsSchema object UserError { - @Semi(JsCodec, JsSchema) final case class BadCredentials(msg: String) extends UserError - @Semi(JsCodec, JsSchema) final case class NoPermission(msg: String) extends UserError - @Semi(JsCodec, JsSchema) final case class NotFound(msg: String) extends UserError - @Semi(JsCodec, JsSchema) final case class ValidationFailed(error: NonEmptyList[String]) extends UserError + final case class BadCredentials(msg: String) extends UserError derives DefaultJsCodec, JsSchema + final case class NoPermission(msg: String) extends UserError derives DefaultJsCodec, JsSchema + final case class NotFound(msg: String) extends UserError derives DefaultJsCodec, JsSchema + final case class ValidationFailed(error: NonEmptyList[String]) extends UserError derives DefaultJsCodec, JsSchema } - @Semi(JsCodec, JsSchema) final case class APISession( + final case class APISession( id: ID[Session], userID: ID[User], sessionType: APISession.SessionType, expiresAt: Session.ExpirationTime - ) + ) derives DefaultJsCodec, + JsSchema object APISession { - @Semi(JsCodec, JsSchema) sealed trait SessionType extends ADT + sealed trait SessionType derives DefaultJsCodec, JsSchema object SessionType { case object UserSession extends SessionType case object OAuth extends SessionType @@ -86,8 +58,8 @@ object UserModels { def fromDomain(session: Session): APISession = { val Session.Usage.Tupled(domainSessionType, _) = session.data.usage val sessionType = domainSessionType match { - case Type.UserSession => SessionType.UserSession - case Type.OAuth => SessionType.OAuth + case Session.Usage.Type.UserSession => SessionType.UserSession + case Session.Usage.Type.OAuth => SessionType.OAuth } session.data .into[APISession] @@ -97,64 +69,66 @@ object UserModels { } } - @Semi(JsCodec, JsSchema) final case class SignUpRequest( + final case class SignUpRequest( email: User.Email, username: User.Name, description: Option[User.Description], password: Password.Raw - ) - @Semi(JsCodec, JsSchema) final case class SignUpResponse( + ) derives DefaultJsCodec, + JsSchema + final case class SignUpResponse( userID: ID[User], sessionID: ID[Session] - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class SignInResponse( + final case class SignInResponse( userID: ID[User], sessionID: ID[Session], expiresAt: Session.ExpirationTime - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class SignOutResponse( + final case class SignOutResponse( userID: ID[User], sessionID: Option[ID[Session]] // in case user wasn't using sessionID - ) + ) derives DefaultJsCodec, + JsSchema - @Semi(JsCodec, JsSchema) final case class APIUser( + final case class APIUser( id: ID[User], email: User.Email, username: User.Name, description: Option[User.Description], permissions: Permissions - ) + ) derives DefaultJsCodec, + JsSchema object APIUser { def fromDomain(user: User): APIUser = user.data.into[APIUser].withFieldConst(_.id, user.id).transform } - @Semi(JsCodec, JsSchema) final case class UpdateUserRequest( + final case class UpdateUserRequest( newUsername: Updatable[User.Name], newDescription: OptionUpdatable[User.Description], newPassword: Updatable[Password.Raw] - ) - @Semi(JsCodec, JsSchema) final case class UpdateUserResponse(id: ID[User]) - - @Semi(JsCodec, JsSchema) final case class DeleteUserResponse(id: ID[User]) - - @Semi(JsCodec, JsSchema) final case class GrantModerationRequest(id: ID[User]) - - @Semi(JsCodec, JsSchema) final case class GrantModerationResponse(id: ID[User]) - - @Semi(JsCodec, JsSchema) final case class RevokeModerationRequest(id: ID[User]) + ) derives DefaultJsCodec, + JsSchema + final case class UpdateUserResponse(id: ID[User]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class RevokeModerationResponse(id: ID[User]) + final case class DeleteUserResponse(id: ID[User]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class BansResponse(bannedIDs: List[ID[User]]) + final case class GrantModerationRequest(id: ID[User]) derives DefaultJsCodec, JsSchema + final case class GrantModerationResponse(id: ID[User]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class BanOrderRequest(id: ID[User], reason: Ban.Reason) + final case class RevokeModerationRequest(id: ID[User]) derives DefaultJsCodec, JsSchema + final case class RevokeModerationResponse(id: ID[User]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class BanOrderResponse(id: ID[User]) + final case class BansResponse(bannedIDs: List[ID[User]]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class BanLiftRequest(id: ID[User]) + final case class BanOrderRequest(id: ID[User], reason: Ban.Reason) derives DefaultJsCodec, JsSchema + final case class BanOrderResponse(id: ID[User]) derives DefaultJsCodec, JsSchema - @Semi(JsCodec, JsSchema) final case class BanLiftResponse(id: ID[User]) + final case class BanLiftRequest(id: ID[User]) derives DefaultJsCodec, JsSchema + final case class BanLiftResponse(id: ID[User]) derives DefaultJsCodec, JsSchema } diff --git a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModerationAPIs.scala b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModerationAPIs.scala index 0bc26bef..a5a2a5f2 100644 --- a/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModerationAPIs.scala +++ b/modules/users-api/src/main/scala/io/branchtalk/users/api/UserModerationAPIs.scala @@ -1,9 +1,9 @@ package io.branchtalk.users.api -import io.branchtalk.api._ -import io.branchtalk.api.AuthenticationSupport._ -import io.branchtalk.api.TapirSupport._ -import io.branchtalk.users.api.UserModels._ +import io.branchtalk.api.* +import io.branchtalk.api.AuthenticationSupport.{ *, given } +import io.branchtalk.api.TapirSupport.{ *, given } +import io.branchtalk.users.api.UserModels.{ *, given } import sttp.model.StatusCode object UserModerationAPIs { @@ -19,7 +19,7 @@ object UserModerationAPIs { val paginate: AuthedEndpoint[ Authentication, - (Option[PaginationOffset], Option[PaginationLimit]), + (Option[Pagination.Offset], Option[Pagination.Limit]), UserError, Pagination[APIUser], Any @@ -31,8 +31,8 @@ object UserModerationAPIs { .get .securityIn(authHeader) .in(prefix) - .in(query[Option[PaginationOffset]]("offset")) - .in(query[Option[PaginationLimit]]("limit")) + .in(query[Option[Pagination.Offset]]("offset")) + .in(query[Option[Pagination.Limit]]("limit")) .out(jsonBody[Pagination[APIUser]]) .errorOut(errorMapping) .requiringPermissions(_ => RequiredPermissions.anyOf(Permission.ModerateUsers, Permission.Administrate)) diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/UsersModule.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/UsersModule.scala index 6aadc9b1..ded0ba5d 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/UsersModule.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/UsersModule.scala @@ -3,14 +3,13 @@ package io.branchtalk.users import cats.data.NonEmptyList import cats.effect.{ Async, Resource } import cats.effect.std.Dispatcher -import com.softwaremill.macwire.wire import io.branchtalk.discussions.events.DiscussionEvent -import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure._ -import io.branchtalk.shared.model.{ Logger, UUID, UUIDGenerator } +import io.branchtalk.logging.* +import io.branchtalk.shared.infrastructure.* +import io.branchtalk.shared.model.UUID import io.branchtalk.users.events.{ UsersCommandEvent, UsersEvent } -import io.branchtalk.users.reads._ -import io.branchtalk.users.writes._ +import io.branchtalk.users.reads.* +import io.branchtalk.users.writes.* import io.prometheus.client.CollectorRegistry import scala.annotation.nowarn @@ -28,7 +27,7 @@ final case class UsersWrites[F[_]]( runProjections: StreamRunner[F], runDiscussionsConsumer: StreamRunner.FromConsumerStream[F, DiscussionEvent] ) -@nowarn("cat=unused") // macwire + object UsersModule { private val module = DomainModule[UsersEvent, UsersCommandEvent] @@ -38,80 +37,78 @@ object UsersModule { val discussionsProjectionName = "discussions" def reads[F[_]: Async]( - domainConfig: DomainConfig, + domainConfig: DomainModule.Config, registry: CollectorRegistry ): Resource[F, UsersReads[F]] = - Logger.getLogger[F].pipe { logger => - Resource.make(logger.info("Initialize Users reads"))(_ => logger.info("Shut down Users reads")) - } >> - module.setupReads[F](domainConfig, registry).map { case ReadsInfrastructure(transactor, _) => - val userReads: UserReads[F] = wire[UserReadsImpl[F]] - val sessionReads: SessionReads[F] = wire[SessionReadsImpl[F]] - val banReads: BanReads[F] = wire[BanReadsImpl[F]] + for { + logger <- Resource.eval(Logger.create[F]) + _ <- Resource.make(logger.info("Initialize Users reads"))(_ => logger.info("Shut down Users reads")) + case Reads.Infrastructure(transactor, _) <- module.setupReads[F](domainConfig, logger, registry) + } yield { + // macwire got removed due to: + // https://github.com/softwaremill/macwire/blob/abf95284e24138a984a06ef4f08d0788af371825/macros/src/main/scala-3/com/softwaremill/macwire/internals/ConstructorCrimper.scala#L91 + val userReads: UserReads[F] = UserReadsImpl[F](transactor) + val sessionReads: SessionReads[F] = SessionReadsImpl[F](transactor) + val banReads: BanReads[F] = BanReadsImpl[F](transactor) - wire[UsersReads[F]] - } + UsersReads(userReads, sessionReads, banReads) + } - // scalastyle:off method.length def writes[F[_]: Async: Dispatcher: MDC]( - domainConfig: DomainConfig, - registry: CollectorRegistry - )(implicit uuidGenerator: UUIDGenerator): Resource[F, UsersWrites[F]] = - Logger.getLogger[F].pipe { logger => - Resource.make(logger.info("Initialize Users writes"))(_ => logger.info("Shut down Users writes")) >> - module.setupWrites[F](domainConfig, registry).map { - case WritesInfrastructure(transactor, - internalProducer, - internalConsumerStream, - producer, - consumerStream, - cache - ) => - val userWrites: UserWrites[F] = wire[UserWritesImpl[F]] - val sessionWrites: SessionWrites[F] = wire[SessionWritesImpl[F]] - val banWrites: BanWrites[F] = wire[BanWritesImpl[F]] + domainConfig: DomainModule.Config, + registry: CollectorRegistry + )(using UUID.Generator): Resource[F, UsersWrites[F]] = + for { + logger <- Resource.eval(Logger.create[F]) + _ <- Resource.make(logger.info("Initialize Users writes"))(_ => logger.info("Shut down Users writes")) + case Writes.Infrastructure(transactor, + internalProducer, + internalConsumerStream, + producer, + consumerStream, + cache + ) <- module.setupWrites[F](domainConfig, logger, registry) + } yield { + val userWrites: UserWrites[F] = UserWritesImpl[F](internalProducer, transactor) + val sessionWrites: SessionWrites[F] = SessionWritesImpl[F](producer, transactor) + val banWrites: BanWrites[F] = BanWritesImpl[F](internalProducer, transactor) - val commandHandler: Projector[F, UsersCommandEvent, (UUID, UsersEvent)] = NonEmptyList - .of( - wire[UserCommandHandler[F]], - wire[BanCommandHandler[F]] - ) - .reduce - val postgresProjector: Projector[F, UsersEvent, (UUID, UsersEvent)] = NonEmptyList - .of( - wire[UserPostgresProjector[F]], - wire[BanPostgresProjector[F]] - ) - .reduce - val runProjections: StreamRunner[F] = { - val runCommandProjector: StreamRunner[F] = - internalConsumerStream.runCachedThrough(logger, cache)( - ConsumerStream.noID.andThen(commandHandler).andThen(producer).andThen(ConsumerStream.produced) - ) - val runPostgresProjector: StreamRunner[F] = - consumerStream(domainConfig.consumers(postgresProjectionName)).runCachedThrough(logger, cache)( - ConsumerStream.noID.andThen(postgresProjector).andThen(ConsumerStream.noID) - ) - runCommandProjector |+| runPostgresProjector - } + val commandHandler: Projector[F, UsersCommandEvent, (UUID, UsersEvent)] = NonEmptyList + .of( + UserCommandHandler[F], + BanCommandHandler[F] + ) + .reduce + val postgresProjector: Projector[F, UsersEvent, (UUID, UsersEvent)] = NonEmptyList + .of( + UserPostgresProjector[F](transactor), + BanPostgresProjector[F](transactor) + ) + .reduce + val runProjections: StreamRunner[F] = { + val runCommandProjector: StreamRunner[F] = + internalConsumerStream.runCachedThrough(logger, cache)( + ConsumerStream.noID.andThen(commandHandler).andThen(producer).andThen(ConsumerStream.produced) + ) + val runPostgresProjector: StreamRunner[F] = + consumerStream(domainConfig.consumers(postgresProjectionName)).runCachedThrough(logger, cache)( + ConsumerStream.noID.andThen(postgresProjector).andThen(ConsumerStream.noID) + ) + runCommandProjector |+| runPostgresProjector + } - val discussionsConsumer: DiscussionsConsumer[F] = wire[DiscussionsConsumer[F]] - val runDiscussionsConsumer: StreamRunner.FromConsumerStream[F, DiscussionEvent] = - _.runThrough(logger)( - ConsumerStream.noID - .andThen(discussionsConsumer) - .andThen(internalProducer) - .andThen(ConsumerStream.produced) - ) + val discussionsConsumer: DiscussionsConsumer[F] = DiscussionsConsumer[F] + val runDiscussionsConsumer: StreamRunner.FromConsumerStream[F, DiscussionEvent] = + _.runThrough(logger)( + ConsumerStream.noID.andThen(discussionsConsumer).andThen(internalProducer).andThen(ConsumerStream.produced) + ) - wire[UsersWrites[F]] - } + UsersWrites(userWrites, sessionWrites, banWrites, runProjections, runDiscussionsConsumer) } - // scalastyle:on method.length - def listenToUsers[F[_]](domainConfig: DomainConfig)( - discussionEventConsumer: ConsumerStream.Factory[F, DiscussionEvent], - runDiscussionsConsumer: StreamRunner.FromConsumerStream[F, DiscussionEvent] + def listenToUsers[F[_]](domainConfig: DomainModule.Config)( + discussionEventConsumer: ConsumerStream.Factory[F, DiscussionEvent], + runDiscussionsConsumer: StreamRunner.FromConsumerStream[F, DiscussionEvent] ): StreamRunner[F] = (discussionEventConsumer andThen runDiscussionsConsumer)(domainConfig.consumers(discussionsProjectionName)) } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/events/BanCommandEvent.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/events/BanCommandEvent.scala index f92f242d..7d422076 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/events/BanCommandEvent.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/events/BanCommandEvent.scala @@ -1,30 +1,28 @@ package io.branchtalk.users.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } import io.branchtalk.users.model.{ Ban, User } -import io.scalaland.catnip.Semi -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait BanCommandEvent extends ADT +sealed trait BanCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object BanCommandEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class OrderBan( + final case class OrderBan( bannedUserID: ID[User], moderatorID: Option[ID[User]], reason: Ban.Reason, scope: Ban.Scope, createdAt: CreationTime, correlationID: CorrelationID - ) extends BanCommandEvent + ) extends BanCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class LiftBan( + final case class LiftBan( bannedUserID: ID[User], moderatorID: Option[ID[User]], scope: Ban.Scope, modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends BanCommandEvent + ) extends BanCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/events/UserCommandEvent.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/events/UserCommandEvent.scala index 1c1c0b02..9b04cd2c 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/events/UserCommandEvent.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/events/UserCommandEvent.scala @@ -1,19 +1,18 @@ package io.branchtalk.users.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ +import io.branchtalk.shared.model.* import io.branchtalk.shared.model.AvroSerialization.DeserializationResult -import io.branchtalk.shared.model.AvroSupport._ +import io.branchtalk.shared.model.AvroSupport.{ *, given } import io.branchtalk.users.model.{ Password, Permission, Session, User } -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* +import io.scalaland.chimney.partial.syntax.* -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait UserCommandEvent extends ADT +sealed trait UserCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object UserCommandEvent { - @Semi(FastEq, ShowPretty) final case class Create( + final case class Create( id: ID[User], email: SensitiveData[User.Email], username: SensitiveData[User.Name], @@ -23,7 +22,8 @@ object UserCommandEvent { sessionID: ID[Session], sessionExpiresAt: Session.ExpirationTime, correlationID: CorrelationID - ) { + ) derives FastEq, + ShowPretty { def encrypt( algorithm: SensitiveData.Algorithm, @@ -37,7 +37,7 @@ object UserCommandEvent { } object Create { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Encrypted( + final case class Encrypted( id: ID[User], email: SensitiveData.Encrypted[User.Email], username: SensitiveData.Encrypted[User.Name], @@ -47,21 +47,23 @@ object UserCommandEvent { sessionID: ID[Session], sessionExpiresAt: Session.ExpirationTime, correlationID: CorrelationID - ) extends UserCommandEvent { + ) extends UserCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor { def decrypt( algorithm: SensitiveData.Algorithm, key: SensitiveData.Key ): DeserializationResult[Create] = this - .intoF[DeserializationResult, Create] - .withFieldComputedF(_.email, _.email.decrypt(algorithm, key)) - .withFieldComputedF(_.username, _.username.decrypt(algorithm, key)) - .withFieldComputedF(_.password, _.password.decrypt(algorithm, key)) + .intoPartial[Create] + .withFieldComputedPartial(_.email, _.email.decrypt(algorithm, key).asResult) + .withFieldComputedPartial(_.username, _.username.decrypt(algorithm, key).asResult) + .withFieldComputedPartial(_.password, _.password.decrypt(algorithm, key).asResult) .transform + .asEither + .leftMap(e => DeserializationError.DecryptionError(this.show, e.asErrorPathMessageStrings.toMap)) } } - @Semi(FastEq, ShowPretty) final case class Update( + final case class Update( id: ID[User], moderatorID: Option[ID[User]], newUsername: Updatable[SensitiveData[User.Name]], @@ -70,7 +72,8 @@ object UserCommandEvent { updatePermissions: List[Permission.Update], modifiedAt: ModificationTime, correlationID: CorrelationID - ) { + ) derives FastEq, + ShowPretty { def encrypt( algorithm: SensitiveData.Algorithm, @@ -83,7 +86,7 @@ object UserCommandEvent { } object Update { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Encrypted( + final case class Encrypted( id: ID[User], moderatorID: Option[ID[User]], newUsername: Updatable[SensitiveData.Encrypted[User.Name]], @@ -92,23 +95,25 @@ object UserCommandEvent { updatePermissions: List[Permission.Update], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends UserCommandEvent { + ) extends UserCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor { def decrypt( algorithm: SensitiveData.Algorithm, key: SensitiveData.Key ): DeserializationResult[Update] = this - .intoF[DeserializationResult, Update] - .withFieldComputedF(_.newUsername, _.newUsername.traverse(_.decrypt(algorithm, key))) - .withFieldComputedF(_.newPassword, _.newPassword.traverse(_.decrypt(algorithm, key))) + .intoPartial[Update] + .withFieldComputedPartial(_.newUsername, _.newUsername.traverse(_.decrypt(algorithm, key)).asResult) + .withFieldComputedPartial(_.newPassword, _.newPassword.traverse(_.decrypt(algorithm, key)).asResult) .transform + .asEither + .leftMap(e => DeserializationError.DecryptionError(this.show, e.asErrorPathMessageStrings.toMap)) } } - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Delete( + final case class Delete( id: ID[User], moderatorID: Option[ID[User]], deletedAt: ModificationTime, correlationID: CorrelationID - ) extends UserCommandEvent + ) extends UserCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/events/UsersCommandEvent.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/events/UsersCommandEvent.scala index 035d8095..3517a434 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/events/UsersCommandEvent.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/events/UsersCommandEvent.scala @@ -1,11 +1,9 @@ package io.branchtalk.users.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT +import com.sksamuel.avro4s.* import io.branchtalk.shared.model.{ FastEq, ShowPretty } -import io.scalaland.catnip.Semi -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait UsersCommandEvent extends ADT +sealed trait UsersCommandEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object UsersCommandEvent { final case class ForUser(user: UserCommandEvent) extends UsersCommandEvent final case class ForBan(ban: BanCommandEvent) extends UsersCommandEvent diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/infrastructure/DoobieExtensions.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/infrastructure/DoobieExtensions.scala index 280814cf..cb93fb9b 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/infrastructure/DoobieExtensions.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/infrastructure/DoobieExtensions.scala @@ -1,12 +1,11 @@ package io.branchtalk.users.infrastructure -import cats.Id -import com.github.plokhotnyuk.jsoniter_scala.core._ -import com.github.plokhotnyuk.jsoniter_scala.macros._ -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model.{ ID, SensitiveData, UUID, branchtalkLocale } -import io.branchtalk.users.model.{ Ban, Password, Permission, Permissions, Session } -import io.estatico.newtype.Coercible +import cats.{ Id, Show } +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* +import io.branchtalk.users.model.* import org.postgresql.util.PGobject import scala.annotation.nowarn @@ -14,49 +13,73 @@ import scala.collection.compat.immutable.ArraySeq object DoobieExtensions { - implicit val banScopeTypeMeta: Meta[Ban.Scope.Type] = - pgEnumString("user_ban_type", Ban.Scope.Type.withNameInsensitive, _.entryName.toLowerCase(branchtalkLocale)) + private given [A: Show]: Show[Array[A]] = _.map(_.show).mkString(", ") - implicit val passwordAlgorithmMeta: Meta[Password.Algorithm] = - pgEnumString("password_algorithm", - Password.Algorithm.withNameInsensitive, - _.entryName.toLowerCase(branchtalkLocale) - ) + @SuppressWarnings(Array("org.wartremover.warts.Throw")) + given banScopeTypeMeta: Meta[Ban.Scope.Type] = pgEnumString( + "user_ban_type", + name => + Ban.Scope.Type.values + .find(_.entryName.equalsIgnoreCase(name)) + .getOrElse(throw new NoSuchElementException(show"$name is not a member of Enum (${Ban.Scope.Type.values})")), + _.entryName.toLowerCase(branchtalkLocale) + ) - implicit val sessionUsageTypeMeta: Meta[Session.Usage.Type] = - pgEnumString("session_usage_type", - Session.Usage.Type.withNameInsensitive, - _.entryName.toLowerCase(branchtalkLocale) - ) + given passwordAlgorithmMeta: Meta[Password.Algorithm] = pgEnumString( + "password_algorithm", + Password.Algorithm.withNameInsensitive, + _.entryName.toLowerCase(branchtalkLocale) + ) + + given passwordHashMeta: Meta[Password.Hash] = Password.Hash.unsafeMakeF(Meta.apply) + + given passwordSaltMeta: Meta[Password.Salt] = Password.Salt.unsafeMakeF(Meta.apply) + + @SuppressWarnings(Array("org.wartremover.warts.Throw")) + given sessionUsageTypeMeta: Meta[Session.Usage.Type] = pgEnumString( + "session_usage_type", + name => + Session.Usage.Type.values + .find(_.entryName.equalsIgnoreCase(name)) + .getOrElse( + throw new NoSuchElementException(show"$name is not a member of Enum (${Session.Usage.Type.values})") + ), + _.entryName.toLowerCase(branchtalkLocale) + ) + + given sessionExpirationTime: Meta[Session.ExpirationTime] = Session.ExpirationTime.unsafeMakeF(Meta.apply) - implicit val sensitiveDataAlgorithmTypeMeta: Meta[SensitiveData.Algorithm] = - pgEnumString("data_encryption_algorithm", - SensitiveData.Algorithm.withNameInsensitive, - _.entryName.toLowerCase(branchtalkLocale) + @SuppressWarnings(Array("org.wartremover.warts.Throw")) + given sensitiveDataAlgorithmTypeMeta: Meta[SensitiveData.Algorithm] = + pgEnumString( + "data_encryption_algorithm", + name => + SensitiveData.Algorithm.values + .find(_.entryName.equalsIgnoreCase(name)) + .getOrElse( + throw new NoSuchElementException(show"$name is not a member of Enum (${SensitiveData.Algorithm.values})") + ), + _.entryName.toLowerCase(branchtalkLocale) ) - @SuppressWarnings(Array("org.wartremover.warts.All")) // coercible - implicit val sensitiveDataKey: Meta[SensitiveData.Key] = - Meta[Array[Byte]].timap[ArraySeq[Byte]](ArraySeq.from(_))(a => a.toArray).asInstanceOf[Meta[SensitiveData.Key]] + given sensitiveDataKey: Meta[SensitiveData.Key] = + SensitiveData.Key.unsafeMakeF[Meta](Meta[Array[Byte]].timap[ArraySeq[Byte]](ArraySeq.from(_))(a => a.toArray)) - @nowarn("cat=unused") // macros - @SuppressWarnings(Array("org.wartremover.warts.All")) // macros - implicit private def idCodec[A](implicit ev: Coercible[UUID, ID[A]]): JsonValueCodec[ID[A]] = - Coercible.unsafeWrapMM[JsonValueCodec, Id, UUID, ID[A]].apply(JsonCodecMaker.make) - @SuppressWarnings(Array("org.wartremover.warts.All")) // macros - implicit private val permissionCodec: JsonValueCodec[Permission] = JsonCodecMaker.make[Permission] - @SuppressWarnings(Array("org.wartremover.warts.All")) // macros - implicit private val permissionsCodec: JsonValueCodec[Permissions] = - Coercible[JsonValueCodec[Set[Permission]], JsonValueCodec[Permissions]].apply(JsonCodecMaker.make) + private given JsonValueCodec[Permission] = { + given [A]: JsonValueCodec[ID[A]] = ID.unsafeMakeF[JsonValueCodec, A](JsonCodecMaker.make[UUID]) + JsonCodecMaker.make[Permission] + } + private given JsonValueCodec[Permissions] = + Permissions.unsafeMakeF[JsonValueCodec](JsonCodecMaker.make[Set[Permission]]) private val jsonType = "jsonb" - implicit val permissionMeta: Meta[Permission] = + given permissionMeta: Meta[Permission] = Meta.Advanced.other[PGobject](jsonType).timap[Permission](pgObj => readFromString[Permission](pgObj.getValue)) { permission => new PGobject().tap(_.setType(jsonType)).tap(_.setValue(writeToString(permission))) } - implicit val permissionsMeta: Meta[Permissions] = + given permissionsMeta: Meta[Permissions] = // imap instead of timap because a @newtype cannot have TypeTag Meta.Advanced.other[PGobject](jsonType).imap[Permissions](pgObj => readFromString[Permissions](pgObj.getValue)) { permissions => new PGobject().tap(_.setType(jsonType)).tap(_.setValue(writeToString(permissions))) diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/model/BanDao.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/model/BanDao.scala index 6e9b3c26..010e4ab1 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/model/BanDao.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/model/BanDao.scala @@ -1,7 +1,7 @@ package io.branchtalk.users.model import io.branchtalk.shared.model.{ ID, UUID } -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final case class BanDao( bannedUserID: ID[User], diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/model/SessionDao.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/model/SessionDao.scala index d9998aec..3abe6aeb 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/model/SessionDao.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/model/SessionDao.scala @@ -1,7 +1,7 @@ package io.branchtalk.users.model import io.branchtalk.shared.model.ID -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final case class SessionDao( id: ID[Session], diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/model/UserDao.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/model/UserDao.scala index 3dd3bd90..9ae517af 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/model/UserDao.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/model/UserDao.scala @@ -1,7 +1,7 @@ package io.branchtalk.users.model import io.branchtalk.shared.model.{ CreationTime, ID, ModificationTime } -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final case class UserDao( id: ID[User], diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/reads/BanReadsImpl.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/reads/BanReadsImpl.scala index f5f7d4a8..de034d86 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/reads/BanReadsImpl.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/reads/BanReadsImpl.scala @@ -1,16 +1,14 @@ package io.branchtalk.users.reads import cats.effect.Sync -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.infrastructure.DoobieSupport.Fragments.whereAnd import io.branchtalk.shared.model.ID -import io.branchtalk.users.infrastructure.DoobieExtensions._ +import io.branchtalk.users.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.users.model.{ Ban, BanDao, Channel, User } final class BanReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends BanReads[F] { - implicit private val logHandler: LogHandler = doobieLogger(getClass) - private val channelBan: Ban.Scope.Type = Ban.Scope.Type.ForChannel private val globalBan: Ban.Scope.Type = Ban.Scope.Type.Globally @@ -22,15 +20,23 @@ final class BanReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends BanReads |FROM bans""".stripMargin override def findForUser(userID: ID[User]): F[Set[Ban]] = - (commonSelect ++ whereAnd(fr"user_id = $userID")).query[BanDao].map(_.toDomain).to[Set].transact(transactor) + (commonSelect ++ whereAnd(fr"user_id = $userID")) + .queryWithLabel[BanDao](show"Require Users' Ban for User=$userID") + .map(_.toDomain) + .to[Set] + .transact(transactor) override def findForChannel(channelID: ID[Channel]): F[Set[Ban]] = (commonSelect ++ whereAnd(fr"ban_id = $channelID", fr"ban_type = $channelBan")) - .query[BanDao] + .queryWithLabel[BanDao](show"Require Users' Ban for Channel=$channelID") .map(_.toDomain) .to[Set] .transact(transactor) override def findGlobally: F[Set[Ban]] = - (commonSelect ++ whereAnd(fr"ban_type = $globalBan")).query[BanDao].map(_.toDomain).to[Set].transact(transactor) + (commonSelect ++ whereAnd(fr"ban_type = $globalBan")) + .queryWithLabel[BanDao]("Require Users' Ban globally") + .map(_.toDomain) + .to[Set] + .transact(transactor) } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/reads/SessionReadsImpl.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/reads/SessionReadsImpl.scala index 3c88d0bd..f294310a 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/reads/SessionReadsImpl.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/reads/SessionReadsImpl.scala @@ -1,18 +1,12 @@ package io.branchtalk.users.reads import cats.effect.Sync -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.model.{ ID, Paginated } -import io.branchtalk.users.infrastructure.DoobieExtensions._ +import io.branchtalk.users.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.users.model.{ Session, SessionDao, User } -final class SessionReadsImpl[F[_]: Sync]( - transactor: Transactor[F] -) extends SessionReads[F] { - - implicit private val logHandler: LogHandler = doobieLogger(getClass) +final class SessionReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends SessionReads[F] { private val commonSelect: Fragment = fr"""SELECT id, @@ -29,17 +23,17 @@ final class SessionReadsImpl[F[_]: Sync]( override def paginate( user: ID[User], sortBy: Session.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Session]] = - (commonSelect ++ fr"WHERE user_id = ${user}" ++ orderBy(sortBy)) - .paginate[SessionDao](offset, limit) + (commonSelect ++ fr"WHERE user_id = $user" ++ orderBy(sortBy)) + .paginate[SessionDao](offset, limit, show"Paginate Users' Session from $offset taking $limit sorted by $sortBy") .map(_.map(_.toDomain)) .transact(transactor) override def requireById(id: ID[Session]): F[Session] = - (commonSelect ++ fr"WHERE id = ${id}") - .query[SessionDao] + (commonSelect ++ fr"WHERE id = $id") + .queryWithLabel[SessionDao](show"Require Users' Session by ID=$id") .map(_.toDomain) .failNotFound("Session", id) .transact(transactor) diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/reads/UserReadsImpl.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/reads/UserReadsImpl.scala index 73c7cde5..729b7b6d 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/reads/UserReadsImpl.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/reads/UserReadsImpl.scala @@ -1,18 +1,13 @@ package io.branchtalk.users.reads import cats.effect.Sync -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.Paginated -import io.branchtalk.users.infrastructure.DoobieExtensions._ -import io.branchtalk.users.model.{ Password, User, UserDao } +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* +import io.branchtalk.users.infrastructure.DoobieExtensions.{ *, given } +import io.branchtalk.users.model.* final class UserReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends UserReads[F] { - implicit private val logHandler: LogHandler = doobieLogger(getClass) - private val commonSelect: Fragment = fr"""SELECT id, | email, @@ -37,11 +32,11 @@ final class UserReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends UserRea case User.Sorting.EmailAlphabetically => fr"ORDER BY email ASC" } - private def idExists(id: ID[User]): Fragment = fr"id = ${id}" + private def idExists(id: ID[User]): Fragment = fr"id = $id" override def authenticate(username: User.Name, password: Password.Raw): F[User] = (commonSelect ++ fr"WHERE username = ${username}") - .query[UserDao] + .queryWithLabel[UserDao](show"Authenticate Users' User for Name=${username}") .map(_.toDomain) .option .transact(transactor) @@ -54,27 +49,33 @@ final class UserReadsImpl[F[_]: Sync](transactor: Transactor[F]) extends UserRea override def paginate( sortBy: User.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive, + offset: Paginated.Offset, + limit: Paginated.Limit, filters: List[User.Filter] = List.empty ): F[Paginated[User]] = - (commonSelect ++ Fragments.whereAnd(filters.map(filtered): _*) ++ orderBy(sortBy)) - .paginate[UserDao](offset, limit) + (commonSelect ++ Fragments.whereAndOpt(filters.map(filtered)) ++ orderBy(sortBy)) + .paginate[UserDao](offset, limit, show"Paginate Users' Session from $offset taking $limit sorted by $sortBy") .map(_.map(_.toDomain)) .transact(transactor) override def exists(id: ID[User]): F[Boolean] = - (fr"SELECT 1 FROM users WHERE" ++ idExists(id)).exists.transact(transactor) + (fr"SELECT 1 FROM users WHERE" ++ idExists(id)).exists(show"Users' User ID=$id exists").transact(transactor) override def deleted(id: ID[User]): F[Boolean] = - (fr"SELECT 1 FROM deleted_users WHERE" ++ idExists(id)).exists.transact(transactor) + (fr"SELECT 1 FROM deleted_users WHERE" ++ idExists(id)) + .exists(show"Users' User ID=$id deleted") + .transact(transactor) override def getById(id: ID[User]): F[Option[User]] = - (commonSelect ++ fr"WHERE" ++ idExists(id)).query[UserDao].map(_.toDomain).option.transact(transactor) + (commonSelect ++ fr"WHERE" ++ idExists(id)) + .queryWithLabel[UserDao](show"Get Users' User by ID=$id") + .map(_.toDomain) + .option + .transact(transactor) override def requireById(id: ID[User]): F[User] = (commonSelect ++ fr"WHERE" ++ idExists(id)) - .query[UserDao] + .queryWithLabel[UserDao](show"Require Users' User by ID=$id") .map(_.toDomain) .failNotFound("User", id) .transact(transactor) diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanCommandHandler.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanCommandHandler.scala index 30bd659f..ca2606be 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanCommandHandler.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanCommandHandler.scala @@ -6,7 +6,7 @@ import fs2.Stream import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID import io.branchtalk.users.events.{ BanCommandEvent, BanEvent, UsersCommandEvent, UsersEvent } -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class BanCommandHandler[F[_]: Sync] extends Projector[F, UsersCommandEvent, (UUID, UsersEvent)] { @@ -26,8 +26,8 @@ final class BanCommandHandler[F[_]: Sync] extends Projector[F, UsersCommandEvent } def toOrder(event: BanCommandEvent.OrderBan): F[(UUID, BanEvent.Banned)] = - (event.bannedUserID.uuid -> event.transformInto[BanEvent.Banned]).pure[F] + (event.bannedUserID.unwrap -> event.transformInto[BanEvent.Banned]).pure[F] def toLift(event: BanCommandEvent.LiftBan): F[(UUID, BanEvent.Unbanned)] = - (event.bannedUserID.uuid -> event.transformInto[BanEvent.Unbanned]).pure[F] + (event.bannedUserID.unwrap -> event.transformInto[BanEvent.Unbanned]).pure[F] } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanPostgresProjector.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanPostgresProjector.scala index 5cd12d82..de0af9b1 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanPostgresProjector.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanPostgresProjector.scala @@ -4,21 +4,18 @@ import cats.effect.Sync import com.typesafe.scalalogging.Logger import fs2.Stream import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID import io.branchtalk.users.events.{ BanEvent, UsersEvent } -import io.branchtalk.users.infrastructure.DoobieExtensions._ +import io.branchtalk.users.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.users.model.Ban -import io.branchtalk.users.model.BanProperties.Scope final class BanPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) extends Projector[F, UsersEvent, (UUID, UsersEvent)] { private val logger = Logger(getClass) - implicit private val logHandler: LogHandler = doobieLogger(getClass) - override def apply(in: Stream[F, UsersEvent]): Stream[F, (UUID, UsersEvent)] = in.collect { case UsersEvent.ForBan(event) => event @@ -46,7 +43,7 @@ final class BanPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) | ${banType}, | ${banID}, | ${event.reason} - |)""".stripMargin.update.run.as(event.bannedUserID.uuid -> event).transact(transactor) + |)""".stripMargin.update.run.as(event.bannedUserID.unwrap -> event).transact(transactor) } @@ -54,15 +51,15 @@ final class BanPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) withCorrelationID(event.correlationID) { val Ban.Scope.Tupled(banType, _) = event.scope (event.scope match { - case Scope.ForChannel(channelID) => + case Ban.Scope.ForChannel(channelID) => sql"""DELETE FROM bans |WHERE user_id = ${event.bannedUserID} | AND ban_type = $banType | AND ban_id = $channelID""".stripMargin - case Scope.Globally => + case Ban.Scope.Globally => sql"""DELETE FROM bans |WHERE user_id = ${event.bannedUserID} | AND ban_type = $banType""".stripMargin - }).update.run.as(event.bannedUserID.uuid -> event).transact(transactor) + }).update.run.as(event.bannedUserID.unwrap -> event).transact(transactor) } } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanWritesImpl.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanWritesImpl.scala index 84d1e835..9c878b99 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanWritesImpl.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/BanWritesImpl.scala @@ -2,27 +2,26 @@ package io.branchtalk.users.writes import cats.effect.Sync import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.infrastructure.{ EventBusProducer, Writes } -import io.branchtalk.shared.model.{ CreationTime, ModificationTime, UUIDGenerator } +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.infrastructure.{ KafkaEventBus, Writes } +import io.branchtalk.shared.model.{ CreationTime, ModificationTime, UUID } import io.branchtalk.users.events.{ BanCommandEvent, UsersCommandEvent } import io.branchtalk.users.model.{ Ban, User } -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class BanWritesImpl[F[_]: Sync: MDC]( - producer: EventBusProducer[F, UsersCommandEvent], + producer: KafkaEventBus.Producer[F, UsersCommandEvent], transactor: Transactor[F] -)(implicit - uuidGenerator: UUIDGenerator -) extends Writes[F, User, UsersCommandEvent](producer) - with BanWrites[F] { +)(using UUID.Generator) + extends Writes[F, User, UsersCommandEvent](producer), + BanWrites[F] { private val userCheck = new EntityCheck("User", transactor) override def orderBan(order: Ban.Order): F[Unit] = for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = order.bannedUserID - _ <- userCheck(id, sql"""SELECT 1 FROM users WHERE id = ${id}""") + _ <- userCheck(id, sql"""SELECT 1 FROM users WHERE id = $id""") now <- CreationTime.now[F] command = order .into[BanCommandEvent.OrderBan] @@ -35,7 +34,7 @@ final class BanWritesImpl[F[_]: Sync: MDC]( override def liftBan(lift: Ban.Lift): F[Unit] = for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = lift.bannedUserID - _ <- userCheck(id, sql"""SELECT 1 FROM users WHERE id = ${id}""") + _ <- userCheck(id, sql"""SELECT 1 FROM users WHERE id = $id""") now <- ModificationTime.now[F] command = lift .into[BanCommandEvent.LiftBan] diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/DiscussionsConsumer.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/DiscussionsConsumer.scala index c86405b1..31530ffb 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/DiscussionsConsumer.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/DiscussionsConsumer.scala @@ -13,18 +13,18 @@ final class DiscussionsConsumer[F[_]: Sync] extends Projector[F, DiscussionEvent override def apply(in: Stream[F, DiscussionEvent]): Stream[F, (UUID, UsersCommandEvent)] = in.collect { case DiscussionEvent.ForChannel(created: ChannelEvent.Created) => val event = toGrantedChannelModerator(created) - event.id.uuid -> UsersCommandEvent.ForUser(event) + event.id.unwrap -> UsersCommandEvent.ForUser(event) } def toGrantedChannelModerator(created: ChannelEvent.Created): UserCommandEvent.Update.Encrypted = UserCommandEvent.Update.Encrypted( - id = ID[User](created.authorID.uuid), + id = ID[User](created.authorID.unwrap), moderatorID = None, newUsername = Updatable.Keep, newDescription = OptionUpdatable.Keep, newPassword = Updatable.Keep, - updatePermissions = List(Permission.Update.Add(Permission.ModerateChannel(ID[Channel](created.id.uuid)))), - modifiedAt = ModificationTime(created.createdAt.offsetDateTime), + updatePermissions = List(Permission.Update.Add(Permission.ModerateChannel(ID[Channel](created.id.unwrap)))), + modifiedAt = ModificationTime(created.createdAt.unwrap), correlationID = created.correlationID ) } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/SessionWritesImpl.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/SessionWritesImpl.scala index 3d942e2f..484552c4 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/SessionWritesImpl.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/SessionWritesImpl.scala @@ -2,27 +2,24 @@ package io.branchtalk.users.writes import cats.effect.Sync import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.infrastructure.{ EventBusProducer, Writes } -import io.branchtalk.shared.model.{ ID, UUIDGenerator } +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.infrastructure.{ KafkaEventBus, Writes } +import io.branchtalk.shared.model.{ ID, UUID } import io.branchtalk.users.events.{ SessionEvent, UsersEvent } -import io.branchtalk.users.infrastructure.DoobieExtensions._ +import io.branchtalk.users.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.users.model.{ Session, SessionDao } import io.branchtalk.users.reads.SessionReadsImpl -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class SessionWritesImpl[F[_]: Sync: MDC]( - producer: EventBusProducer[F, UsersEvent], + producer: KafkaEventBus.Producer[F, UsersEvent], transactor: Transactor[F] -)(implicit - uuidGenerator: UUIDGenerator -) extends Writes[F, Session, UsersEvent](producer) - with SessionWrites[F] { +)(using UUID.Generator) + extends Writes[F, Session, UsersEvent](producer), + SessionWrites[F] { private val reads = new SessionReadsImpl[F](transactor) - implicit private val logHandler: LogHandler = doobieLogger(getClass) - override def createSession(newSession: Session.Create): F[Session] = for { id <- ID.create[F, Session] diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserCommandHandler.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserCommandHandler.scala index 98540f6a..140b743e 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserCommandHandler.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserCommandHandler.scala @@ -6,7 +6,7 @@ import fs2.Stream import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.UUID import io.branchtalk.users.events.{ UserCommandEvent, UserEvent, UsersCommandEvent, UsersEvent } -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class UserCommandHandler[F[_]: Sync] extends Projector[F, UsersCommandEvent, (UUID, UsersEvent)] { @@ -27,11 +27,11 @@ final class UserCommandHandler[F[_]: Sync] extends Projector[F, UsersCommandEven } def toCreate(command: UserCommandEvent.Create.Encrypted): F[(UUID, UserEvent.Created.Encrypted)] = - (command.id.uuid -> command.transformInto[UserEvent.Created.Encrypted]).pure[F] + (command.id.unwrap -> command.transformInto[UserEvent.Created.Encrypted]).pure[F] def toUpdate(command: UserCommandEvent.Update.Encrypted): F[(UUID, UserEvent.Updated.Encrypted)] = - (command.id.uuid -> command.transformInto[UserEvent.Updated.Encrypted]).pure[F] + (command.id.unwrap -> command.transformInto[UserEvent.Updated.Encrypted]).pure[F] def toDelete(command: UserCommandEvent.Delete): F[(UUID, UserEvent.Deleted)] = - (command.id.uuid -> command.transformInto[UserEvent.Deleted]).pure[F] + (command.id.unwrap -> command.transformInto[UserEvent.Deleted]).pure[F] } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserPostgresProjector.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserPostgresProjector.scala index d46ad640..cbbf31f8 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserPostgresProjector.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserPostgresProjector.scala @@ -5,11 +5,11 @@ import cats.effect.Sync import com.typesafe.scalalogging.Logger import fs2.Stream import io.branchtalk.logging.MDC -import io.branchtalk.shared.infrastructure.DoobieSupport._ +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } import io.branchtalk.shared.infrastructure.Projector import io.branchtalk.shared.model.{ ID, SensitiveData, UUID } import io.branchtalk.users.events.{ UserEvent, UsersEvent } -import io.branchtalk.users.infrastructure.DoobieExtensions._ +import io.branchtalk.users.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.users.model.{ Permission, Permissions, Session, User } final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) @@ -17,8 +17,6 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) private val logger = Logger(getClass) - implicit private val logHandler: LogHandler = doobieLogger(getClass) - override def apply(in: Stream[F, UsersEvent]): Stream[F, (UUID, UsersEvent)] = in.collect { case UsersEvent.ForUser(event) => event @@ -34,7 +32,6 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) Stream.empty } - // scalastyle:off method.length def toCreate(encrypted: UserEvent.Created.Encrypted): F[Option[(UUID, UserEvent.Created.Encrypted)]] = withCorrelationID(encrypted.correlationID) { findKeys(encrypted.id) @@ -45,8 +42,12 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) val Session.Usage.Tupled(sessionType, sessionPermissions) = Session.Usage.UserSession - sql"DELETE FROM reserved_emails WHERE email = ${event.email.value}".update.run >> - sql"DELETE FROM reserved_usernames WHERE username = ${event.username.value}".update.run >> + sql"DELETE FROM reserved_emails WHERE email = ${event.email.value}" + .updateWithLabel(show"Delete Users' Email reservation") + .run >> + sql"DELETE FROM reserved_usernames WHERE username = ${event.username.value}" + .updateWithLabel(show"Delete Users' Name reservation") + .run >> sql"""INSERT INTO users ( | id, | email, @@ -69,7 +70,9 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) | ${Permissions(Set.empty)}, | ${event.createdAt} |) - |ON CONFLICT (id) DO NOTHING""".stripMargin.update.run >> + |ON CONFLICT (id) DO NOTHING""".stripMargin + .updateWithLabel(show"Create Users' User ID=${event.id}") + .run >> sql"""INSERT INTO sessions ( | id, | user_id, @@ -86,12 +89,10 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) |)""".stripMargin.update.run } ) - .as((encrypted.id.uuid -> encrypted).some) + .as((encrypted.id.unwrap -> encrypted).some) .transact(transactor) } - // scalastyle:on method.length - // scalastyle:off method.length def toUpdate(encrypted: UserEvent.Updated.Encrypted): F[Option[(UUID, UserEvent.Updated.Encrypted)]] = withCorrelationID(encrypted.correlationID) { findKeys(encrypted.id) @@ -99,17 +100,20 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) _.traverse { case (algorithm, key) => @SuppressWarnings(Array("org.wartremover.warts.Throw")) val event = encrypted.decrypt(algorithm, key).fold(e => throw new Exception(e.show), identity) - import event._ + import event.* val defaultPermissions = Permissions.empty val permissionsUpdateNel = NonEmptyList.fromList(updatePermissions) - val cleanReservedIfNecessary = event.newUsername.toOption - .traverse(username => sql"DELETE FROM reserved_usernames WHERE username = ${username.value}".update.run) + val cleanReservedIfNecessary = event.newUsername.toOption.traverse(username => + sql"DELETE FROM reserved_usernames WHERE username = ${username.value}" + .updateWithLabel(show"Delete Users' Name=${username} reservation") + .run + ) val fetchPermissionsIfNecessary = permissionsUpdateNel.fold(defaultPermissions.pure[ConnectionIO]) { _ => sql"""SELECT permissions FROM users WHERE id = $id""" - .query[Permissions] + .queryWithLabel[Permissions](show"Get Users' Permissions for ID=$id") .option .map(_.getOrElse(defaultPermissions)) } @@ -128,9 +132,9 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) ), permissionsUpdateNel.map { nel => fr"""permissions = ${nel.foldLeft(existingPermissions) { - case (permissions, Permission.Update.Add(permission)) => permissions.append(permission) - case (permissions, Permission.Update.Remove(permission)) => permissions.remove(permission) - }}""" + case (permissions, Permission.Update.Add(permission)) => permissions.append(permission) + case (permissions, Permission.Update.Remove(permission)) => permissions.remove(permission) + }}""" } ).flatten.pipe(NonEmptyList.fromList) match { case Some(updates) => @@ -143,12 +147,11 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) ) } - (cleanReservedIfNecessary >> fetchPermissionsIfNecessary.flatMap(updateUser)).as(id.uuid -> encrypted) + (cleanReservedIfNecessary >> fetchPermissionsIfNecessary.flatMap(updateUser)).as(id.unwrap -> encrypted) } ) .transact(transactor) } - // scalastyle:on method.length def toDelete(event: UserEvent.Deleted): F[Option[(UUID, UserEvent.Deleted)]] = withCorrelationID(event.correlationID) { @@ -157,11 +160,13 @@ final class UserPostgresProjector[F[_]: Sync: MDC](transactor: Transactor[F]) sql"""INSERT INTO deleted_users (id, deleted_at) |VALUES (${event.id}, ${event.deletedAt}) ON CONFLICT (id) DO NOTHING""".stripMargin.update.run - }.as((event.id.uuid -> event).some).transact(transactor) + }.as((event.id.unwrap -> event).some).transact(transactor) } private def findKeys(userID: ID[User]): ConnectionIO[Option[(SensitiveData.Algorithm, SensitiveData.Key)]] = sql"""SELECT enc_algorithm, key_value FROM sensitive_data_keys WHERE user_id = $userID""" - .query[(SensitiveData.Algorithm, SensitiveData.Key)] + .queryWithLabel[(SensitiveData.Algorithm, SensitiveData.Key)]( + show"Get encryption keys for Users' User ID=$userID" + ) .option } diff --git a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserWritesImpl.scala b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserWritesImpl.scala index bc2cd400..d2b54e78 100644 --- a/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserWritesImpl.scala +++ b/modules/users-impl/src/main/scala/io/branchtalk/users/writes/UserWritesImpl.scala @@ -3,55 +3,56 @@ package io.branchtalk.users.writes import cats.data.NonEmptyList import cats.effect.Sync import io.branchtalk.logging.{ CorrelationID, MDC } -import io.branchtalk.shared.infrastructure.DoobieSupport._ -import io.branchtalk.shared.infrastructure.{ EventBusProducer, Writes } -import io.branchtalk.shared.model._ +import io.branchtalk.shared.infrastructure.* +import io.branchtalk.shared.infrastructure.DoobieSupport.{ *, given } +import io.branchtalk.shared.model.* import io.branchtalk.users.events.{ UserCommandEvent, UsersCommandEvent } -import io.branchtalk.users.infrastructure.DoobieExtensions._ +import io.branchtalk.users.infrastructure.DoobieExtensions.{ *, given } import io.branchtalk.users.model.{ Session, User } -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* final class UserWritesImpl[F[_]: Sync: MDC]( - producer: EventBusProducer[F, UsersCommandEvent], + producer: KafkaEventBus.Producer[F, UsersCommandEvent], transactor: Transactor[F] -)(implicit - uuidGenerator: UUIDGenerator -) extends Writes[F, User, UsersCommandEvent](producer) - with UserWrites[F] { - - implicit private val logHandler: LogHandler = doobieLogger(getClass) +)(using UUID.Generator) + extends Writes[F, User, UsersCommandEvent](producer), + UserWrites[F] { private val sessionExpiresInDays = 7L // TODO: make it configurable private val userCheck = new EntityCheck("User", transactor) - private def reserveEmail(email: User.Email, id: Option[ID[User]] = None)(implicit pos: CodePosition): F[Unit] = { + private def reserveEmail(email: User.Email, id: Option[ID[User]] = None)(using CodePosition): F[Unit] = { for { isReserved <- sql"""SELECT 1 FROM users WHERE email = $email AND id <> $id |UNION |SELECT 1 FROM reserved_emails WHERE email = $email - |""".stripMargin.exists + |""".stripMargin.exists(show"Check if Users' Email=$email is reserved for ID=$id") _ <- if (isReserved) { - CommonError - .ValidationFailed(NonEmptyList.one(show"Email $email already exists"), pos) - .raiseError[ConnectionIO, Unit] - } else sql"""INSERT INTO reserved_emails (email) VALUES ($email)""".update.run.void + CommonError.validationFailed(show"Email $email already exists").raiseError[ConnectionIO, Unit] + } else + sql"""INSERT INTO reserved_emails (email) VALUES ($email)""" + .updateWithLabel(show"Reserve Users' Email=$email for ID=$id") + .run + .void } yield () }.transact(transactor) - private def reserveUsername(name: User.Name, id: Option[ID[User]] = None)(implicit pos: CodePosition): F[Unit] = { + private def reserveUsername(name: User.Name, id: Option[ID[User]] = None)(using CodePosition): F[Unit] = { for { isReserved <- sql"""SELECT 1 FROM users WHERE username = $name AND id <> $id |UNION |SELECT 1 FROM reserved_usernames WHERE username = $name - |""".stripMargin.exists + |""".stripMargin.exists(show"Check if Users' Name=$name is reserved for ID=$id") _ <- if (isReserved) { - CommonError - .ValidationFailed(NonEmptyList.one(show"Username $name already exists"), pos) - .raiseError[ConnectionIO, Unit] - } else sql"""INSERT INTO reserved_usernames (username) VALUES ($name)""".update.run.void + CommonError.validationFailed(show"Username $name already exists").raiseError[ConnectionIO, Unit] + } else + sql"""INSERT INTO reserved_usernames (username) VALUES ($name)""" + .updateWithLabel(show"Reserve Users' Name=$name for ID=$id") + .run + .void } yield () }.transact(transactor) @@ -72,7 +73,11 @@ final class UserWritesImpl[F[_]: Sync: MDC]( | $id, | $key, | $algorithm - |)""".stripMargin.update.run.as(algorithm -> key).transact(transactor) + |)""".stripMargin + .updateWithLabel(show"Create Users' User ID=$id") + .run + .as(algorithm -> key) + .transact(transactor) } sessionID <- ID.create[F, Session] now <- CreationTime.now[F] @@ -84,7 +89,7 @@ final class UserWritesImpl[F[_]: Sync: MDC]( .withFieldComputed(_.password, _.password.pipe(SensitiveData(_))) .withFieldConst(_.createdAt, now) .withFieldConst(_.sessionID, sessionID) - .withFieldConst(_.sessionExpiresAt, Session.ExpirationTime(now.offsetDateTime.plusDays(sessionExpiresInDays))) + .withFieldConst(_.sessionExpiresAt, Session.ExpirationTime(now.unwrap.plusDays(sessionExpiresInDays))) .withFieldConst(_.correlationID, correlationID) .transform .encrypt(algorithm, key) @@ -99,7 +104,9 @@ final class UserWritesImpl[F[_]: Sync: MDC]( _ <- userCheck(id, sql"""SELECT 1 FROM users WHERE id = $id""") (algorithm, key) <- sql"""SELECT enc_algorithm, key_value FROM sensitive_data_keys WHERE user_id = $id""" - .query[(SensitiveData.Algorithm, SensitiveData.Key)] + .queryWithLabel[(SensitiveData.Algorithm, SensitiveData.Key)]( + show"Get encryption keys for Users' User ID=$id" + ) .unique .transact(transactor) now <- ModificationTime.now[F] @@ -118,7 +125,7 @@ final class UserWritesImpl[F[_]: Sync: MDC]( for { correlationID <- CorrelationID.getCurrentOrGenerate[F] id = deletedUser.id - _ <- userCheck(id, sql"""SELECT 1 FROM users WHERE id = ${id}""") + _ <- userCheck(id, sql"""SELECT 1 FROM users WHERE id = $id""") now <- ModificationTime.now[F] command = deletedUser .into[UserCommandEvent.Delete] diff --git a/modules/users-impl/src/it/resources/users-test.conf b/modules/users-impl/src/test/resources/users-test.conf similarity index 100% rename from modules/users-impl/src/it/resources/users-test.conf rename to modules/users-impl/src/test/resources/users-test.conf diff --git a/modules/users-impl/src/it/scala/io/branchtalk/users/BanReadsWritesSpec.scala b/modules/users-impl/src/test/scala/io/branchtalk/users/BanReadsWritesSpec.scala similarity index 71% rename from modules/users-impl/src/it/scala/io/branchtalk/users/BanReadsWritesSpec.scala rename to modules/users-impl/src/test/scala/io/branchtalk/users/BanReadsWritesSpec.scala index 59d85747..57eefb2e 100644 --- a/modules/users-impl/src/it/scala/io/branchtalk/users/BanReadsWritesSpec.scala +++ b/modules/users-impl/src/test/scala/io/branchtalk/users/BanReadsWritesSpec.scala @@ -1,22 +1,20 @@ package io.branchtalk.users -import cats.syntax.eq._ import io.branchtalk.shared.model.TestUUIDGenerator import io.branchtalk.users.model.Ban -import io.branchtalk.users.model.BanProperties.Scope -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* import org.specs2.mutable.Specification -final class BanReadsWritesSpec extends Specification with UsersIOTest with UsersFixtures { +final class BanReadsWritesSpec extends Specification, UsersIOTest, UsersFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "Ban Reads & Writes" should { "order a User's Ban and lift User's ban and eventually execute command" in { for { // given - userID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + userID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) _ <- usersReads.userReads.requireById(userID).eventually() channelID <- channelIDCreate expectedBans <- banCreate(userID, channelID).map(ban => List(ban, ban.copy(scope = Ban.Scope.Globally))) @@ -37,20 +35,20 @@ final class BanReadsWritesSpec extends Specification with UsersIOTest with Users .eventually() } yield { // then - bansExecuted must_=== expectedBans.toSet - channelBansExecuted must_=== expectedBans + bansExecuted === expectedBans.toSet + channelBansExecuted === expectedBans .filter(_.scope match { - case Scope.ForChannel(_) => true - case Scope.Globally => false + case Ban.Scope.ForChannel(_) => true + case Ban.Scope.Globally => false }) .toSet - globalBansExecuted must_=== expectedBans + globalBansExecuted === expectedBans .filter(_.scope match { - case Scope.ForChannel(_) => false - case Scope.Globally => true + case Ban.Scope.ForChannel(_) => false + case Ban.Scope.Globally => true }) .toSet - bansLifted must beEmpty + bansLifted.toSeq must beEmpty } } } diff --git a/modules/users-impl/src/it/scala/io/branchtalk/users/SessionReadsWritesSpec.scala b/modules/users-impl/src/test/scala/io/branchtalk/users/SessionReadsWritesSpec.scala similarity index 71% rename from modules/users-impl/src/it/scala/io/branchtalk/users/SessionReadsWritesSpec.scala rename to modules/users-impl/src/test/scala/io/branchtalk/users/SessionReadsWritesSpec.scala index 965fa7f7..79748167 100644 --- a/modules/users-impl/src/it/scala/io/branchtalk/users/SessionReadsWritesSpec.scala +++ b/modules/users-impl/src/test/scala/io/branchtalk/users/SessionReadsWritesSpec.scala @@ -1,19 +1,20 @@ package io.branchtalk.users -import io.branchtalk.shared.model.{ CreationScheduled, TestUUIDGenerator } +import io.branchtalk.shared.model.* +import io.branchtalk.shared.infrastructure.* import io.branchtalk.users.model.Session import org.specs2.mutable.Specification -final class SessionReadsWritesSpec extends Specification with UsersIOTest with UsersFixtures { +final class SessionReadsWritesSpec extends Specification, UsersIOTest, UsersFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "Session Reads & Writes" should { "create a Session and immediately read it" in { for { // given - userID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + userID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) _ <- usersReads.userReads.requireById(userID).eventually() creationData <- (0 until 3).toList.traverse(_ => sessionCreate(userID)) // when @@ -28,7 +29,7 @@ final class SessionReadsWritesSpec extends Specification with UsersIOTest with U "allow immediate delete of a created Session" in { for { // given - userID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + userID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) _ <- usersReads.userReads.requireById(userID).eventually() creationData <- (0 until 3).toList.traverse(_ => sessionCreate(userID)) toCreate <- creationData.traverse(usersWrites.sessionWrites.createSession) @@ -69,14 +70,22 @@ final class SessionReadsWritesSpec extends Specification with UsersIOTest with U paginatedData <- (0 until 19).toList.traverse(_ => sessionCreate(userID)) _ <- paginatedData.traverse(usersWrites.sessionWrites.createSession).map(_.map(_.id)) // when - pagination <- usersReads.sessionReads.paginate(userID, Session.Sorting.ClosestToExpiry, 0L, 10) - pagination2 <- usersReads.sessionReads.paginate(userID, Session.Sorting.ClosestToExpiry, 10L, 10) + pagination <- usersReads.sessionReads.paginate(userID, + Session.Sorting.ClosestToExpiry, + Paginated.Offset(0L), + Paginated.Limit(10) + ) + pagination2 <- usersReads.sessionReads.paginate(userID, + Session.Sorting.ClosestToExpiry, + Paginated.Offset(10L), + Paginated.Limit(10) + ) } yield { // then pagination.entities must haveSize(10) - pagination.nextOffset.map(_.value) must beSome(10L) + pagination.nextOffset.map(_.unwrap) must beSome(10L) pagination2.entities must haveSize(10) - pagination2.nextOffset.map(_.value) must beNone + pagination2.nextOffset.map(_.unwrap) must beNone } } } diff --git a/modules/users-impl/src/it/scala/io/branchtalk/users/TestUsersConfig.scala b/modules/users-impl/src/test/scala/io/branchtalk/users/TestUsersConfig.scala similarity index 59% rename from modules/users-impl/src/it/scala/io/branchtalk/users/TestUsersConfig.scala rename to modules/users-impl/src/test/scala/io/branchtalk/users/TestUsersConfig.scala index 6998d93c..3fffd1bf 100644 --- a/modules/users-impl/src/it/scala/io/branchtalk/users/TestUsersConfig.scala +++ b/modules/users-impl/src/test/scala/io/branchtalk/users/TestUsersConfig.scala @@ -1,23 +1,15 @@ package io.branchtalk.users import cats.effect.{ Async, Resource, Sync } -import io.branchtalk.shared.infrastructure.{ - DomainConfig, - DomainName, - KafkaEventConsumerConfig, - TestKafkaEventBusConfig, - TestPostgresConfig, - TestResources -} -import io.scalaland.catnip.Semi -import pureconfig.{ ConfigReader, ConfigSource } +import io.branchtalk.shared.infrastructure.* +import io.branchtalk.shared.infrastructure.PureconfigSupport.{ *, given } -@Semi(ConfigReader) final case class TestUsersConfig( +final case class TestUsersConfig( database: TestPostgresConfig, publishedEventBus: TestKafkaEventBusConfig, internalEventBus: TestKafkaEventBusConfig, - consumers: Map[String, KafkaEventConsumerConfig] -) + consumers: Map[String, KafkaEventBus.ConsumerConfig] +) derives ConfigReader object TestUsersConfig { def load[F[_]: Sync]: Resource[F, TestUsersConfig] = @@ -27,11 +19,11 @@ object TestUsersConfig { ) ) - def loadDomainConfig[F[_]: Async]: Resource[F, DomainConfig] = + def loadDomainConfig[F[_]: Async]: Resource[F, DomainModule.Config] = for { TestUsersConfig(dbTest, publishedESTest, internalESTest, consumers) <- TestUsersConfig.load[F] db <- TestResources.postgresConfigResource[F](dbTest) publishedES <- TestResources.kafkaEventBusConfigResource[F](publishedESTest) internalES <- TestResources.kafkaEventBusConfigResource[F](internalESTest) - } yield DomainConfig(DomainName("discussions-test"), db, db, publishedES, internalES, consumers) + } yield DomainModule.Config(DomainModule.Name("discussions-test"), db, db, publishedES, internalES, consumers) } diff --git a/modules/users-impl/src/it/scala/io/branchtalk/users/UserPaginationSpec.scala b/modules/users-impl/src/test/scala/io/branchtalk/users/UserPaginationSpec.scala similarity index 67% rename from modules/users-impl/src/it/scala/io/branchtalk/users/UserPaginationSpec.scala rename to modules/users-impl/src/test/scala/io/branchtalk/users/UserPaginationSpec.scala index f792f0d1..4eadf396 100644 --- a/modules/users-impl/src/it/scala/io/branchtalk/users/UserPaginationSpec.scala +++ b/modules/users-impl/src/test/scala/io/branchtalk/users/UserPaginationSpec.scala @@ -1,18 +1,18 @@ package io.branchtalk.users -import io.branchtalk.shared.model.{ OptionUpdatable, TestUUIDGenerator, Updatable } +import io.branchtalk.shared.model.* import io.branchtalk.users.model.Permission.ModerateChannel import io.branchtalk.users.model.{ Permission, Permissions, User } import org.specs2.mutable.Specification import scala.concurrent.duration.DurationInt -final class UserPaginationSpec extends Specification with UsersIOTest with UsersFixtures { +final class UserPaginationSpec extends Specification, UsersIOTest, UsersFixtures { // User pagination tests cannot be run in parallel to other User tests (no parent to filter other tests) sequential - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "User pagination" should { @@ -20,17 +20,17 @@ final class UserPaginationSpec extends Specification with UsersIOTest with Users for { // given paginatedData <- (0 until 10).toList.traverse(_ => userCreate) - paginatedIDs <- paginatedData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.id)) + paginatedIDs <- paginatedData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.unwrap)) _ <- paginatedIDs .traverse(usersReads.userReads.requireById(_)) .eventually(delay = 1.second, timeout = 30.seconds) // when - pagination <- usersReads.userReads.paginate(User.Sorting.Newest, 0L, 5) - pagination2 <- usersReads.userReads.paginate(User.Sorting.Newest, 5L, 5) + pagination <- usersReads.userReads.paginate(User.Sorting.Newest, Paginated.Offset(0L), Paginated.Limit(5)) + pagination2 <- usersReads.userReads.paginate(User.Sorting.Newest, Paginated.Offset(5L), Paginated.Limit(5)) } yield { // then pagination.entities must haveSize(5) - pagination.nextOffset.map(_.value) must beSome(5L) + pagination.nextOffset.map(_.unwrap) must beSome(5L) pagination2.entities must haveSize(5) } } @@ -39,17 +39,23 @@ final class UserPaginationSpec extends Specification with UsersIOTest with Users for { // given paginatedData <- (0 until 10).toList.traverse(_ => userCreate) - paginatedIDs <- paginatedData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.id)) + paginatedIDs <- paginatedData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.unwrap)) _ <- paginatedIDs .traverse(usersReads.userReads.requireById(_)) .eventually(delay = 1.second, timeout = 30.seconds) // when - pagination <- usersReads.userReads.paginate(User.Sorting.NameAlphabetically, 0L, 5) - pagination2 <- usersReads.userReads.paginate(User.Sorting.NameAlphabetically, 5L, 5) + pagination <- usersReads.userReads.paginate(User.Sorting.NameAlphabetically, + Paginated.Offset(0L), + Paginated.Limit(5) + ) + pagination2 <- usersReads.userReads.paginate(User.Sorting.NameAlphabetically, + Paginated.Offset(5L), + Paginated.Limit(5) + ) } yield { // then pagination.entities must haveSize(5) - pagination.nextOffset.map(_.value) must beSome(5L) + pagination.nextOffset.map(_.unwrap) must beSome(5L) pagination2.entities must haveSize(5) } } @@ -58,17 +64,23 @@ final class UserPaginationSpec extends Specification with UsersIOTest with Users for { // given paginatedData <- (0 until 10).toList.traverse(_ => userCreate) - paginatedIDs <- paginatedData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.id)) + paginatedIDs <- paginatedData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.unwrap)) _ <- paginatedIDs .traverse(usersReads.userReads.requireById(_)) .eventually(delay = 1.second, timeout = 30.seconds) // when - pagination <- usersReads.userReads.paginate(User.Sorting.EmailAlphabetically, 0L, 5) - pagination2 <- usersReads.userReads.paginate(User.Sorting.EmailAlphabetically, 5L, 5) + pagination <- usersReads.userReads.paginate(User.Sorting.EmailAlphabetically, + Paginated.Offset(0L), + Paginated.Limit(5) + ) + pagination2 <- usersReads.userReads.paginate(User.Sorting.EmailAlphabetically, + Paginated.Offset(5L), + Paginated.Limit(5) + ) } yield { // then pagination.entities must haveSize(5) - pagination.nextOffset.map(_.value) must beSome(5L) + pagination.nextOffset.map(_.unwrap) must beSome(5L) pagination2.entities must haveSize(5) } } @@ -83,7 +95,7 @@ final class UserPaginationSpec extends Specification with UsersIOTest with Users ModerateChannel(channelID) ) creationdData <- permissions.traverse(_ => userCreate) - paginatedIDs <- creationdData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.id)) + paginatedIDs <- creationdData.traverse(usersWrites.userWrites.createUser).map(_.map(_._1.unwrap)) _ <- paginatedIDs .traverse(usersReads.userReads.requireById(_)) .eventually(delay = 1.second, timeout = 30.seconds) @@ -104,19 +116,24 @@ final class UserPaginationSpec extends Specification with UsersIOTest with Users .eventually(delay = 1.second, timeout = 30.seconds) // when paginations1 <- permissions.traverse(permission => - usersReads.userReads.paginate(User.Sorting.Newest, 0L, 1, List(User.Filter.HasPermission(permission))) + usersReads.userReads.paginate(User.Sorting.Newest, + Paginated.Offset(0L), + Paginated.Limit(1), + List(User.Filter.HasPermission(permission)) + ) ) paginations2 <- permissions.traverse(permission => - usersReads.userReads.paginate(User.Sorting.Newest, - 0L, - 1, - List(User.Filter.HasPermissions(Permissions(Set(permission)))) + usersReads.userReads.paginate( + User.Sorting.Newest, + Paginated.Offset(0L), + Paginated.Limit(1), + List(User.Filter.HasPermissions(Permissions(Set(permission)))) ) ) } yield { // then - paginations1.map(_.entities.size) must_=== permissions.map(_ => 1) - paginations2.map(_.entities.size) must_=== permissions.map(_ => 1) + paginations1.map(_.entities.size) === permissions.map(_ => 1) + paginations2.map(_.entities.size) === permissions.map(_ => 1) } } } diff --git a/modules/users-impl/src/it/scala/io/branchtalk/users/UserReadsWritesSpec.scala b/modules/users-impl/src/test/scala/io/branchtalk/users/UserReadsWritesSpec.scala similarity index 83% rename from modules/users-impl/src/it/scala/io/branchtalk/users/UserReadsWritesSpec.scala rename to modules/users-impl/src/test/scala/io/branchtalk/users/UserReadsWritesSpec.scala index 554c8b33..8fc8d34c 100644 --- a/modules/users-impl/src/it/scala/io/branchtalk/users/UserReadsWritesSpec.scala +++ b/modules/users-impl/src/test/scala/io/branchtalk/users/UserReadsWritesSpec.scala @@ -1,14 +1,13 @@ package io.branchtalk.users import cats.effect.IO -import io.branchtalk.shared.model.{ CommonError, ID, OptionUpdatable, TestUUIDGenerator, Updatable } +import io.branchtalk.shared.model.* import io.branchtalk.users.model.{ Password, Permission, Permissions, User } -import monocle.macros.syntax.lens._ import org.specs2.mutable.Specification -final class UserReadsWritesSpec extends Specification with UsersIOTest with UsersFixtures { +final class UserReadsWritesSpec extends Specification, UsersIOTest, UsersFixtures { - implicit protected val uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator + protected given uuidGenerator: TestUUIDGenerator = new TestUUIDGenerator "User Reads & Writes" should { @@ -20,7 +19,7 @@ final class UserReadsWritesSpec extends Specification with UsersIOTest with User creationData <- (0 until 3).toList.traverse(_ => userCreate) // when toCreate <- creationData.traverse(usersWrites.userWrites.createUser) - ids = toCreate.map(_._1.id) + ids = toCreate.map(_._1.unwrap) users <- ids.traverse(usersReads.userReads.requireById).eventually() usersOpt <- ids.traverse(usersReads.userReads.getById).eventually() usersExist <- ids.traverse(usersReads.userReads.exists).eventually() @@ -37,7 +36,7 @@ final class UserReadsWritesSpec extends Specification with UsersIOTest with User "don't update a User that doesn't exists" in { for { // given - moderatorID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + moderatorID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) _ <- usersReads.userReads.requireById(moderatorID).eventually() creationData <- (0 until 3).toList.traverse(_ => userCreate) fakeUpdateData <- creationData.traverse { data => @@ -62,11 +61,11 @@ final class UserReadsWritesSpec extends Specification with UsersIOTest with User "update an existing User" in { for { // given - moderatorID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + moderatorID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) _ <- usersReads.userReads.requireById(moderatorID).eventually() creationData <- (0 until 3).toList.traverse(_ => userCreate) toCreate <- creationData.traverse(usersWrites.userWrites.createUser) - ids = toCreate.map(_._1.id) + ids = toCreate.map(_._1.unwrap) created <- ids.traverse(usersReads.userReads.requireById).eventually() updateData = created.zipWithIndex.collect { case (User(id, data), 0) => @@ -111,19 +110,15 @@ final class UserReadsWritesSpec extends Specification with UsersIOTest with User .collect { case ((User(id, older), User(_, newer)), 0) => // set case - older.focus(_.permissions).replace(Permissions.empty.append(Permission.IsUser(id))) must_=== newer - .focus(_.lastModifiedAt) - .replace(None) + older.copy(permissions = Permissions.empty.append(Permission.IsUser(id))) === newer + .copy(lastModifiedAt = None) case ((User(_, older), User(_, newer)), 1) => // keep case - older must_=== newer + older === newer case ((User(_, older), User(_, newer)), 2) => // erase case - older - .focus(_.permissions) - .replace(Permissions.empty.append(Permission.ModerateUsers)) - .focus(_.description) - .replace(None) must_=== newer.focus(_.lastModifiedAt).replace(None) + older.copy(permissions = Permissions.empty.append(Permission.ModerateUsers), description = None) === newer + .copy(lastModifiedAt = None) } .lastOption .getOrElse(true must beFalse) @@ -132,12 +127,12 @@ final class UserReadsWritesSpec extends Specification with UsersIOTest with User "allow delete of a created User" in { for { // given - moderatorID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.id) + moderatorID <- userCreate.flatMap(usersWrites.userWrites.createUser).map(_._1.unwrap) _ <- usersReads.userReads.requireById(moderatorID).eventually() creationData <- (0 until 3).toList.traverse(_ => userCreate) // when toCreate <- creationData.traverse(usersWrites.userWrites.createUser) - ids = toCreate.map(_._1.id) + ids = toCreate.map(_._1.unwrap) _ <- ids.traverse(usersReads.userReads.requireById).eventually() _ <- ids.map(User.Delete(_, moderatorID.some)).traverse(usersWrites.userWrites.deleteUser) _ <- ids @@ -157,12 +152,12 @@ final class UserReadsWritesSpec extends Specification with UsersIOTest with User for { // given goodPassword <- passwordCreate("password") - rawGoodPassword <- Password.Raw.parse[IO]("password".getBytes) - rawBadPassword <- Password.Raw.parse[IO]("bad".getBytes) + rawGoodPassword <- ParseNewtype[IO].parse[Password.Raw]("password".getBytes) + rawBadPassword <- ParseNewtype[IO].parse[Password.Raw]("bad".getBytes) userId <- userCreate .map(_.copy(password = goodPassword)) .flatMap(usersWrites.userWrites.createUser) - .map(_._1.id) + .map(_._1.unwrap) user <- usersReads.userReads.requireById(userId).eventually() // when ok <- usersReads.userReads.authenticate(user.data.username, rawGoodPassword).attempt @@ -170,7 +165,7 @@ final class UserReadsWritesSpec extends Specification with UsersIOTest with User } yield { // then ok must beRight(user) - fail must beLeft(anInstanceOf[CommonError.InvalidCredentials]) + fail must beLeft(beAnInstanceOf[CommonError.InvalidCredentials]) } } } diff --git a/modules/users-impl/src/it/scala/io/branchtalk/users/UsersFixtures.scala b/modules/users-impl/src/test/scala/io/branchtalk/users/UsersFixtures.scala similarity index 54% rename from modules/users-impl/src/it/scala/io/branchtalk/users/UsersFixtures.scala rename to modules/users-impl/src/test/scala/io/branchtalk/users/UsersFixtures.scala index e3d7fe06..fe1e2ac7 100644 --- a/modules/users-impl/src/it/scala/io/branchtalk/users/UsersFixtures.scala +++ b/modules/users-impl/src/test/scala/io/branchtalk/users/UsersFixtures.scala @@ -1,29 +1,29 @@ package io.branchtalk.users import cats.effect.{ Clock, IO } -import io.branchtalk.shared.model._ +import io.branchtalk.shared.model.* import io.branchtalk.users.model.{ Ban, Channel, Password, Session, User } -import io.branchtalk.shared.Fixtures._ +import io.branchtalk.shared.Fixtures.* import scala.util.Random trait UsersFixtures { - def channelIDCreate(implicit uuidGenerator: UUIDGenerator): IO[ID[Channel]] = + def channelIDCreate(using UUID.Generator): IO[ID[Channel]] = ID.create[IO, Channel] def passwordCreate(password: String = "pass"): IO[Password] = - Password.Raw.parse[IO](password.getBytes).map(Password.create) + ParseNewtype[IO].parse[Password.Raw](password.getBytes).map(Password.create) def userCreate: IO[User.Create] = ( - company().map(_.getEmail).map(e => s"${Random.nextLong()}+$e").flatMap(User.Email.parse[IO]), - textProducer.map(_.randomString(10)).flatMap(User.Name.parse[IO]), - textProducer.map(_.loremIpsum()).map(User.Description(_).some), + company().map(_.getEmail).map(e => s"${Random.nextLong()}+$e").flatMap(ParseNewtype[IO].parse[User.Email](_)), + textProducer.map(_.randomString(10)).flatMap(ParseNewtype[IO].parse[User.Name](_)), + textProducer.map(_.loremIpsum()).flatMap(ParseNewtype[IO].parse[User.Description](_)).map(_.some), passwordCreate() ).mapN(User.Create.apply) - def sessionCreate(userID: ID[User])(implicit clock: Clock[IO]): IO[Session.Create] = + def sessionCreate(userID: ID[User])(using Clock[IO]): IO[Session.Create] = ( userID.pure[IO], (Session.Usage.UserSession: Session.Usage).pure[IO], @@ -33,7 +33,7 @@ trait UsersFixtures { def banCreate(userID: ID[User], channelID: ID[Channel]): IO[Ban] = ( userID.pure[IO], - textProducer.map(_.loremIpsum()).flatMap(Ban.Reason.parse[IO]), + textProducer.map(_.loremIpsum()).flatMap(ParseNewtype[IO].parse[Ban.Reason](_)), Ban.Scope.ForChannel(channelID).pure[IO] ).mapN(Ban.apply _) } diff --git a/modules/users-impl/src/it/scala/io/branchtalk/users/UsersIOTest.scala b/modules/users-impl/src/test/scala/io/branchtalk/users/UsersIOTest.scala similarity index 61% rename from modules/users-impl/src/it/scala/io/branchtalk/users/UsersIOTest.scala rename to modules/users-impl/src/test/scala/io/branchtalk/users/UsersIOTest.scala index f5e0550b..d3ba4adc 100644 --- a/modules/users-impl/src/it/scala/io/branchtalk/users/UsersIOTest.scala +++ b/modules/users-impl/src/test/scala/io/branchtalk/users/UsersIOTest.scala @@ -1,18 +1,18 @@ package io.branchtalk.users import cats.effect.{ IO, Resource } -import io.branchtalk.shared.infrastructure.DomainConfig +import io.branchtalk.shared.infrastructure.* import io.branchtalk.{ IOTest, ResourcefulTest } -import io.branchtalk.shared.model.UUIDGenerator +import io.branchtalk.shared.model.UUID -trait UsersIOTest extends IOTest with ResourcefulTest { +trait UsersIOTest extends IOTest, ResourcefulTest { - implicit protected def uuidGenerator: UUIDGenerator + protected given uuidGenerator: UUID.Generator // populated by resources - protected var usersCfg: DomainConfig = _ - protected var usersReads: UsersReads[IO] = _ - protected var usersWrites: UsersWrites[IO] = _ + protected var usersCfg: DomainModule.Config = _ + protected var usersReads: UsersReads[IO] = _ + protected var usersWrites: UsersWrites[IO] = _ protected lazy val usersResource: Resource[IO, Unit] = for { _ <- TestUsersConfig.loadDomainConfig[IO].map(usersCfg = _) diff --git a/modules/users/src/main/scala/io/branchtalk/users/events/BanEvent.scala b/modules/users/src/main/scala/io/branchtalk/users/events/BanEvent.scala index 6a0801fe..5666c8a1 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/events/BanEvent.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/events/BanEvent.scala @@ -1,30 +1,28 @@ package io.branchtalk.users.events import com.sksamuel.avro4s.{ Decoder, Encoder, SchemaFor } -import io.branchtalk.ADT -import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import io.branchtalk.logging.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } import io.branchtalk.users.model.{ Ban, User } -import io.scalaland.catnip.Semi -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait BanEvent extends ADT +sealed trait BanEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object BanEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Banned( + final case class Banned( bannedUserID: ID[User], moderatorID: Option[ID[User]], scope: Ban.Scope, reason: Ban.Reason, createdAt: CreationTime, correlationID: CorrelationID - ) extends BanEvent + ) extends BanEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Unbanned( + final case class Unbanned( bannedUserID: ID[User], moderatorID: Option[ID[User]], scope: Ban.Scope, modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends BanEvent + ) extends BanEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/users/src/main/scala/io/branchtalk/users/events/SessionEvent.scala b/modules/users/src/main/scala/io/branchtalk/users/events/SessionEvent.scala index a71bb694..a2c5053c 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/events/SessionEvent.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/events/SessionEvent.scala @@ -1,26 +1,24 @@ package io.branchtalk.users.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT -import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import com.sksamuel.avro4s.* +import io.branchtalk.logging.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } import io.branchtalk.users.model.{ Session, User } -import io.scalaland.catnip.Semi -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait SessionEvent extends ADT +sealed trait SessionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object SessionEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class LoggedIn( + final case class LoggedIn( id: ID[Session], userID: ID[User], expiresAt: Session.ExpirationTime, correlationID: CorrelationID - ) extends SessionEvent + ) extends SessionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class LoggedOut( + final case class LoggedOut( id: ID[Session], userID: ID[User], correlationID: CorrelationID - ) extends SessionEvent + ) extends SessionEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/users/src/main/scala/io/branchtalk/users/events/UserEvent.scala b/modules/users/src/main/scala/io/branchtalk/users/events/UserEvent.scala index aac70009..72a528aa 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/events/UserEvent.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/events/UserEvent.scala @@ -1,20 +1,19 @@ package io.branchtalk.users.events -import com.sksamuel.avro4s._ -import io.branchtalk.ADT -import io.branchtalk.logging.CorrelationID -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ +import com.sksamuel.avro4s.* +import io.branchtalk.logging.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } import io.branchtalk.shared.model.AvroSerialization.DeserializationResult import io.branchtalk.users.model.{ Password, Permission, Session, User } -import io.scalaland.catnip.Semi -import io.scalaland.chimney.dsl._ +import io.scalaland.chimney.dsl.* +import io.scalaland.chimney.partial.syntax.* // user events doesn't store any data as they can be sensitive data -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait UserEvent extends ADT +sealed trait UserEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object UserEvent { - @Semi(FastEq, ShowPretty) final case class Created( + final case class Created( id: ID[User], sessionID: ID[Session], // session created by registration email: SensitiveData[User.Email], @@ -24,7 +23,8 @@ object UserEvent { sessionExpiresAt: Session.ExpirationTime, createdAt: CreationTime, correlationID: CorrelationID - ) { + ) derives FastEq, + ShowPretty { def encrypt( algorithm: SensitiveData.Algorithm, @@ -38,7 +38,7 @@ object UserEvent { } object Created { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Encrypted( + final case class Encrypted( id: ID[User], sessionID: ID[Session], // session created by registration email: SensitiveData.Encrypted[User.Email], @@ -48,21 +48,23 @@ object UserEvent { sessionExpiresAt: Session.ExpirationTime, createdAt: CreationTime, correlationID: CorrelationID - ) extends UserEvent { + ) extends UserEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor { def decrypt( algorithm: SensitiveData.Algorithm, key: SensitiveData.Key ): DeserializationResult[Created] = this - .intoF[DeserializationResult, Created] - .withFieldComputedF(_.email, _.email.decrypt(algorithm, key)) - .withFieldComputedF(_.username, _.username.decrypt(algorithm, key)) - .withFieldComputedF(_.password, _.password.decrypt(algorithm, key)) + .intoPartial[Created] + .withFieldComputedPartial(_.email, _.email.decrypt(algorithm, key).asResult) + .withFieldComputedPartial(_.username, _.username.decrypt(algorithm, key).asResult) + .withFieldComputedPartial(_.password, _.password.decrypt(algorithm, key).asResult) .transform + .asEither + .leftMap(e => DeserializationError.DecryptionError(this.show, e.asErrorPathMessageStrings.toMap)) } } - @Semi(FastEq, ShowPretty) final case class Updated( + final case class Updated( id: ID[User], moderatorID: Option[ID[User]], newUsername: Updatable[SensitiveData[User.Name]], @@ -71,7 +73,8 @@ object UserEvent { updatePermissions: List[Permission.Update], modifiedAt: ModificationTime, correlationID: CorrelationID - ) { + ) derives FastEq, + ShowPretty { def encrypt( algorithm: SensitiveData.Algorithm, @@ -84,7 +87,7 @@ object UserEvent { } object Updated { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Encrypted( + final case class Encrypted( id: ID[User], moderatorID: Option[ID[User]], newUsername: Updatable[SensitiveData.Encrypted[User.Name]], @@ -93,23 +96,25 @@ object UserEvent { updatePermissions: List[Permission.Update], modifiedAt: ModificationTime, correlationID: CorrelationID - ) extends UserEvent { + ) extends UserEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor { def decrypt( algorithm: SensitiveData.Algorithm, key: SensitiveData.Key ): DeserializationResult[Updated] = this - .intoF[DeserializationResult, Updated] - .withFieldComputedF(_.newUsername, _.newUsername.traverse(_.decrypt(algorithm, key))) - .withFieldComputedF(_.newPassword, _.newPassword.traverse(_.decrypt(algorithm, key))) + .intoPartial[Updated] + .withFieldComputedPartial(_.newUsername, _.newUsername.traverse(_.decrypt(algorithm, key)).asResult) + .withFieldComputedPartial(_.newPassword, _.newPassword.traverse(_.decrypt(algorithm, key)).asResult) .transform + .asEither + .leftMap(e => DeserializationError.DecryptionError(this.show, e.asErrorPathMessageStrings.toMap)) } } - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class Deleted( + final case class Deleted( id: ID[User], moderatorID: Option[ID[User]], deletedAt: ModificationTime, correlationID: CorrelationID - ) extends UserEvent + ) extends UserEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/users/src/main/scala/io/branchtalk/users/events/UsersEvent.scala b/modules/users/src/main/scala/io/branchtalk/users/events/UsersEvent.scala index bedd4b44..134a7b6c 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/events/UsersEvent.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/events/UsersEvent.scala @@ -1,20 +1,24 @@ package io.branchtalk.users.events -import com.sksamuel.avro4s._ -import io.branchtalk.shared.model._ -import io.branchtalk.shared.model.AvroSupport._ -import io.branchtalk.ADT -import io.scalaland.catnip.Semi +import com.sksamuel.avro4s.* +import io.branchtalk.shared.model.* +import io.branchtalk.shared.model.AvroSupport.{ *, given } -@Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) sealed trait UsersEvent extends ADT +sealed trait UsersEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor object UsersEvent { - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) - final case class ForUser(user: UserEvent) extends UsersEvent + final case class ForUser( + user: UserEvent + ) extends UsersEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) final case class ForSession(session: SessionEvent) extends UsersEvent + derives Decoder, + Encoder, + FastEq, + ShowPretty, + SchemaFor - @Semi(Decoder, Encoder, FastEq, ShowPretty, SchemaFor) - final case class ForBan(ban: BanEvent) extends UsersEvent + final case class ForBan( + ban: BanEvent + ) extends UsersEvent derives Decoder, Encoder, FastEq, ShowPretty, SchemaFor } diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/Ban.scala b/modules/users/src/main/scala/io/branchtalk/users/model/Ban.scala index 0271a7c2..186fe02c 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/model/Ban.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/model/Ban.scala @@ -1,6 +1,70 @@ package io.branchtalk.users.model -import io.branchtalk.shared.model.ID +import cats.Show +import cats.effect.Sync +import enumeratum.* +import enumeratum.EnumEntry.Hyphencase +import io.branchtalk.shared.model.* -final case class Ban(bannedUserID: ID[User], reason: Ban.Reason, scope: Ban.Scope) -object Ban extends BanProperties with BanCommands +final case class Ban( + bannedUserID: ID[User], + reason: Ban.Reason, + scope: Ban.Scope +) derives FastEq, + ShowPretty +object Ban { + + final case class Order( + bannedUserID: ID[User], + reason: Ban.Reason, + scope: Ban.Scope, + moderatorID: Option[ID[User]] + ) derives FastEq, + ShowPretty + + final case class Lift( + bannedUserID: ID[User], + scope: Ban.Scope, + moderatorID: Option[ID[User]] + ) derives FastEq, + ShowPretty + + type Reason = Reason.Type + object Reason extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(reason: Reason): Some[String] = Some(reason.unwrap) + + given Show[Reason] = unsafeMakeF[Show](Show[String]) + given cats.Order[Reason] = unsafeMakeF[cats.Order](cats.Order[String]) + } + + enum Scope derives FastEq, ShowPretty { + case ForChannel(channelID: ID[Channel]) + case Globally + } + object Scope { + + enum Type extends EnumEntry with Hyphencase derives FastEq, ShowPretty { + case ForChannel + case Globally + } + + object Tupled { + @SuppressWarnings(Array("org.wartremover.warts.Throw")) // illegal input from the DB + def apply(scopeType: Type, scopeValue: Option[UUID]): Scope = (scopeType, scopeValue) match { + case (Type.ForChannel, Some(uuid)) => Scope.ForChannel(ID[Channel](uuid)) + case (Type.Globally, _) => Scope.Globally + case _ => throw new IllegalArgumentException("Expected ID for non-Global Scope") + } + + def unpack(scope: Scope): (Type, Option[UUID]) = scope match { + case ForChannel(channelID) => (Type.ForChannel, channelID.unwrap.some) + case Globally => (Type.Globally, none) + } + + def unapply(scope: Scope): Some[(Type, Option[UUID])] = Some(unpack(scope)) + } + } +} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/BanCommands.scala b/modules/users/src/main/scala/io/branchtalk/users/model/BanCommands.scala deleted file mode 100644 index 6726ce68..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/BanCommands.scala +++ /dev/null @@ -1,26 +0,0 @@ -package io.branchtalk.users.model - -import io.branchtalk.shared.model.{ FastEq, ID, ShowPretty } -import io.scalaland.catnip.Semi - -trait BanCommands { - type Order = BanCommands.Order - type Lift = BanCommands.Lift - val Order = BanCommands.Order - val Lift = BanCommands.Lift -} -object BanCommands { - - @Semi(FastEq, ShowPretty) final case class Order( - bannedUserID: ID[User], - reason: Ban.Reason, - scope: Ban.Scope, - moderatorID: Option[ID[User]] - ) - - @Semi(FastEq, ShowPretty) final case class Lift( - bannedUserID: ID[User], - scope: Ban.Scope, - moderatorID: Option[ID[User]] - ) -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/BanProperties.scala b/modules/users/src/main/scala/io/branchtalk/users/model/BanProperties.scala deleted file mode 100644 index 5962e194..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/BanProperties.scala +++ /dev/null @@ -1,60 +0,0 @@ -package io.branchtalk.users.model - -import cats.{ Order, Show } -import cats.effect.Sync -import enumeratum._ -import enumeratum.EnumEntry.Hyphencase -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.ADT -import io.branchtalk.shared.model._ -import io.estatico.newtype.macros.newtype -import io.scalaland.catnip.Semi - -trait BanProperties { - type Reason = BanProperties.Reason - type Scope = BanProperties.Scope - val Reason = BanProperties.Reason - val Scope = BanProperties.Scope -} -object BanProperties { - - @newtype final case class Reason(nonEmptyString: NonEmptyString) - object Reason { - def unapply(reason: Reason): Some[NonEmptyString] = Some(reason.nonEmptyString) - def parse[F[_]: Sync](string: String): F[Reason] = ParseRefined[F].parse[NonEmpty](string).map(Reason.apply) - - implicit val show: Show[Reason] = Show.wrap(_.nonEmptyString.value) - implicit val order: Order[Reason] = Order.by(_.nonEmptyString.value) - } - - @Semi(FastEq, ShowPretty) sealed trait Scope extends ADT - object Scope { - final case class ForChannel(channelID: ID[Channel]) extends Scope - case object Globally extends Scope - - @Semi(FastEq, ShowPretty) sealed trait Type extends EnumEntry with Hyphencase - object Type extends Enum[Type] { - case object ForChannel extends Type - case object Globally extends Type - - val values: IndexedSeq[Type] = findValues - } - - object Tupled { - @SuppressWarnings(Array("org.wartremover.warts.Throw")) // illegal input from the DB - def apply(scopeType: Type, scopeValue: Option[UUID]): Scope = (scopeType, scopeValue) match { - case (Type.ForChannel, Some(uuid)) => Scope.ForChannel(ID[Channel](uuid)) - case (Type.Globally, _) => Scope.Globally - case _ => throw new IllegalArgumentException("Expected ID for non-Global Scope") - } - - def unpack(scope: Scope): (Type, Option[UUID]) = scope match { - case ForChannel(channelID) => (Type.ForChannel, channelID.uuid.some) - case Globally => (Type.Globally, none) - } - - def unapply(scope: Scope): Some[(Type, Option[UUID])] = Some(unpack(scope)) - } - } -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/Password.scala b/modules/users/src/main/scala/io/branchtalk/users/model/Password.scala index e36510f1..3ce2b615 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/model/Password.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/model/Password.scala @@ -6,17 +6,14 @@ import cats.{ Eq, Show } import cats.effect.{ Sync, SyncIO } import enumeratum.{ Enum, EnumEntry } import enumeratum.EnumEntry.Hyphencase -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import io.branchtalk.shared.model._ -import io.estatico.newtype.macros.newtype -import io.scalaland.catnip.Semi +import io.branchtalk.shared.model.* -@Semi(FastEq, ShowPretty) final case class Password( +final case class Password( algorithm: Password.Algorithm, hash: Password.Hash, salt: Password.Salt -) { +) derives FastEq, + ShowPretty { def update(raw: Password.Raw): Password = copy(hash = algorithm.hashRaw(raw, salt)) def verify(raw: Password.Raw): Boolean = algorithm.verify(raw, salt, hash) @@ -24,20 +21,18 @@ import io.scalaland.catnip.Semi // allows comparison of Passwords which would otherwise use Array's hashCode method override def equals(other: Any): Boolean = other match { - case Password(`algorithm`, otherHash, otherSalt) - if hash.bytes.sameElements(otherHash.bytes) && salt.bytes.sameElements(otherSalt.bytes) => - true - case _ => false + case Password(`algorithm`, otherHash, otherSalt) => hash === otherHash && salt === otherSalt + case _ => false } - override def hashCode(): Int = algorithm.hashCode() ^ hash.bytes.toSeq.hashCode() ^ salt.bytes.toSeq.hashCode() + override def hashCode(): Int = algorithm.hashCode() ^ hash.unwrap.toSeq.hashCode() ^ salt.unwrap.toSeq.hashCode() } object Password { - @Semi(FastEq, ShowPretty) sealed trait Algorithm extends EnumEntry with Hyphencase { + sealed trait Algorithm extends EnumEntry with Hyphencase derives FastEq, ShowPretty { - def createSalt: Password.Salt - def hashRaw(raw: Password.Raw, salt: Password.Salt): Password.Hash + def createSalt: Password.Salt + def hashRaw(raw: Password.Raw, salt: Password.Salt): Password.Hash def verify(raw: Password.Raw, salt: Password.Salt, hash: Password.Hash): Boolean } object Algorithm extends Enum[Algorithm] { @@ -58,10 +53,10 @@ object Password { } override def hashRaw(raw: Password.Raw, salt: Password.Salt): Password.Hash = - Password.Hash(hasher.hashRaw(cost, salt.bytes, raw.nonEmptyBytes).rawHash) + Password.Hash(hasher.hashRaw(cost, salt.unwrap, raw.unwrap).rawHash) override def verify(raw: Password.Raw, salt: Password.Salt, hash: Password.Hash): Boolean = - verifier.verify(raw.nonEmptyBytes, cost, salt.bytes, hash.bytes).verified + verifier.verify(raw.unwrap, cost, salt.unwrap, hash.unwrap).verified } def default: Algorithm = BCrypt @@ -69,33 +64,34 @@ object Password { val values: IndexedSeq[Algorithm] = findValues } - @newtype final case class Hash(bytes: Array[Byte]) - object Hash { - def unapply(hash: Hash): Some[Array[Byte]] = Some(hash.bytes) + private val arrayEq: Eq[Array[Byte]] = _ sameElements _ - implicit val show: Show[Hash] = Show.wrap(_ => "EDITED OUT") - implicit val eq: Eq[Hash] = _.bytes sameElements _.bytes + type Hash = Hash.Type + object Hash extends Newtype[Array[Byte]] { + def unapply(hash: Hash): Some[Array[Byte]] = Some(hash.unwrap) + + given Show[Hash] = _ => "EDITED OUT" + given Eq[Hash] = unsafeMakeF[Eq](arrayEq) } - @newtype final case class Salt(bytes: Array[Byte]) - object Salt { - def unapply(salt: Salt): Some[Array[Byte]] = Some(salt.bytes) + type Salt = Salt.Type + object Salt extends Newtype[Array[Byte]] { + def unapply(salt: Salt): Some[Array[Byte]] = Some(salt.unwrap) - implicit val show: Show[Salt] = Show.wrap(_ => "EDITED OUT") - implicit val eq: Eq[Salt] = _.bytes sameElements _.bytes + given Show[Salt] = _ => "EDITED OUT" + given Eq[Salt] = unsafeMakeF[Eq](arrayEq) } - @newtype final case class Raw(nonEmptyBytes: Array[Byte] Refined NonEmpty) - object Raw { - def unapply(raw: Raw): Some[Array[Byte] Refined NonEmpty] = Some(raw.nonEmptyBytes) - def parse[F[_]: Sync](bytes: Array[Byte]): F[Raw] = - ParseRefined[F].parse[NonEmpty](bytes).map(Raw.apply) + type Raw = Raw.Type + object Raw extends Newtype[Array[Byte]] { + override inline def validate(input: Array[Byte]): Boolean = input.nonEmpty + + def unapply(hash: Raw): Some[Array[Byte]] = Some(hash.unwrap) - def fromString(string: String Refined NonEmpty): Raw = - Raw(ParseRefined[SyncIO].parse[NonEmpty](string.getBytes(branchtalkCharset)).unsafeRunSync()) + def fromString(string: String): Either[String, Raw] = make(string.getBytes(branchtalkCharset)) - implicit val show: Show[Raw] = Show.wrap(_ => "EDITED OUT") - implicit val eq: Eq[Raw] = _.nonEmptyBytes.value sameElements _.nonEmptyBytes.value + given Show[Raw] = _ => "EDITED OUT" + given Eq[Raw] = unsafeMakeF[Eq](arrayEq) } def create(raw: Password.Raw): Password = { diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/Permission.scala b/modules/users/src/main/scala/io/branchtalk/users/model/Permission.scala index ad276c99..d768a3f9 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/model/Permission.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/model/Permission.scala @@ -1,20 +1,23 @@ package io.branchtalk.users.model import cats.Order -import io.branchtalk.ADT -import io.branchtalk.shared.model.{ ID, ShowPretty } -import io.scalaland.catnip.Semi +import io.branchtalk.shared.model.* -@Semi(ShowPretty) sealed trait Permission extends ADT -object Permission extends PermissionCommands { +enum Permission derives ShowPretty { + case Administrate + case IsUser(userID: ID[User]) + case ModerateUsers + case ModerateChannel(channelID: ID[Channel]) + case CanPublish(channelID: ID[Channel]) +} +object Permission { - case object Administrate extends Permission - final case class IsUser(userID: ID[User]) extends Permission - case object ModerateUsers extends Permission - final case class ModerateChannel(channelID: ID[Channel]) extends Permission - final case class CanPublish(channelID: ID[Channel]) extends Permission + enum Update derives FastEq, ShowPretty { + case Add(permission: Permission) + case Remove(permission: Permission) + } - implicit val order: Order[Permission] = { + given Order[Permission] = { case (Administrate, Administrate) => 0 case (Administrate, _) => 1 case (IsUser(u1), IsUser(u2)) => Order[ID[User]].compare(u1, u2) diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/PermissionCommands.scala b/modules/users/src/main/scala/io/branchtalk/users/model/PermissionCommands.scala deleted file mode 100644 index 8b99aca7..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/PermissionCommands.scala +++ /dev/null @@ -1,18 +0,0 @@ -package io.branchtalk.users.model - -import io.branchtalk.ADT -import io.branchtalk.shared.model.{ FastEq, ShowPretty } -import io.scalaland.catnip.Semi - -trait PermissionCommands { - type Update = PermissionCommands.Update - val Update = PermissionCommands.Update -} -object PermissionCommands { - - @Semi(FastEq, ShowPretty) sealed trait Update extends ADT - object Update { - final case class Add(permission: Permission) extends Update - final case class Remove(permission: Permission) extends Update - } -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/Permissions.scala b/modules/users/src/main/scala/io/branchtalk/users/model/Permissions.scala new file mode 100644 index 00000000..4d680ccf --- /dev/null +++ b/modules/users/src/main/scala/io/branchtalk/users/model/Permissions.scala @@ -0,0 +1,36 @@ +package io.branchtalk.users.model + +import cats.{ Eq, Eval, Show } + +type Permissions = Permissions.Type +object Permissions extends Newtype[Set[Permission]] { + def unapply(permissions: Permissions): Some[Set[Permission]] = Some(permissions.unwrap) + + def empty: Permissions = Permissions(Set.empty) + + @SuppressWarnings(Array("org.wartremover.warts.All")) // Eval should be stack-safe + def validatePermissions(required: RequiredPermissions, existing: Permissions): Boolean = { + def permitted(permission: Permission) = existing.unwrap.contains(permission) + def evaluate(req: RequiredPermissions): Eval[Boolean] = Eval.later(req).flatMap { + case RequiredPermissions.Empty => Eval.True + case RequiredPermissions.AllOf(set) => Eval.later(set.forall(permitted)) + case RequiredPermissions.AnyOf(set) => Eval.later(set.exists(permitted)) + case RequiredPermissions.And(x, y) => (evaluate(x), evaluate(y)).mapN(_ && _) + case RequiredPermissions.Or(x, y) => (evaluate(x), evaluate(y)).mapN(_ || _) + case RequiredPermissions.Not(x) => evaluate(x).map(!_) + } + evaluate(required).value + } + + extension (permissions: Permissions) { + + def append(permission: Permission): Permissions = unsafeMake(permissions.unwrap + permission) + def remove(permission: Permission): Permissions = unsafeMake(permissions.unwrap - permission) + + def allow(required: RequiredPermissions): Boolean = validatePermissions(required, permissions) + def intersect(another: Permissions): Permissions = unsafeMake(permissions.unwrap intersect another.unwrap) + } + + given Show[Permissions] = unsafeMakeF[Show](Show[Set[Permission]]) + given Eq[Permissions] = unsafeMakeF[Eq](Eq[Set[Permission]]) +} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/RequiredPermissions.scala b/modules/users/src/main/scala/io/branchtalk/users/model/RequiredPermissions.scala index f04ae509..7d704083 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/model/RequiredPermissions.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/model/RequiredPermissions.scala @@ -2,51 +2,49 @@ package io.branchtalk.users.model import cats.Eq import cats.data.{ NonEmptyList, NonEmptySet } -import io.branchtalk.ADT -import io.branchtalk.shared.model.{ FastEq, ShowPretty } -import io.scalaland.catnip.Semi +import io.branchtalk.shared.model.* -@Semi(FastEq, ShowPretty) sealed trait RequiredPermissions extends ADT { +import scala.annotation.targetName - // scalastyle:off method.name - def &&(other: RequiredPermissions): RequiredPermissions = RequiredPermissions.And(this, other) - def ||(other: RequiredPermissions): RequiredPermissions = RequiredPermissions.Or(this, other) - def unary_! : RequiredPermissions = RequiredPermissions.Not(this) - // scalastyle:on method.name +enum RequiredPermissions derives FastEq, ShowPretty { + case Empty + + case AllOf(toSet: NonEmptySet[Permission]) + case AnyOf(toSet: NonEmptySet[Permission]) + + case And(x: RequiredPermissions, y: RequiredPermissions) + case Or(x: RequiredPermissions, y: RequiredPermissions) + case Not(x: RequiredPermissions) + + @targetName("and") def &&(other: RequiredPermissions): RequiredPermissions = And(this, other) + @targetName("or") def ||(other: RequiredPermissions): RequiredPermissions = Or(this, other) + @targetName("not") def unary_! : RequiredPermissions = Not(this) } object RequiredPermissions { - def empty: RequiredPermissions = Empty - def one(permission: Permission): RequiredPermissions = AllOf(NonEmptySet.one(permission)) + def empty: RequiredPermissions = Empty + def one(permission: Permission): RequiredPermissions = AllOf(NonEmptySet.one(permission)) def allOf(head: Permission, tail: Permission*): RequiredPermissions = AllOf(NonEmptySet.of(head, tail: _*)) def anyOf(head: Permission, tail: Permission*): RequiredPermissions = AnyOf(NonEmptySet.of(head, tail: _*)) - case object Empty extends RequiredPermissions - - final case class AllOf(toSet: NonEmptySet[Permission]) extends RequiredPermissions - final case class AnyOf(toSet: NonEmptySet[Permission]) extends RequiredPermissions - - final case class And(x: RequiredPermissions, y: RequiredPermissions) extends RequiredPermissions - final case class Or(x: RequiredPermissions, y: RequiredPermissions) extends RequiredPermissions - final case class Not(x: RequiredPermissions) extends RequiredPermissions - - implicit val nesEq: Eq[NonEmptySet[Permission]] = (x: NonEmptySet[Permission], y: NonEmptySet[Permission]) => + given Eq[NonEmptySet[Permission]] = (x: NonEmptySet[Permission], y: NonEmptySet[Permission]) => x.toSortedSet === y.toSortedSet - implicit val nesShow: ShowPretty[NonEmptySet[Permission]] = - (t: NonEmptySet[Permission], sb: StringBuilder, indentWith: String, indentLevel: Int) => { + @SuppressWarnings(Array("org.wartremover.warts.MutableDataStructures")) + given ShowPretty[NonEmptySet[Permission]] = { + (t: NonEmptySet[Permission], sb: StringBuilder, indentWith: String, indentLevel: Int) => val nextIndent = indentLevel + 1 - sb.append(indentWith * indentLevel).append("NonEmptySet(\n") + void(sb.append(indentWith * indentLevel).append("NonEmptySet(\n")) t.toNonEmptyList match { case NonEmptyList(head, tail) => sb.append(indentWith * nextIndent) - implicitly[ShowPretty[Permission]].showPretty(head, sb, indentWith, nextIndent) + void(summon[ShowPretty[Permission]].showPretty(head, sb, indentWith, nextIndent)) tail.foreach { elem => sb.append(",\n") sb.append(indentWith * nextIndent) - implicitly[ShowPretty[Permission]].showPretty(elem, sb, indentWith, nextIndent) + summon[ShowPretty[Permission]].showPretty(elem, sb, indentWith, nextIndent) } sb.append("\n)") } sb - } + } } diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/Session.scala b/modules/users/src/main/scala/io/branchtalk/users/model/Session.scala index d7f17888..27461132 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/model/Session.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/model/Session.scala @@ -1,17 +1,87 @@ package io.branchtalk.users.model -import io.branchtalk.shared.model.{ FastEq, ID, ShowPretty } -import io.scalaland.catnip.Semi +import java.time.{ Instant, OffsetDateTime, ZoneId } +import java.time.format.DateTimeFormatter -@Semi(FastEq, ShowPretty) final case class Session( +import cats.{ Functor, Order, Show } +import cats.effect.Clock +import enumeratum.{ Enum, EnumEntry } +import enumeratum.EnumEntry.Hyphencase +import io.branchtalk.shared.model.* + +final case class Session( id: ID[Session], data: Session.Data -) -object Session extends SessionProperties with SessionCommands { +) derives FastEq, + ShowPretty +object Session { + + final case class Data( + userID: ID[User], + usage: Session.Usage, + expiresAt: Session.ExpirationTime + ) derives FastEq, + ShowPretty - @Semi(FastEq, ShowPretty) final case class Data( + final case class Create( userID: ID[User], usage: Session.Usage, expiresAt: Session.ExpirationTime - ) + ) derives FastEq, + ShowPretty + + final case class Delete( + id: ID[Session] + ) derives FastEq, + ShowPretty + + type ExpirationTime = ExpirationTime.Type + object ExpirationTime extends Newtype[OffsetDateTime] { + + def unapply(expirationTime: ExpirationTime): Some[OffsetDateTime] = Some(expirationTime.unwrap) + + def now[F[_]: Functor: Clock]: F[ExpirationTime] = + Clock[F].realTime + .map(_.toMillis) + .map(Instant.ofEpochMilli) + .map(OffsetDateTime.ofInstant(_, ZoneId.systemDefault())) + .pipe(unsafeMakeF) + + given Show[ExpirationTime] = _.unwrap.pipe(DateTimeFormatter.ISO_INSTANT.format) + given Order[ExpirationTime] = Order.by[ExpirationTime, OffsetDateTime](_.unwrap)(Order.fromComparable) + + extension (time: ExpirationTime) { + def plusDays(days: Long): ExpirationTime = ExpirationTime(time.unwrap.plusDays(days)) + } + } + + enum Usage derives FastEq, ShowPretty { + case UserSession + case OAuth(permissions: Permissions) + } + object Usage { + + enum Type extends EnumEntry with Hyphencase derives FastEq, ShowPretty { + case UserSession + case OAuth + } + + object Tupled { + def apply(usageType: Type, usagePermissions: Permissions): Usage = usageType match { + case Type.UserSession => Usage.UserSession + case Type.OAuth => Usage.OAuth(usagePermissions) + } + + def unpack(usage: Usage): (Type, Permissions) = usage match { + case UserSession => (Type.UserSession, Permissions(Set.empty)) + case OAuth(permissions) => (Type.OAuth, permissions) + } + + def unapply(usage: Usage): Some[(Type, Permissions)] = Some(unpack(usage)) + } + } + + enum Sorting derives FastEq, ShowPretty { + case ClosestToExpiry + } } diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/SessionCommands.scala b/modules/users/src/main/scala/io/branchtalk/users/model/SessionCommands.scala deleted file mode 100644 index 3ea0421d..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/SessionCommands.scala +++ /dev/null @@ -1,23 +0,0 @@ -package io.branchtalk.users.model - -import io.branchtalk.shared.model.{ FastEq, ID, ShowPretty } -import io.scalaland.catnip.Semi - -trait SessionCommands { - type Create = SessionCommands.Create - type Delete = SessionCommands.Delete - val Create = SessionCommands.Create - val Delete = SessionCommands.Delete -} -object SessionCommands { - - @Semi(FastEq, ShowPretty) final case class Create( - userID: ID[User], - usage: Session.Usage, - expiresAt: Session.ExpirationTime - ) - - @Semi(FastEq, ShowPretty) final case class Delete( - id: ID[Session] - ) -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/SessionProperties.scala b/modules/users/src/main/scala/io/branchtalk/users/model/SessionProperties.scala deleted file mode 100644 index f42e5f3f..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/SessionProperties.scala +++ /dev/null @@ -1,80 +0,0 @@ -package io.branchtalk.users.model - -import java.time.{ Instant, OffsetDateTime, ZoneId } -import java.time.format.DateTimeFormatter - -import cats.{ Functor, Order, Show } -import cats.effect.Clock -import enumeratum.{ Enum, EnumEntry } -import enumeratum.EnumEntry.Hyphencase -import io.branchtalk.ADT -import io.branchtalk.shared.model._ -import io.estatico.newtype.macros.newtype -import io.scalaland.catnip.Semi - -trait SessionProperties { - type ExpirationTime = SessionProperties.ExpirationTime - type Usage = SessionProperties.Usage - type Sorting = SessionProperties.Sorting - val ExpirationTime = SessionProperties.ExpirationTime - val Usage = SessionProperties.Usage - val Sorting = SessionProperties.Sorting -} -object SessionProperties { - - @newtype final case class ExpirationTime(offsetDateTime: OffsetDateTime) { - - def plusDays(days: Long): ExpirationTime = ExpirationTime(offsetDateTime.plusDays(days)) - } - - object ExpirationTime { - def unapply(expirationTime: ExpirationTime): Some[OffsetDateTime] = Some(expirationTime.offsetDateTime) - - def now[F[_]: Functor: Clock]: F[ExpirationTime] = - Clock[F].realTime - .map(_.toMillis) - .map(Instant.ofEpochMilli) - .map(OffsetDateTime.ofInstant(_, ZoneId.systemDefault())) - .map(ExpirationTime(_)) - - implicit val show: Show[ExpirationTime] = Show.wrap(_.offsetDateTime.pipe(DateTimeFormatter.ISO_INSTANT.format)) - implicit val order: Order[ExpirationTime] = - Order.by[ExpirationTime, OffsetDateTime](_.offsetDateTime)(Order.fromComparable) - } - - @Semi(FastEq, ShowPretty) sealed trait Usage extends ADT - - object Usage { - case object UserSession extends Usage - final case class OAuth(permissions: Permissions) extends Usage - - @Semi(FastEq, ShowPretty) sealed trait Type extends EnumEntry with Hyphencase - object Type extends Enum[Type] { - case object UserSession extends Type - case object OAuth extends Type - - val values: IndexedSeq[Type] = findValues - } - - object Tupled { - def apply(usageType: Type, usagePermissions: Permissions): Usage = usageType match { - case Type.UserSession => Usage.UserSession - case Type.OAuth => Usage.OAuth(usagePermissions) - } - - def unpack(usage: Usage): (Type, Permissions) = usage match { - case UserSession => (Type.UserSession, Permissions(Set.empty)) - case OAuth(permissions) => (Type.OAuth, permissions) - } - - def unapply(usage: Usage): Some[(Type, Permissions)] = Some(unpack(usage)) - } - } - - sealed trait Sorting extends EnumEntry - object Sorting extends Enum[Sorting] { - case object ClosestToExpiry extends Sorting - - val values = findValues - } -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/User.scala b/modules/users/src/main/scala/io/branchtalk/users/model/User.scala index 0a97274c..f9ffe058 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/model/User.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/model/User.scala @@ -1,15 +1,17 @@ package io.branchtalk.users.model -import io.branchtalk.shared.model.{ CreationTime, FastEq, ID, ModificationTime, ShowPretty } -import io.scalaland.catnip.Semi +import cats.{ Order, Show } +import cats.effect.Sync +import io.branchtalk.shared.model.* -@Semi(FastEq, ShowPretty) final case class User( +final case class User( id: ID[User], data: User.Data -) -object User extends UserProperties with UserCommands { +) derives FastEq, + ShowPretty +object User { - @Semi(FastEq, ShowPretty) final case class Data( + final case class Data( email: User.Email, // validate email username: User.Name, description: Option[User.Description], @@ -17,5 +19,82 @@ object User extends UserProperties with UserCommands { permissions: Permissions, createdAt: CreationTime, lastModifiedAt: Option[ModificationTime] - ) + ) derives FastEq, + ShowPretty + + final case class Create( + email: User.Email, + username: User.Name, + description: Option[User.Description], + password: Password + ) derives FastEq, + ShowPretty + + final case class Update( + id: ID[User], + moderatorID: Option[ID[User]], + newUsername: Updatable[User.Name], + newDescription: OptionUpdatable[User.Description], + newPassword: Updatable[Password], + updatePermissions: List[Permission.Update] + ) derives FastEq, + ShowPretty + + final case class Delete( + id: ID[User], + moderatorID: Option[ID[User]] + ) derives FastEq, + ShowPretty + + final case class Restore( + id: ID[User], + moderatorID: Option[ID[User]] + ) derives FastEq, + ShowPretty + + type Email = Email.Type + object Email extends Newtype[String] { + + private val pattern = "(.+)@(.+)".r + + override inline def validate(input: String): Boolean = pattern.matches(input) + + def unapply(email: Email): Some[String] = Some(email.unwrap) + + given Show[Email] = unsafeMakeF[Show](Show[String]) + given Order[Email] = unsafeMakeF[Order](Order[String]) + } + + type Name = Name.Type + object Name extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(name: Name): Some[String] = Some(name.unwrap) + + given Show[Name] = unsafeMakeF[Show](Show[String]) + given Order[Name] = unsafeMakeF[Order](Order[String]) + } + + type Description = Description.Type + object Description extends Newtype[String] { + + override inline def validate(input: String): Boolean = input.nonEmpty + + def unapply(description: Description): Some[String] = Some(description.unwrap) + + given Show[Description] = unsafeMakeF[Show](Show[String]) + given Order[Description] = unsafeMakeF[Order](Order[String]) + } + + enum Filter { + case HasPermission(permission: Permission) + case HasPermissions(permissions: Permissions) + } + + enum Sorting derives FastEq, ShowPretty { + case Newest + case NameAlphabetically + case EmailAlphabetically + } } diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/UserCommands.scala b/modules/users/src/main/scala/io/branchtalk/users/model/UserCommands.scala deleted file mode 100644 index 7da82b8f..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/UserCommands.scala +++ /dev/null @@ -1,43 +0,0 @@ -package io.branchtalk.users.model - -import io.branchtalk.shared.model.{ FastEq, ID, OptionUpdatable, ShowPretty, Updatable } -import io.scalaland.catnip.Semi - -trait UserCommands { - type Create = UserCommands.Create - type Update = UserCommands.Update - type Delete = UserCommands.Delete - type Restore = UserCommands.Restore - val Create = UserCommands.Create - val Update = UserCommands.Update - val Delete = UserCommands.Delete - val Restore = UserCommands.Restore -} -object UserCommands { - - @Semi(FastEq, ShowPretty) final case class Create( - email: User.Email, - username: User.Name, - description: Option[User.Description], - password: Password - ) - - @Semi(FastEq, ShowPretty) final case class Update( - id: ID[User], - moderatorID: Option[ID[User]], - newUsername: Updatable[User.Name], - newDescription: OptionUpdatable[User.Description], - newPassword: Updatable[Password], - updatePermissions: List[Permission.Update] - ) - - @Semi(FastEq, ShowPretty) final case class Delete( - id: ID[User], - moderatorID: Option[ID[User]] - ) - - @Semi(FastEq, ShowPretty) final case class Restore( - id: ID[User], - moderatorID: Option[ID[User]] - ) -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/UserProperties.scala b/modules/users/src/main/scala/io/branchtalk/users/model/UserProperties.scala deleted file mode 100644 index 3d2d1a89..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/UserProperties.scala +++ /dev/null @@ -1,73 +0,0 @@ -package io.branchtalk.users.model - -import cats.{ Order, Show } -import cats.effect.Sync -import enumeratum.{ Enum, EnumEntry } -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.string.MatchesRegex -import eu.timepit.refined.types.string.NonEmptyString -import io.branchtalk.ADT -import io.branchtalk.shared.model._ -import io.estatico.newtype.macros.newtype -import io.estatico.newtype.ops._ - -trait UserProperties { - type Email = UserProperties.Email - type Name = UserProperties.Name - type Description = UserProperties.Description - type Filter = UserProperties.Filter - type Sorting = UserProperties.Sorting - val Email = UserProperties.Email - val Name = UserProperties.Name - val Description = UserProperties.Description - val Filter = UserProperties.Filter - val Sorting = UserProperties.Sorting -} -object UserProperties { - - @newtype final case class Email(emailString: String Refined MatchesRegex[Email.Pattern]) - object Email { - type Pattern = "(.+)@(.+)" - - def unapply(email: Email): Some[String Refined MatchesRegex[Email.Pattern]] = Some(email.emailString) - def parse[F[_]: Sync](string: String): F[Email] = - ParseRefined[F].parse[MatchesRegex[Email.Pattern]](string).map(Email.apply) - - implicit val show: Show[Email] = Show.wrap(_.emailString.value) - implicit val order: Order[Email] = Order.by(_.emailString.value) - } - - @newtype final case class Name(nonEmptyString: NonEmptyString) - object Name { - def unapply(name: Name): Some[NonEmptyString] = Some(name.nonEmptyString) - def parse[F[_]: Sync](string: String): F[Name] = - ParseRefined[F].parse[NonEmpty](string).map(Name.apply) - - implicit val show: Show[Name] = Show.wrap(_.nonEmptyString.value) - implicit val order: Order[Name] = Order.by(_.nonEmptyString.value) - } - - @newtype final case class Description(string: String) - object Description { - def unapply(description: Description): Some[String] = Some(description.string) - - implicit val show: Show[Description] = Show.wrap(_.string) - implicit val eq: Order[Description] = Order[String].coerce - } - - sealed trait Filter extends ADT - object Filter { - final case class HasPermission(permission: Permission) extends Filter - final case class HasPermissions(permissions: Permissions) extends Filter - } - - sealed trait Sorting extends EnumEntry - object Sorting extends Enum[Sorting] { - case object Newest extends Sorting - case object NameAlphabetically extends Sorting - case object EmailAlphabetically extends Sorting - - val values = findValues - } -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/model/package.scala b/modules/users/src/main/scala/io/branchtalk/users/model/package.scala deleted file mode 100644 index fab10fcd..00000000 --- a/modules/users/src/main/scala/io/branchtalk/users/model/package.scala +++ /dev/null @@ -1,39 +0,0 @@ -package io.branchtalk.users - -import cats.{ Eq, Eval, Show } -import io.branchtalk.shared.model.ShowCompanionOps -import io.estatico.newtype.macros.newtype - -package object model { - - @newtype final case class Permissions(set: Set[Permission]) { - - def append(permission: Permission): Permissions = Permissions(set + permission) - def remove(permission: Permission): Permissions = Permissions(set - permission) - - def allow(permissions: RequiredPermissions): Boolean = Permissions.validatePermissions(permissions, this) - def intersect(permissions: Permissions): Permissions = Permissions(set intersect permissions.set) - } - object Permissions { - def unapply(permissions: Permissions): Some[Set[Permission]] = Some(permissions.set) - - def empty: Permissions = Permissions(Set.empty) - - implicit val show: Show[Permissions] = Show.wrap(_.set.mkString(", ")) - implicit val eq: Eq[Permissions] = Eq.by(_.set) - - @SuppressWarnings(Array("org.wartremover.warts.All")) // Eval should be stack-safe - def validatePermissions(required: RequiredPermissions, existing: Permissions): Boolean = { - def permitted(permission: Permission) = existing.set.contains(permission) - def evaluate(req: RequiredPermissions): Eval[Boolean] = req match { - case RequiredPermissions.Empty => Eval.True - case RequiredPermissions.AllOf(set) => Eval.later(set.forall(permitted)) - case RequiredPermissions.AnyOf(set) => Eval.later(set.exists(permitted)) - case RequiredPermissions.And(x, y) => (evaluate(x), evaluate(y)).mapN(_ && _) - case RequiredPermissions.Or(x, y) => (evaluate(x), evaluate(y)).mapN(_ || _) - case RequiredPermissions.Not(x) => evaluate(x).map(!_) - } - evaluate(required).value - } - } -} diff --git a/modules/users/src/main/scala/io/branchtalk/users/reads/BanReads.scala b/modules/users/src/main/scala/io/branchtalk/users/reads/BanReads.scala index 271a04f2..2c00ecba 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/reads/BanReads.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/reads/BanReads.scala @@ -7,5 +7,5 @@ trait BanReads[F[_]] { def findForUser(userID: ID[User]): F[Set[Ban]] def findForChannel(channelID: ID[Channel]): F[Set[Ban]] - def findGlobally: F[Set[Ban]] + def findGlobally: F[Set[Ban]] } diff --git a/modules/users/src/main/scala/io/branchtalk/users/reads/SessionReads.scala b/modules/users/src/main/scala/io/branchtalk/users/reads/SessionReads.scala index 5b8bf5a1..802c1e18 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/reads/SessionReads.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/reads/SessionReads.scala @@ -1,7 +1,5 @@ package io.branchtalk.users.reads -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } import io.branchtalk.shared.model.{ ID, Paginated } import io.branchtalk.users.model.{ Session, User } @@ -10,8 +8,8 @@ trait SessionReads[F[_]] { def paginate( user: ID[User], sortBy: Session.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive + offset: Paginated.Offset, + limit: Paginated.Limit ): F[Paginated[Session]] def requireById(id: ID[Session]): F[Session] diff --git a/modules/users/src/main/scala/io/branchtalk/users/reads/UserReads.scala b/modules/users/src/main/scala/io/branchtalk/users/reads/UserReads.scala index 4441d7af..ea645687 100644 --- a/modules/users/src/main/scala/io/branchtalk/users/reads/UserReads.scala +++ b/modules/users/src/main/scala/io/branchtalk/users/reads/UserReads.scala @@ -1,7 +1,5 @@ package io.branchtalk.users.reads -import eu.timepit.refined.api.Refined -import eu.timepit.refined.numeric.{ NonNegative, Positive } import io.branchtalk.shared.model.{ ID, Paginated } import io.branchtalk.users.model.{ Password, User } @@ -11,8 +9,8 @@ trait UserReads[F[_]] { def paginate( sortBy: User.Sorting, - offset: Long Refined NonNegative, - limit: Int Refined Positive, + offset: Paginated.Offset, + limit: Paginated.Limit, filters: List[User.Filter] = List.empty ): F[Paginated[User]] diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ae84a951..f9c72d75 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,210 +1,101 @@ import sbt._ -import sbt.Keys.{ libraryDependencies, scalaBinaryVersion } -import Dependencies._ -import sbtcrossproject.CrossProject -import sbtcrossproject.CrossPlugin.autoImport._ -import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._ -import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSVersion - -import Dependencies._ object Dependencies { // scala version - val scalaOrganization = "org.scala-lang" - val scalaVersion = "2.13.9" - val crossScalaVersions = Seq("2.13.9") + val scalaVersion = "3.4.2" // libraries versions - val avro4sVersion = "4.1.0" // https://github.com/sksamuel/avro4s/releases - val catsVersion = "2.8.0" // https://github.com/typelevel/cats/releases - val catsEffectVersion = "3.3.14" // https://github.com/typelevel/cats-effect/releases - val declineVersion = "2.3.0" // https://github.com/tpolecat/doobie/releases - val doobieVersion = "1.0.0-RC2" // https://github.com/tpolecat/doobie/releases + val avro4sVersion = "5.0.13" // https://github.com/sksamuel/avro4s/releases + val catsVersion = "2.12.0" // https://github.com/typelevel/cats/releases + val catsEffectVersion = "3.5.0" // https://github.com/typelevel/cats-effect/releases + val declineVersion = "2.4.1" // https://github.com/bkirwi/decline/releases + val doobieVersion = "1.0.0-RC5" // https://github.com/tpolecat/doobie/releases val drosteVersion = "0.9.0" // https://github.com/higherkindness/droste/releases - val enumeratumVersion = "1.7.0" // https://github.com/lloydmeta/enumeratum/releases - val fs2Version = "3.2.14" // https://github.com/typelevel/fs2/releases - val log4catsVersion = "2.5.0" // https://github.com/ChristopherDavenport/log4cats/releases - val http4sVersion = "0.24.1" // https://github.com/http4s/http4s/releases - val jsoniterVersion = "2.17.4" // https://github.com/plokhotnyuk/jsoniter-scala/releases - val monocleVersion = "3.1.0" // https://github.com/optics-dev/Monocle/releases - val pureConfigVersion = "0.17.1" // https://github.com/pureconfig/pureconfig/releases - val refinedVersion = "0.10.1" // https://github.com/fthomas/refined/releases - val specs2Version = "4.16.1" // https://github.com/etorreborre/specs2/releases - val tapirVersion = "1.1.0" // https://github.com/softwaremill/tapir/releases - - // resolvers - val resolvers = Seq( - Resolver.sonatypeOssRepos("public"), - Resolver.sonatypeOssRepos("releases"), - Seq(Resolver.typesafeRepo("releases")) - ).flatten + val enumeratumVersion = "1.7.4" // https://github.com/lloydmeta/enumeratum/releases + val fs2Version = "3.10.2" // https://github.com/typelevel/fs2/releases + val log4catsVersion = "2.7.0" // https://github.com/ChristopherDavenport/log4cats/releases + val http4sVersion = "0.24.7" // https://github.com/http4s/http4s/releases + val jsoniterVersion = "2.30.7" // https://github.com/plokhotnyuk/jsoniter-scala/releases + val neotypeVersion = "0.3.0" // https://github.com/kitlangton/neotype/releases + val pureConfigVersion = "0.17.7" // https://github.com/pureconfig/pureconfig/releases + val specs2Version = "5.5.3" // https://github.com/etorreborre/specs2/releases + val tapirVersion = "1.10.14" // https://github.com/softwaremill/tapir/releases - // compiler plugins - val betterMonadicFor = - "com.olegpy" %% "better-monadic-for" % "0.3.1" // https://github.com/oleg-py/better-monadic-for/releases - val kindProjector = - "org.typelevel" %% "kind-projector" % "0.13.2" cross CrossVersion.full // https://github.com/typelevel/kind-projector/releases // functional libraries - val catnip = - "io.scalaland" %% "catnip" % "1.1.2" exclude ("org.typelevel", "kittens_2.13") // https://github.com/scalalandio/catnip/releases - val cats = "org.typelevel" %% "cats-core" % catsVersion - val catsFree = "org.typelevel" %% "cats-free" % catsVersion - val catsEffect = "org.typelevel" %% "cats-effect" % catsEffectVersion - val alleycats = "org.typelevel" %% "alleycats-core" % catsVersion - val kittens = "org.typelevel" %% "kittens" % "3.0.0" // https://github.com/typelevel/kittens/releases - val catsLaws = "org.typelevel" %% "cats-laws" % catsVersion - val chimney = "io.scalaland" %% "chimney" % "0.6.2" // https://github.com/scalalandio/chimney/releases - val droste = "io.higherkindness" %% "droste-core" % drosteVersion - val enumeratum = "com.beachape" %% "enumeratum" % enumeratumVersion - val fastuuid = "com.eatthepath" % "fast-uuid" % "0.2.0" // https://github.com/jchambers/fast-uuid/releases + val cats = "org.typelevel" %% "cats-core" % catsVersion + val catsFree = "org.typelevel" %% "cats-free" % catsVersion + val catsEffect = "org.typelevel" %% "cats-effect" % catsEffectVersion + val alleycats = "org.typelevel" %% "alleycats-core" % catsVersion + val kittens = "org.typelevel" %% "kittens" % "3.3.0" // https://github.com/typelevel/kittens/releases + val catsLaws = "org.typelevel" %% "cats-laws" % catsVersion + val chimney = "io.scalaland" %% "chimney" % "1.3.0" // https://github.com/scalalandio/chimney/releases + val droste = "io.higherkindness" %% "droste-core" % drosteVersion + val enumeratum = "com.beachape" %% "enumeratum" % enumeratumVersion + val enumeratumDoobie = "com.beachape" %% "enumeratum-doobie" % "1.7.6" + val fastuuid = "com.eatthepath" % "fast-uuid" % "0.2.0" // https://github.com/jchambers/fast-uuid/releases val uuidGenerator = - "com.fasterxml.uuid" % "java-uuid-generator" % "4.0.1" // https://github.com/cowtowncoder/java-uuid-generator/releases + "com.fasterxml.uuid" % "java-uuid-generator" % "5.1.0" // https://github.com/cowtowncoder/java-uuid-generator/releases val fs2 = "co.fs2" %% "fs2-core" % fs2Version val fs2IO = "co.fs2" %% "fs2-io" % fs2Version val magnolia = - "com.softwaremill.magnolia1_2" %% "magnolia" % "1.1.2" // https://github.com/softwaremill/magnolia/releases - val monocle = "dev.optics" %% "monocle-core" % monocleVersion - val monocleMacro = "dev.optics" %% "monocle-macro" % monocleVersion - val newtype = "io.estatico" %% "newtype" % "0.4.4" // https://github.com/estatico/scala-newtype/releases - val refined = "eu.timepit" %% "refined" % refinedVersion - val refinedCats = "eu.timepit" %% "refined-cats" % refinedVersion - val refinedDecline = "com.monovore" %% "decline-refined" % declineVersion - val refinedPureConfig = "eu.timepit" %% "refined-pureconfig" % refinedVersion + "com.softwaremill.magnolia1_3" %% "magnolia" % "1.3.7" // https://github.com/softwaremill/magnolia/releases + val neotype = "io.github.kitlangton" %% "neotype" % neotypeVersion + val neotypeChimney = "io.github.kitlangton" %% "neotype-chimney" % neotypeVersion + val neotypeDoobie = "io.github.kitlangton" %% "neotype-doobie" % neotypeVersion + val neotypeJsoniter = "io.github.kitlangton" %% "neotype-jsoniter" % neotypeVersion + val neotypeTapir = "io.github.kitlangton" %% "neotype-tapir" % neotypeVersion + val quicklens = + "com.softwaremill.quicklens" %% "quicklens" % "1.9.7" // https://github.com/softwaremill/quicklens/releases // infrastructure val avro4s = "com.sksamuel.avro4s" %% "avro4s-core" % avro4sVersion val avro4sCats = "com.sksamuel.avro4s" %% "avro4s-cats" % avro4sVersion - val avro4sRefined = "com.sksamuel.avro4s" %% "avro4s-refined" % avro4sVersion val doobie = "org.tpolecat" %% "doobie-core" % doobieVersion val doobieHikari = "org.tpolecat" %% "doobie-hikari" % doobieVersion val doobiePostgres = "org.tpolecat" %% "doobie-postgres" % doobieVersion - val doobieRefined = "org.tpolecat" %% "doobie-refined" % doobieVersion val doobieSpecs2 = "org.tpolecat" %% "doobie-specs2" % doobieVersion - val flyway = "org.flywaydb" % "flyway-core" % "9.3.0" // https://github.com/flyway/flyway/releases - val fs2Kafka = "com.github.fd4s" %% "fs2-kafka" % "2.5.0" // https://github.com/fd4s/fs2-kafka/releases - val macwire = - "com.softwaremill.macwire" %% "macros" % "2.5.8" % "provided" // https://github.com/softwaremill/macwire/releases + val flyway = "org.flywaydb" % "flyway-core" % "10.16.0" // https://github.com/flyway/flyway/releases + val flywayPostgres = "org.flywaydb" % "flyway-database-postgresql" % "10.16.0" // https://github.com/flyway/flyway/releases + val fs2Kafka = "com.github.fd4s" %% "fs2-kafka" % "3.5.1" // https://github.com/fd4s/fs2-kafka/releasesreleases val redis4cats = - "dev.profunktor" %% "redis4cats-effects" % "1.2.0" // https://github.com/profunktor/redis4cats/releases + "dev.profunktor" %% "redis4cats-effects" % "1.7.1" // https://github.com/profunktor/redis4cats/releases // API val sttpCats = - "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % "3.8.0" // https://github.com/softwaremill/sttp/releases + "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % "3.9.7" // https://github.com/softwaremill/sttp/releases // same as the one used by tapir - val http4sBlaze = "org.http4s" %% "http4s-blaze-server" % "0.23.12" // https://github.com/http4s/blaze/releases + val http4sBlaze = "org.http4s" %% "http4s-blaze-server" % "0.23.16" // https://github.com/http4s/blaze/releases val http4sPrometheus = "org.http4s" %% "http4s-prometheus-metrics" % http4sVersion val tapir = "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion val tapirHttp4s = "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % tapirVersion val tapirJsoniter = "com.softwaremill.sttp.tapir" %% "tapir-jsoniter-scala" % tapirVersion - val tapirRefined = "com.softwaremill.sttp.tapir" %% "tapir-refined" % tapirVersion val tapirOpenAPI = "com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % tapirVersion val tapirSwaggerUI = "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-http4s" % "0.19.0-M4" // tapirVersion val tapirSTTP = "com.softwaremill.sttp.tapir" %% "tapir-sttp-client" % tapirVersion val jsoniter = "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core" % jsoniterVersion val jsoniterMacro = - "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % jsoniterVersion % "compile-internal" + "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % jsoniterVersion // % "compile-internal" // config val decline = "com.monovore" %% "decline" % declineVersion - val scalaConfig = "com.typesafe" % "config" % "1.4.2" // https://github.com/lightbend/config/releases - val pureConfig = "com.github.pureconfig" %% "pureconfig" % pureConfigVersion + val scalaConfig = "com.typesafe" % "config" % "1.4.3" // https://github.com/lightbend/config/releases + val pureConfig = "com.github.pureconfig" %% "pureconfig-core" % pureConfigVersion val pureConfigCats = "com.github.pureconfig" %% "pureconfig-cats" % pureConfigVersion val pureConfigEnumeratum = "com.github.pureconfig" %% "pureconfig-enumeratum" % pureConfigVersion // security - val bcrypt = "at.favre.lib" % "bcrypt" % "0.9.0" + val bcrypt = "at.favre.lib" % "bcrypt" % "0.10.2" // logging val log4cats = "org.typelevel" %% "log4cats-core" % log4catsVersion val log4catsSlf4j = "org.typelevel" %% "log4cats-slf4j" % log4catsVersion val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5" // GH releases are out of date - val logback = "ch.qos.logback" % "logback-classic" % "1.4.1" // https://github.com/qos-ch/logback/releases + val logback = "ch.qos.logback" % "logback-classic" % "1.5.6" // https://github.com/qos-ch/logback/releases val logbackJackson = "ch.qos.logback.contrib" % "logback-jackson" % "0.1.5" // see MVN val logbackJsonClassic = "ch.qos.logback.contrib" % "logback-json-classic" % "0.1.5" // see MVN - val sourcecode = "com.lihaoyi" %% "sourcecode" % "0.3.0" // https://github.com/lihaoyi/sourcecode/releases - val prometheus = "io.prometheus" % "simpleclient" % "0.16.0" // https://github.com/prometheus/client_java/releases + val sourcecode = "com.lihaoyi" %% "sourcecode" % "0.4.2" // https://github.com/lihaoyi/sourcecode/releases + val prometheus = "io.prometheus" % "simpleclient" % "0.16.0" // https://github.com/prometheus/client_java/releases // testing - val jfairy = "com.devskiller" % "jfairy" % "0.6.4" // https://github.com/Devskiller/jfairy/releases - val guice = "com.google.inject" % "guice" % "5.0.1" // required by jfairy on JDK 15+ + val jfairy = "com.devskiller" % "jfairy" % "0.6.5" // https://github.com/Devskiller/jfairy/releases + val guice = "com.google.inject" % "guice" % "7.0.0" // required by jfairy on JDK 15+ val guiceAssisted = - "com.google.inject.extensions" % "guice-assistedinject" % "5.0.1" // required by jfairy on JDK 15+ + "com.google.inject.extensions" % "guice-assistedinject" % "7.0.0" // required by jfairy on JDK 15+ val spec2Core = "org.specs2" %% "specs2-core" % specs2Version val spec2Scalacheck = "org.specs2" %% "specs2-scalacheck" % specs2Version } - -trait Dependencies { - - val scalaOrganizationUsed = scalaOrganization - val scalaVersionUsed = scalaVersion - val crossScalaVersionsUsed = crossScalaVersions - - // resolvers - val commonResolvers = resolvers - - val mainDeps = Seq( - cats, - catsFree, - catsEffect, - alleycats, - kittens, - chimney, - enumeratum, - fastuuid, - uuidGenerator, - log4cats, - log4catsSlf4j, - magnolia, - monocle, - monocleMacro, - newtype, - refined, - refinedCats, - scalaLogging, - logback - ) - - val testDeps = Seq(catsLaws, spec2Core, spec2Scalacheck) - - implicit final class ProjectRoot(project: Project) { - - def root: Project = project in file(".") - } - - implicit final class ProjectFrom(project: Project) { - - private val commonDir = "modules" - - def from(dir: String): Project = project in file(s"$commonDir/$dir") - } - - implicit final class DependsOnProject(project: Project) { - - private val testConfigurations = Set("test", "fun", "it") - private def findCompileAndTestConfigs(p: Project) = - (p.configurations.map(_.name).toSet intersect testConfigurations) + "compile" - - private val thisProjectsConfigs = findCompileAndTestConfigs(project) - private def generateDepsForProject(p: Project) = - p % (thisProjectsConfigs intersect findCompileAndTestConfigs(p) map (c => s"$c->$c") mkString ";") - - def compileAndTestDependsOn(projects: Project*): Project = - project dependsOn (projects.map(generateDepsForProject): _*) - } - - implicit final class CrossProjectFrom(project: CrossProject) { - - private val commonDir = "modules" - - def from(dir: String): CrossProject = project in file(s"$commonDir/$dir") - } - - implicit final class DependsOnCrossProject(project: CrossProject) { - - private val testConfigurations = Set("test", "fun", "it") - private def findCompileAndTestConfigs(p: CrossProject) = - (p.projects(JVMPlatform).configurations.map(_.name).toSet intersect testConfigurations) + "compile" - - private val thisProjectsConfigs = findCompileAndTestConfigs(project) - private def generateDepsForProject(p: CrossProject) = - p % (thisProjectsConfigs intersect findCompileAndTestConfigs(p) map (c => s"$c->$c") mkString ";") - - def compileAndTestDependsOn(projects: CrossProject*): CrossProject = - project dependsOn (projects.map(generateDepsForProject): _*) - } -} diff --git a/project/Settings.scala b/project/Settings.scala deleted file mode 100644 index 4631eb0b..00000000 --- a/project/Settings.scala +++ /dev/null @@ -1,292 +0,0 @@ -import sbt._ -import sbt.Keys._ -import sbt.TestFrameworks.Specs2 -import sbt.Tests.Argument -import com.github.sbt.git.GitVersioning -import com.typesafe.sbt._ -import com.typesafe.sbt.packager.archetypes.JavaAppPackaging -import com.typesafe.sbt.packager.docker.DockerPlugin -import org.scalafmt.sbt.ScalafmtPlugin.scalafmtConfigSettings -import org.scalafmt.sbt.ScalafmtPlugin.autoImport._ -import org.scalastyle.sbt.ScalastylePlugin.autoImport._ -import sbtassembly.AssemblyPlugin.autoImport._ -import sbtcrossproject.CrossPlugin.autoImport.JVMPlatform -import sbtcrossproject.CrossProject -import scoverage._ -import wartremover.WartRemover.autoImport._ - -object Settings extends Dependencies { - - val FunctionalTest: Configuration = config("fun") extend Test describedAs "Runs only functional tests" - - private val commonSettings = Seq( - organization := "io.branchtalk", - scalaOrganization := scalaOrganizationUsed, - scalaVersion := scalaVersionUsed, - crossScalaVersions := crossScalaVersionsUsed, - // kind of required to avoid "{project}/doc" task failure, because of Catnip :/ - Compile / doc / sources := Seq.empty, - Compile / packageDoc / publishArtifact := false - ) - - private val rootSettings = commonSettings - - private val modulesSettings = commonSettings ++ Seq( - scalacOptions ++= Seq( - // standard settings - "-encoding", - "UTF-8", - "-unchecked", - "-deprecation", - "-explaintypes", - "-feature", - // language features - "-language:existentials", - "-language:higherKinds", - "-language:implicitConversions", - "-language:postfixOps", - // private options - "-Ybackend-parallelism", - "8", - "-Ymacro-annotations", - "-Wmacros:before", - // warnings - "-Ywarn-dead-code", - "-Ywarn-extra-implicit", - "-Ywarn-macros:before", - "-Ywarn-numeric-widen", - //"-Ywarn-unused", // TODO: a lot of new false-positive errors after bumping from 2.13.4 to 2.13.5 - "-Ywarn-unused:implicits", - "-Ywarn-unused:imports", - "-Ywarn-unused:imports", - "-Ywarn-unused:locals", - "-Ywarn-unused:patvars", - "-Ywarn-unused:privates", - "-Ywarn-value-discard", - // advanced options - "-Xcheckinit", - "-Xfatal-warnings", - // linting - "-Xlint:adapted-args", - "-Xlint:constant", - "-Xlint:delayedinit-select", - "-Xlint:doc-detached", - "-Xlint:implicit-recursion", - "-Xlint:inaccessible", - "-Xlint:infer-any", - "-Xlint:missing-interpolator", - "-Xlint:nullary-unit", - "-Xlint:option-implicit", - "-Xlint:package-object-classes", - "-Xlint:poly-implicit-overload", - "-Xlint:private-shadow", - "-Xlint:stars-align", - "-Xlint:type-parameter-shadow" - ), - console / scalacOptions --= Seq( - // warnings - "-Ywarn-unused", - "-Ywarn-unused:implicits", - "-Ywarn-unused:imports", - "-Ywarn-unused:imports", - "-Ywarn-unused:locals", - "-Ywarn-unused:patvars", - "-Ywarn-unused:privates", - // advanced options - "-Xfatal-warnings", - // linting - "-Xlint" - ), - Global / cancelable := true, - Compile / trapExit := false, - Compile / connectInput := true, - Compile / outputStrategy := Some(StdoutOutput), - resolvers ++= commonResolvers, - libraryDependencies ++= mainDeps, - addCompilerPlugin(Dependencies.betterMonadicFor), - addCompilerPlugin(Dependencies.kindProjector), - Compile / scalafmtOnCompile := true, - Compile / compile / wartremoverWarnings ++= Warts.allBut( - Wart.Any, - Wart.DefaultArguments, - Wart.ExplicitImplicitTypes, - Wart.ImplicitConversion, - Wart.ImplicitParameter, - Wart.Overloading, - Wart.PublicInference, - Wart.NonUnitStatements, - Wart.Nothing - ), - scalastyleFailOnError := true - ) - - def customPredef(imports: String*): Def.Setting[Task[Seq[String]]] = - scalacOptions += s"-Yimports:${(Seq("java.lang", "scala", "scala.Predef") ++ imports).mkString(",")}" - - implicit final class RunConfigurator(project: Project) { - - def configureRun(main: String): Project = - project - .enablePlugins(JavaAppPackaging, DockerPlugin) - .settings( - inTask(assembly)( - Seq( - assemblyJarName := s"${name.value}.jar", - assemblyMergeStrategy := { - // required for OpenAPIServer to work - case PathList("META-INF", "maven", "org.webjars", "swagger-ui", "pom.properties") => - MergeStrategy.singleOrError - // conflicts on random crap - case "module-info.class" => MergeStrategy.discard - // our own Catnip customizations - case "derive.semi.conf" => MergeStrategy.concat - case "derive.stub.conf" => MergeStrategy.concat - // otherwise - case strategy => MergeStrategy.defaultMergeStrategy(strategy) - }, - mainClass := Some(main) - ) - ) - ) - .settings( - Compile / run / mainClass := Some(main), - Compile / run / fork := true, - Compile / runMain / fork := true - ) - } - - sealed abstract class TestConfigurator(project: Project, config: Configuration) { - - protected def configure(requiresFork: Boolean): Project = - project - .configs(config) - .settings(inConfig(config)(Defaults.testSettings): _*) - .settings(inConfig(config)(scalafmtConfigSettings)) - .settings( - inConfig(config)( - Seq( - scalafmtOnCompile := true, - scalastyleConfig := baseDirectory.value / "scalastyle-test-config.xml", - scalastyleFailOnError := false, - fork := requiresFork, - testFrameworks := Seq(Specs2) - ) - ) - ) - .settings(libraryDependencies ++= testDeps map (_ % config.name)) - .enablePlugins(ScoverageSbtPlugin) - - protected def configureSequential(requiresFork: Boolean): Project = - configure(requiresFork).settings( - inConfig(config)( - Seq( - testOptions += Argument(Specs2, "sequential"), - parallelExecution := false - ) - ) - ) - } - - implicit final class DataConfigurator(project: Project) { - - def setName(newName: String): Project = project.settings(name := newName) - - def setDescription(newDescription: String): Project = project.settings(description := newDescription) - - def setInitialImport(newInitialCommand: String*): Project = - project.settings(initialCommands := s"import ${("io.branchtalk._" +: newInitialCommand).mkString(", ")}") - } - - implicit final class CrossDataConfigurator(project: CrossProject) { - - def setName(newName: String): CrossProject = project.configure(_.setName(newName)) - - def setDescription(newDescription: String): CrossProject = project.configure(_.setDescription(newDescription)) - - def setInitialImport(newInitialCommand: String*): CrossProject = - project.configure(_.setInitialImport(newInitialCommand: _*)) - } - - implicit final class RootConfigurator(project: Project) { - - def configureRoot: Project = project.settings(rootSettings: _*) - } - - implicit final class ModuleConfigurator(project: Project) { - - def configureModule: Project = project.settings(modulesSettings: _*).enablePlugins(GitVersioning) - } - - implicit final class UnitTestConfigurator(project: Project) extends TestConfigurator(project, Test) { - - def configureTests(requiresFork: Boolean = false): Project = configure(requiresFork) - - def configureTestsSequential(requiresFork: Boolean = false): Project = configureSequential(requiresFork) - } - - implicit final class FunctionalTestConfigurator(project: Project) extends TestConfigurator(project, FunctionalTest) { - - def configureFunctionalTests(requiresFork: Boolean = false): Project = configure(requiresFork) - - def configureFunctionalTestsSequential(requiresFork: Boolean = false): Project = configureSequential(requiresFork) - } - - implicit final class IntegrationTestConfigurator(project: Project) - extends TestConfigurator(project, IntegrationTest) { - - def configureIntegrationTests(requiresFork: Boolean = false): Project = configure(requiresFork) - - def configureIntegrationTestsSequential(requiresFork: Boolean = false): Project = configureSequential(requiresFork) - } - - implicit final class CrossModuleConfigurator(project: CrossProject) { - - private val testConfigurations = Set("test", "fun", "it") - private def findCompileAndTestConfigs(p: CrossProject) = { - val names = p.projects(JVMPlatform).configurations.map(_.name).toSet intersect testConfigurations - (p.projects(JVMPlatform).configurations.filter(cfg => names(cfg.name)).toSet) + Compile - } - - def configureModule: CrossProject = project - .configure(_.configureModule) - .settings( - // workaround for https://github.com/portable-scala/sbt-crossproject/issues/74 - findCompileAndTestConfigs(project).toList.flatMap( - inConfig(_) { - unmanagedResourceDirectories ++= { - unmanagedSourceDirectories.value - .map(src => (src / ".." / "resources").getCanonicalFile) - .filterNot(unmanagedResourceDirectories.value.contains) - .distinct - } - } - ) - ) - } - - implicit final class CrossUnitTestConfigurator(project: CrossProject) { - - def configureTests(requiresFork: Boolean = false): CrossProject = project.configure(_.configureTests(requiresFork)) - - def configureTestsSequential(requiresFork: Boolean = false): CrossProject = - project.configure(_.configureTestsSequential(requiresFork)) - } - - implicit final class CrossFunctionalTestConfigurator(project: CrossProject) { - - def configureFunctionalTests(requiresFork: Boolean = false): CrossProject = - project.configure(_.configureFunctionalTests(requiresFork)) - - def configureFunctionalTestsSequential(requiresFork: Boolean = false): CrossProject = - project.configure(_.configureFunctionalTestsSequential(requiresFork)) - } - - implicit final class CrossIntegrationTestConfigurator(project: CrossProject) { - - def configureIntegrationTests(requiresFork: Boolean = false): CrossProject = - project.configure(_.configureIntegrationTests(requiresFork)) - - def configureIntegrationTestsSequential(requiresFork: Boolean = false): CrossProject = - project.configure(_.configureIntegrationTestsSequential(requiresFork)) - } -} diff --git a/project/build.properties b/project/build.properties index 22af2628..081fdbbc 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.7.1 +sbt.version=1.10.0 diff --git a/project/plugins.sbt b/project/plugins.sbt index 51ba9904..81e569a6 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,17 +1,23 @@ -// Scala.js and cross-compilation -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.1.0") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.8.0") +// git +addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.1") +// linters +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") +addSbtPlugin("org.wartremover" % "sbt-wartremover" % "3.1.7") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.1.0") +// cross-compile +addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.10.0") +addSbtPlugin("com.indoorvivants" % "sbt-commandmatrix" % "0.0.5") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") // publishing -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.1") addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.11") -addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.0") -// linting -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") -addSbtPlugin("com.beautiful-scala" % "sbt-scalastyle" % "1.5.1") -addSbtPlugin("org.wartremover" % "sbt-wartremover" % "3.0.6") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3") +addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.1") +// disabling projects in IDE +addSbtPlugin("org.jetbrains" % "sbt-ide-settings" % "1.1.0") // running addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") libraryDependencies += "org.slf4j" % "slf4j-nop" % "1.7.25" dependencyOverrides += "org.scala-lang.modules" %% "scala-xml" % "2.1.0" + +ThisBuild / libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always diff --git a/scalastyle-config.xml b/scalastyle-config.xml deleted file mode 100644 index 2863ab95..00000000 --- a/scalastyle-config.xml +++ /dev/null @@ -1,99 +0,0 @@ - - branchtalk style - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/scalastyle-test-config.xml b/scalastyle-test-config.xml deleted file mode 100644 index 4ac1ab15..00000000 --- a/scalastyle-test-config.xml +++ /dev/null @@ -1,99 +0,0 @@ - - branchtalk test style - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -