diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala index 7ba4a8078..8fc99e0ad 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala @@ -8,7 +8,8 @@ import ox.OxApp.Settings import ox.otel.context.PropagatingVirtualThreadFactory object Main extends OxApp.Simple with Logging: - // route JUL to SLF4J (JUL is used by Magnum & OTEL for logging) + // route JUL to SLF4J (JUL is used by OTEL for logging); parlance instead logs via java.lang.System.Logger, + // which is routed to SLF4J by the slf4j-jdk-platform-logging runtime dependency, not by this bridge SLF4JBridgeHandler.removeHandlersForRootLogger() SLF4JBridgeHandler.install() diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala index e9b2f4d50..5a4cadfab 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala @@ -1,21 +1,22 @@ package com.softwaremill.bootzooka.email -import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, Spec, SqlNameMapper, Table} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import ma.chinespirit.parlance.{EntityMeta, QueryBuilder, Repo, SqlNameMapper, Table} +import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.util.Strings.Id import ox.discard /** Model for storing and retrieving scheduled emails. */ class EmailModel: - private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]] + private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]() - def insert(email: Email)(using DbTx): Unit = emailRepo.insert(ScheduledEmails(email)) - def find(limit: Int)(using DbTx): Vector[Email] = emailRepo.findAll(Spec[ScheduledEmails].limit(limit)).map(_.toEmail) - def count()(using DbTx): Long = emailRepo.count - def delete(ids: Vector[Id[Email]])(using DbTx): Unit = emailRepo.deleteAllById(ids).discard + def insert(email: Email)(using Tx): Unit = emailRepo.rawInsert(ScheduledEmails(email)) + def find(limit: Int)(using Tx): Vector[Email] = QueryBuilder.from[ScheduledEmails].limit(limit).run().map(_.toEmail) + def count()(using Tx): Long = emailRepo.count + def delete(ids: Vector[Id[Email]])(using Tx): Unit = emailRepo.deleteAllById(ids).discard -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) -private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String): +@Table(SqlNameMapper.CamelToSnakeCase) +private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String) derives EntityMeta: def toEmail: Email = Email(id, EmailData(recipient, EmailSubjectContent(subject, content))) private object ScheduledEmails: diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala index a3416e8b8..dda1241de 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala @@ -1,8 +1,7 @@ package com.softwaremill.bootzooka.email -import com.augustnagro.magnum.DbTx import com.softwaremill.bootzooka.email.sender.EmailSender -import com.softwaremill.bootzooka.infrastructure.DB +import com.softwaremill.bootzooka.infrastructure.{DB, Tx} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.metrics.Metrics import com.softwaremill.bootzooka.util.IdGenerator @@ -21,7 +20,7 @@ class EmailService( ) extends EmailScheduler with Logging: - def schedule(data: EmailData)(using DbTx): Unit = + def schedule(data: EmailData)(using Tx): Unit = logger.debug(s"Scheduling email to be sent to: ${data.recipient}") val id = idGenerator.nextId[Email]() emailModel.insert(Email(id, data)) @@ -57,4 +56,4 @@ class EmailService( end EmailService trait EmailScheduler: - def schedule(data: EmailData)(using DbTx): Unit + def schedule(data: EmailData)(using Tx): Unit diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Codecs.scala similarity index 71% rename from backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala rename to backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Codecs.scala index f942c7c1b..89f89116e 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Codecs.scala @@ -1,16 +1,15 @@ package com.softwaremill.bootzooka.infrastructure -import com.augustnagro.magnum.DbCodec -import com.softwaremill.bootzooka.logging.Logging +import ma.chinespirit.parlance.DbCodec import com.softwaremill.bootzooka.util.Strings.* import java.time.{Instant, OffsetDateTime, ZoneOffset} -/** Magnum codecs for custom types, useful when writing SQL queries. */ -object Magnum extends Logging: +/** parlance [[DbCodec]]s for custom types, useful when writing SQL queries. */ +object Codecs: given DbCodec[Instant] = summon[DbCodec[OffsetDateTime]].biMap(_.toInstant, _.atOffset(ZoneOffset.UTC)) given idCodec[T]: DbCodec[Id[T]] = DbCodec.StringCodec.biMap(_.asId[T], _.toString) given DbCodec[Hashed] = DbCodec.StringCodec.biMap(_.asHashed, _.toString) given DbCodec[LowerCased] = DbCodec.StringCodec.biMap(_.toLowerCased, _.toString) -end Magnum +end Codecs diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala index f30a6d62e..7bf4040f4 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.infrastructure -import com.augustnagro.magnum.{DbCodec, DbTx, SqlLogger, Transactor, connect, sql} +import ma.chinespirit.parlance.{Postgres, SqlLogger, Transactor, sql} import com.softwaremill.bootzooka.infrastructure.DB.LeftException import com.softwaremill.bootzooka.logging.Logging import com.zaxxer.hikari.{HikariConfig, HikariDataSource} @@ -15,21 +15,20 @@ import scala.util.NotGiven import scala.util.control.{NoStackTrace, NonFatal} class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable: - private val transactor = Transactor( - dataSource = dataSource, - sqlLogger = SqlLogger.logSlowQueries(200.millis) - ) + // the database type is pinned explicitly so that the `Tx` context type matches throughout the codebase + // (otherwise `Transactor(Postgres, ...)` would infer the singleton type `Postgres.type`) + private val transactor = Transactor[Postgres](Postgres, dataSource, SqlLogger.logSlowQueries(200.millis)) /** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */ - def transactEither[E, T](f: DbTx ?=> Either[E, T]): Either[E, T] = - try com.augustnagro.magnum.transact(transactor)(Right(f.fold(e => throw LeftException(e), identity))) + def transactEither[E, T](f: Tx ?=> Either[E, T]): Either[E, T] = + try transactor.transact(Right(f.fold(e => throw LeftException(e), identity))) catch case e: LeftException[E] @unchecked => Left(e.left) /** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if * no exception is thrown. */ - def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = - com.augustnagro.magnum.transact(transactor)(f) + def transact[T](f: Tx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = + transactor.transact(f) override def close(): Unit = dataSource.close() end DB @@ -56,7 +55,7 @@ object DB extends Logging: .load() def migrate(): Unit = if config.migrateOnStart then flyway.migrate().discard - def testConnection(ds: DataSource): Unit = connect(ds)(sql"SELECT 1".query[Int].run()).discard + def testConnection(ds: DataSource): Unit = Transactor[Postgres](Postgres, ds).connect(sql"SELECT 1".query[Int].run()).discard @tailrec def connectAndMigrate(ds: DataSource): Unit = diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Tx.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Tx.scala new file mode 100644 index 000000000..f8283417f --- /dev/null +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Tx.scala @@ -0,0 +1,9 @@ +package com.softwaremill.bootzooka.infrastructure + +import ma.chinespirit.parlance.{DbTx, Postgres} + +/** A transaction context for the application's single (Postgres) database. Use as a `(using Tx)` parameter on methods that must run within a + * transaction; an instance is provided by [[DB.transact]] / [[DB.transactEither]]. Centralises the choice of database type so call sites + * don't repeat `DbTx[Postgres]`. + */ +type Tx = DbTx[Postgres] diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala index e6ef57a7e..fcb6b7126 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala @@ -1,7 +1,8 @@ package com.softwaremill.bootzooka.passwordreset -import com.augustnagro.magnum.{DbCodec, DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import ma.chinespirit.parlance.{EntityMeta, Repo, SqlName, SqlNameMapper, Table} +import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.security.AuthTokenOps import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id @@ -9,20 +10,20 @@ import com.softwaremill.bootzooka.util.Strings.Id import java.time.Instant class PasswordResetCodeModel: - private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]] + private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]() - def insert(pr: PasswordResetCode)(using DbTx): Unit = passwordResetCodeRepo.insert(pr) - def delete(id: Id[PasswordResetCode])(using DbTx): Unit = passwordResetCodeRepo.deleteById(id) - def findById(id: Id[PasswordResetCode])(using DbTx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id) + def insert(pr: PasswordResetCode)(using Tx): Unit = passwordResetCodeRepo.rawInsert(pr) + def delete(id: Id[PasswordResetCode])(using Tx): Unit = passwordResetCodeRepo.deleteById(id) + def findById(id: Id[PasswordResetCode])(using Tx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id) -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) +@Table(SqlNameMapper.CamelToSnakeCase) @SqlName("password_reset_codes") -case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant) +case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant) derives EntityMeta class PasswordResetAuthToken(passwordResetCodeModel: PasswordResetCodeModel) extends AuthTokenOps[PasswordResetCode]: override def tokenName: String = "PasswordResetCode" - override def findById: DbTx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById - override def delete: DbTx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id) + override def findById: Tx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById + override def delete: Tx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id) override def userId: PasswordResetCode => Id[User] = _.userId override def validUntil: PasswordResetCode => Instant = _.validUntil // password reset code is a one-time token diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala index d289077cd..6d48339d1 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala @@ -1,9 +1,8 @@ package com.softwaremill.bootzooka.passwordreset -import com.augustnagro.magnum.DbTx import com.softwaremill.bootzooka.Fail import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplates} -import com.softwaremill.bootzooka.infrastructure.DB +import com.softwaremill.bootzooka.infrastructure.{DB, Tx} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.security.Auth import com.softwaremill.bootzooka.user.{User, UserModel} @@ -23,14 +22,14 @@ class PasswordResetService( clock: Clock, db: DB ) extends Logging: - def forgotPassword(loginOrEmail: String)(using DbTx): Unit = + def forgotPassword(loginOrEmail: String)(using Tx): Unit = userModel.findByLoginOrEmail(loginOrEmail.toLowerCased) match case None => logger.debug(s"Could not find user with $loginOrEmail login/email") case Some(user) => val pcr = createCode(user) sendCode(user, pcr) - private def createCode(user: User)(using DbTx): PasswordResetCode = + private def createCode(user: User)(using Tx): PasswordResetCode = logger.debug(s"Creating password reset code for user: ${user.id}") val id = idGenerator.nextId[PasswordResetCode]() val validUntil = clock.now().plusMillis(config.codeValid.toMillis) @@ -38,7 +37,7 @@ class PasswordResetService( passwordResetCodeModel.insert(passwordResetCode) passwordResetCode - private def sendCode(user: User, code: PasswordResetCode)(using DbTx): Unit = + private def sendCode(user: User, code: PasswordResetCode)(using Tx): Unit = logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}") emailScheduler.schedule(EmailData(user.emailLowerCase, prepareResetEmail(user, code))) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala index b5133fdce..f5e304879 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala @@ -1,7 +1,8 @@ package com.softwaremill.bootzooka.security -import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import ma.chinespirit.parlance.{EntityMeta, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} +import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id import ox.discard @@ -9,23 +10,23 @@ import ox.discard import java.time.Instant class ApiKeyModel: - private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]] + private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]() private val a = TableInfo[ApiKey, ApiKey, Id[ApiKey]] - def insert(apiKey: ApiKey)(using DbTx): Unit = apiKeyRepo.insert(apiKey) - def findById(id: Id[ApiKey])(using DbTx): Option[ApiKey] = apiKeyRepo.findById(id) - def deleteAllForUser(id: Id[User])(using DbTx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard - def delete(id: Id[ApiKey])(using DbTx): Unit = apiKeyRepo.deleteById(id) + def insert(apiKey: ApiKey)(using Tx): Unit = apiKeyRepo.rawInsert(apiKey) + def findById(id: Id[ApiKey])(using Tx): Option[ApiKey] = apiKeyRepo.findById(id) + def deleteAllForUser(id: Id[User])(using Tx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard + def delete(id: Id[ApiKey])(using Tx): Unit = apiKeyRepo.deleteById(id) end ApiKeyModel -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) +@Table(SqlNameMapper.CamelToSnakeCase) @SqlName("api_keys") -case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant) +case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant) derives EntityMeta class ApiKeyAuthToken(apiKeyModel: ApiKeyModel) extends AuthTokenOps[ApiKey]: override def tokenName: String = "ApiKey" - override def findById: DbTx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById - override def delete: DbTx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id) + override def findById: Tx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById + override def delete: Tx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id) override def userId: ApiKey => Id[User] = _.userId override def validUntil: ApiKey => Instant = _.validUntil override def deleteWhenValid: Boolean = false diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala index 13a79cc40..69ad0767c 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.security -import com.augustnagro.magnum.DbTx +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id @@ -10,7 +10,7 @@ import java.time.temporal.ChronoUnit import scala.concurrent.duration.Duration class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: Clock) extends Logging: - def create(userId: Id[User], valid: Duration)(using DbTx): ApiKey = + def create(userId: Id[User], valid: Duration)(using Tx): ApiKey = val id = idGenerator.nextId[ApiKey]() val now = clock.now() val validUntil = now.plus(valid.toMillis, ChronoUnit.MILLIS) @@ -20,11 +20,11 @@ class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: C apiKey end create - def invalidate(id: Id[ApiKey])(using DbTx): Unit = + def invalidate(id: Id[ApiKey])(using Tx): Unit = logger.debug(s"Invalidating api key $id") apiKeyModel.delete(id) - def invalidateAllForUser(userId: Id[User])(using DbTx): Unit = + def invalidateAllForUser(userId: Id[User])(using Tx): Unit = logger.debug(s"Invalidating all api keys for user $userId") apiKeyModel.deleteAllForUser(userId) end ApiKeyService diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala index be2370ce2..7f31bf323 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala @@ -1,8 +1,7 @@ package com.softwaremill.bootzooka.security -import com.augustnagro.magnum.DbTx import com.softwaremill.bootzooka.* -import com.softwaremill.bootzooka.infrastructure.DB +import com.softwaremill.bootzooka.infrastructure.{DB, Tx} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.* @@ -44,8 +43,8 @@ end Auth */ trait AuthTokenOps[T]: def tokenName: String - def findById: DbTx ?=> Id[T] => Option[T] - def delete: DbTx ?=> T => Unit + def findById: Tx ?=> Id[T] => Option[T] + def delete: Tx ?=> T => Unit def userId: T => Id[User] def validUntil: T => Instant def deleteWhenValid: Boolean diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala index 68bc9e2fe..ae48f0fcb 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala @@ -1,8 +1,9 @@ package com.softwaremill.bootzooka.user -import com.augustnagro.magnum.{DbCodec, DbTx, Frag, PostgresDbType, Repo, Spec, SqlName, SqlNameMapper, Table, TableInfo, sql} +import ma.chinespirit.parlance.{===, ||, EntityMeta, QueryBuilder, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} import com.password4j.{Argon2Function, Password} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.user.User.PasswordHashing import com.softwaremill.bootzooka.user.User.PasswordHashing.Argon2Config.* import com.softwaremill.bootzooka.util.PasswordVerificationStatus @@ -12,37 +13,33 @@ import ox.discard import java.time.Instant class UserModel: - private val userRepo = Repo[User, User, Id[User]] + private val userRepo = Repo[User, User, Id[User]]() private val u = TableInfo[User, User, Id[User]] - export userRepo.{insert, findById} + export userRepo.{rawInsert as insert, findById} - def findByEmail(email: LowerCased)(using DbTx): Option[User] = findBy( - Spec[User].where(sql"${u.emailLowerCase} = $email") - ) - def findByLogin(login: LowerCased)(using DbTx): Option[User] = findBy( - Spec[User].where(sql"${u.loginLowerCase} = $login") - ) - def findByLoginOrEmail(loginOrEmail: LowerCased)(using DbTx): Option[User] = - findBy(Spec[User].where(sql"${u.loginLowerCase} = ${loginOrEmail: String} OR ${u.emailLowerCase} = $loginOrEmail")) + // queries use parlance's typed column DSL (`_.col === value`), which is checked against the entity's fields at compile time + def findByEmail(email: LowerCased)(using Tx): Option[User] = + QueryBuilder.from[User].where(_.emailLowerCase === email).first() + def findByLogin(login: LowerCased)(using Tx): Option[User] = + QueryBuilder.from[User].where(_.loginLowerCase === login).first() + def findByLoginOrEmail(loginOrEmail: LowerCased)(using Tx): Option[User] = + QueryBuilder.from[User].where(u => u.loginLowerCase === loginOrEmail || u.emailLowerCase === loginOrEmail).first() - private def findBy(by: Spec[User])(using DbTx): Option[User] = - userRepo.findAll(by).headOption - - def updatePassword(userId: Id[User], newPassword: Hashed)(using DbTx): Unit = + def updatePassword(userId: Id[User], newPassword: Hashed)(using Tx): Unit = sql"""UPDATE $u SET ${u.passwordHash} = $newPassword WHERE ${u.id} = $userId""".update.run().discard - def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using DbTx): Unit = + def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using Tx): Unit = sql"""UPDATE $u SET ${u.login} = $newLogin, login_lowercase = ${newLoginLowerCase: String} WHERE ${u.id} = $userId""".update .run() .discard - def updateEmail(userId: Id[User], newEmail: LowerCased)(using DbTx): Unit = + def updateEmail(userId: Id[User], newEmail: LowerCased)(using Tx): Unit = sql"""UPDATE $u SET ${u.emailLowerCase} = $newEmail WHERE ${u.id} = $userId""".update.run().discard end UserModel -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) +@Table(SqlNameMapper.CamelToSnakeCase) @SqlName("users") case class User( id: Id[User], @@ -51,7 +48,7 @@ case class User( @SqlName("email_lowercase") emailLowerCase: LowerCased, @SqlName("password") passwordHash: Hashed, createdOn: Instant -): +) derives EntityMeta: def verifyPassword(password: String): PasswordVerificationStatus = if Password.check(password, passwordHash).`with`(PasswordHashing.Argon2) then PasswordVerificationStatus.Verified else PasswordVerificationStatus.VerificationFailed diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala index 6410d951b..8e93b164c 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala @@ -1,8 +1,8 @@ package com.softwaremill.bootzooka.user -import com.augustnagro.magnum.DbTx import com.softwaremill.bootzooka.* import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailTemplates} +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.security.{ApiKey, ApiKeyService} import com.softwaremill.bootzooka.util.* @@ -26,7 +26,7 @@ class UserService( private val EmailAlreadyUsed = "E-mail already in use!" private val IncorrectLoginOrPassword = "Incorrect login/email or password" - def registerNewUser(login: String, email: String, password: String)(using DbTx): Either[Fail, ApiKey] = + def registerNewUser(login: String, email: String, password: String)(using Tx): Either[Fail, ApiKey] = val loginClean = login.trim() val emailClean = email.trim() @@ -56,17 +56,17 @@ class UserService( doRegister() end registerNewUser - def findById(id: Id[User])(using DbTx): Either[Fail, User] = userOrNotFound(userModel.findById(id)) + def findById(id: Id[User])(using Tx): Either[Fail, User] = userOrNotFound(userModel.findById(id)) - def login(loginOrEmail: String, password: String, apiKeyValid: Option[Duration])(using DbTx): Either[Fail, ApiKey] = either: + def login(loginOrEmail: String, password: String, apiKeyValid: Option[Duration])(using Tx): Either[Fail, ApiKey] = either: val loginOrEmailClean = loginOrEmail.trim() val user = userOrNotFound(userModel.findByLoginOrEmail(loginOrEmailClean.toLowerCased)).ok() verifyPassword(user, password, validationErrorMsg = IncorrectLoginOrPassword).ok() apiKeyService.create(user.id, apiKeyValid.getOrElse(config.defaultApiKeyValid)) - def logout(id: Id[ApiKey])(using DbTx): Unit = apiKeyService.invalidate(id) + def logout(id: Id[ApiKey])(using Tx): Unit = apiKeyService.invalidate(id) - def changeUser(userId: Id[User], newLogin: String, newEmail: String)(using DbTx): Either[Fail, Unit] = + def changeUser(userId: Id[User], newLogin: String, newEmail: String)(using Tx): Either[Fail, Unit] = val newLoginClean = newLogin.trim() val newEmailClean = newEmail.trim() val newEmailtoLowerCased = newEmailClean.toLowerCased @@ -111,7 +111,7 @@ class UserService( if anyUpdate then sendMail(findById(userId).ok()) end changeUser - def changePassword(userId: Id[User], currentPassword: String, newPassword: String)(using DbTx): Either[Fail, ApiKey] = + def changePassword(userId: Id[User], currentPassword: String, newPassword: String)(using Tx): Either[Fail, ApiKey] = def validateUserPassword(userId: Id[User], currentPassword: String): Either[Fail, User] = for user <- userOrNotFound(userModel.findById(userId)) diff --git a/build.sbt b/build.sbt index 048281db9..9b159f012 100644 --- a/build.sbt +++ b/build.sbt @@ -14,7 +14,7 @@ val otelVersion = "1.62.0" val otelInstrumentationVersion = "2.17.1-alpha" val dbDependencies = Seq( - "com.augustnagro" %% "magnum" % "1.3.1", // Scala DB client + "ma.chinespirit" %% "parlance" % "0.1.0", // Scala DB client (Magnum-inspired) "org.postgresql" % "postgresql" % "42.7.11", // JDBC driver "com.zaxxer" % "HikariCP" % "7.0.2", // connection pool "org.flywaydb" % "flyway-database-postgresql" % "12.6.2" // database migrations @@ -47,9 +47,10 @@ val jsonDependencies = Seq( val loggingDependencies = Seq( "ch.qos.logback" % "logback-classic" % "1.5.33", // main logging library - "org.slf4j" % "jul-to-slf4j" % "2.0.18", // forward e.g. OTEL and Magnum logs which use JUL to SLF4J + "org.slf4j" % "jul-to-slf4j" % "2.0.18", // forward e.g. OTEL logs which use JUL to SLF4J "com.softwaremill.ox" %% "mdc-logback" % oxVersion, // support MDCs which propagate within Ox scopes - "org.slf4j" % "slf4j-jdk-platform-logging" % "2.0.18" % Runtime // route Java's platform logging (separate from JUL) to SLF4J + // route Java's platform logging (java.lang.System.Logger, separate from JUL) to SLF4J; parlance logs via this + "org.slf4j" % "slf4j-jdk-platform-logging" % "2.0.18" % Runtime ) val configDependencies = Seq( diff --git a/docs/devtips.md b/docs/devtips.md index f9b9a4089..7f5aa4b42 100644 --- a/docs/devtips.md +++ b/docs/devtips.md @@ -44,11 +44,11 @@ There are two imports that are useful when developing a new functionality: If you are defining database queries or running transactions, add the following imports: ```scala -import com.softwaremill.bootzooka.infrastructure.Magnum.given -import com.augustnagro.magnum.{sql, DbTx} +import com.softwaremill.bootzooka.infrastructure.Codecs.given +import ma.chinespirit.parlance.{sql, DbTx, Postgres} ``` -This will bring into scope custom [Magnum](https://github.com/AugustNagro/magnum) codecs, the sql query interpolator +This will bring into scope custom [parlance](https://github.com/lbialy/parlance) codecs, the sql query interpolator as well as the given instance which is required by methods that should run in a transaction. ### HTTP API diff --git a/docs/stack.md b/docs/stack.md index 1a3d47999..155ffd656 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -15,7 +15,7 @@ Bootzooka's stack consists of the following technologies/tools, on the backend: - [Tapir](https://github.com/softwaremill/tapir) (endpoint description library) + [netty](https://netty.io) (backend networking layer) - SQL database, by default [PostgreSQL](https://www.postgresql.org) (persistence) -- [Magnum](https://github.com/AugustNagro/magnum) (type-safe database access) + [flyway](https://flywaydb.org) (schema +- [parlance](https://github.com/lbialy/parlance) (type-safe database access) + [flyway](https://flywaydb.org) (schema evolution) - [Ox](https://github.com/softwaremill/ox) (error handling, concurrency & resource management) - [SBT](https://www.scala-sbt.org) (build tool)