Create user complete, added impl-specific types.

This commit is contained in:
Pat Garrity 2026-02-03 07:35:59 -06:00
parent 5c7c33d1b5
commit 5481686e08
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
8 changed files with 419 additions and 46 deletions

View file

@ -2,6 +2,7 @@ package gs.smolban.auth
import cats.effect.Sync import cats.effect.Sync
import cats.syntax.all.* import cats.syntax.all.*
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import org.bouncycastle.crypto.generators.Argon2BytesGenerator import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters import org.bouncycastle.crypto.params.Argon2Parameters
@ -28,13 +29,19 @@ final class Argon2[F[_]: Sync](
* @return * @return
* The calculated hash. * The calculated hash.
*/ */
def calculateHash(input: String): F[Argon2Hash] = def calculateHash(
input: String,
charset: Charset = StandardCharsets.UTF_8
): F[Argon2Hash] =
calculateHash(input.getBytes(charset))
def calculateHash(input: Array[Byte]): F[Argon2Hash] =
randomSalt().map { salt => randomSalt().map { salt =>
val ap = buildAlgorithmParams(salt) val ap = buildAlgorithmParams(salt)
val a2 = new Argon2BytesGenerator() val a2 = new Argon2BytesGenerator()
val _ = a2.init(ap) val _ = a2.init(ap)
val bytes = new Array[Byte](config.hashLengthInBytes) val bytes = new Array[Byte](config.hashLengthInBytes)
val _ = a2.generateBytes(input.getBytes(StandardCharsets.UTF_8), bytes) val _ = a2.generateBytes(input, bytes)
new Argon2Hash( new Argon2Hash(
algorithmVersion = config.algorithmVersion, algorithmVersion = config.algorithmVersion,
algorithmType = config.algorithmType, algorithmType = config.algorithmType,

View file

@ -14,10 +14,11 @@ import gs.smolban.model.account.AiAgent
import gs.smolban.model.account.PermissionSet import gs.smolban.model.account.PermissionSet
import gs.smolban.model.account.ServiceAccount import gs.smolban.model.account.ServiceAccount
import gs.smolban.model.account.User import gs.smolban.model.account.User
import gs.smolban.model.metadata.CreatedAt
/** Database interface for [[Account]] and related management activities. /** Database interface for [[Account]] and related management activities.
*/ */
trait AuthDb[F[_]]: trait AuthDb[DBIO[_], F[_]]:
/** Get the user account with the given name. /** Get the user account with the given name.
* *
@ -28,7 +29,7 @@ trait AuthDb[F[_]]:
*/ */
def getUser( def getUser(
name: AccountName name: AccountName
): F[Option[User]] ): DBIO[Option[User]]
/** Create a new [[User]]. /** Create a new [[User]].
* *
@ -44,8 +45,9 @@ trait AuthDb[F[_]]:
def createUser( def createUser(
name: AccountName, name: AccountName,
initialPassword: Password, initialPassword: Password,
initialPermissions: PermissionSet initialPermissions: PermissionSet,
): EitherT[F, DbError, User] createdAt: CreatedAt
): F[DBIO[Either[DbError, User]]]
/** Update the password for an existing [[User]]. /** Update the password for an existing [[User]].
* *
@ -59,12 +61,12 @@ trait AuthDb[F[_]]:
def setUserPassword( def setUserPassword(
id: AccountId, id: AccountId,
newPassword: Password newPassword: Password
): EitherT[F, DbError, User] ): EitherT[DBIO, DbError, User]
/** @return /** @return
* List of all active [[User]]. * List of all active [[User]].
*/ */
def listActiveUsers(): F[List[User]] def listActiveUsers(): fs2.Stream[DBIO, User]
/** Get the [[ServiceAccount]] with the given name. /** Get the [[ServiceAccount]] with the given name.
* *
@ -76,7 +78,7 @@ trait AuthDb[F[_]]:
*/ */
def getServiceAccount( def getServiceAccount(
name: AccountName name: AccountName
): F[Option[ServiceAccount]] ): DBIO[Option[ServiceAccount]]
/** Create a new [[ServiceAccount]]. /** Create a new [[ServiceAccount]].
* *
@ -93,12 +95,12 @@ trait AuthDb[F[_]]:
name: AccountName, name: AccountName,
owner: AccountId, owner: AccountId,
initialPermissions: PermissionSet initialPermissions: PermissionSet
): EitherT[F, DbError, NewServiceAccount] ): EitherT[DBIO, DbError, NewServiceAccount]
/** @return /** @return
* List of all active [[ServiceAccount]]. * List of all active [[ServiceAccount]].
*/ */
def listActiveServiceAccounts(): F[List[ServiceAccount]] def listActiveServiceAccounts(): DBIO[List[ServiceAccount]]
/** Get the [[AiAgent]] with the given name. /** Get the [[AiAgent]] with the given name.
* *
@ -109,7 +111,7 @@ trait AuthDb[F[_]]:
*/ */
def getAgentAccount( def getAgentAccount(
name: AccountName name: AccountName
): F[Option[AiAgent]] ): DBIO[Option[AiAgent]]
/** Create a new [[AiAgent]]. /** Create a new [[AiAgent]].
* *
@ -126,12 +128,12 @@ trait AuthDb[F[_]]:
name: AccountName, name: AccountName,
owner: AccountId, owner: AccountId,
initialPermissions: PermissionSet initialPermissions: PermissionSet
): EitherT[F, DbError, NewAiAgentAccount] ): EitherT[DBIO, DbError, NewAiAgentAccount]
/** @return /** @return
* List of all active [[AiAgent]]. * List of all active [[AiAgent]].
*/ */
def listActiveAgentAccounts(): F[List[AiAgent]] def listActiveAgentAccounts(): DBIO[List[AiAgent]]
/** Rotate the specified secret and return the new secret. This is the only /** Rotate the specified secret and return the new secret. This is the only
* time the new value will ever be exposed. * time the new value will ever be exposed.
@ -153,7 +155,7 @@ trait AuthDb[F[_]]:
accountId: AccountId, accountId: AccountId,
credentialId: CredentialId, credentialId: CredentialId,
overlapHours: Int overlapHours: Int
): EitherT[F, DbError, Base64] ): EitherT[DBIO, DbError, Base64]
/** Replace the permissions assigned to some [[Account]]. /** Replace the permissions assigned to some [[Account]].
* *
@ -167,7 +169,7 @@ trait AuthDb[F[_]]:
def setAccountPermissions( def setAccountPermissions(
id: AccountId, id: AccountId,
newPermissions: PermissionSet newPermissions: PermissionSet
): EitherT[F, DbError, Account] ): EitherT[DBIO, DbError, Account]
/** List all credentials that exist for the given [[AccountId]]. /** List all credentials that exist for the given [[AccountId]].
* *
@ -178,7 +180,7 @@ trait AuthDb[F[_]]:
*/ */
def listAccountCredentials( def listAccountCredentials(
id: AccountId id: AccountId
): F[List[Credential]] ): DBIO[List[Credential]]
/** Revoke a credential, making it unusable. /** Revoke a credential, making it unusable.
* *
@ -189,7 +191,7 @@ trait AuthDb[F[_]]:
*/ */
def revokeCredential( def revokeCredential(
id: CredentialId id: CredentialId
): EitherT[F, DbError, Unit] ): EitherT[DBIO, DbError, Unit]
/** Expire a credential, making it unusable. /** Expire a credential, making it unusable.
* *
@ -200,4 +202,4 @@ trait AuthDb[F[_]]:
*/ */
def expireCredential( def expireCredential(
id: CredentialId id: CredentialId
): EitherT[F, DbError, Unit] ): EitherT[DBIO, DbError, Unit]

View file

@ -1,47 +1,161 @@
package gs.smolban.db.doobie package gs.smolban.db.doobie
import cats.data.EitherT import cats.data.EitherT
import cats.effect.Sync
import cats.syntax.all.*
import doobie.* import doobie.*
import doobie.implicits.*
import gs.smolban.auth.Argon2
import gs.smolban.auth.Argon2Hash
import gs.smolban.auth.Base64 import gs.smolban.auth.Base64
import gs.smolban.auth.Credential import gs.smolban.auth.Credential
import gs.smolban.auth.CredentialEffectivity
import gs.smolban.auth.CredentialId import gs.smolban.auth.CredentialId
import gs.smolban.auth.CredentialStatus
import gs.smolban.auth.CredentialType
import gs.smolban.auth.NewAiAgentAccount import gs.smolban.auth.NewAiAgentAccount
import gs.smolban.auth.NewServiceAccount import gs.smolban.auth.NewServiceAccount
import gs.smolban.auth.Password import gs.smolban.auth.Password
import gs.smolban.auth.RsaDecryption
import gs.smolban.auth.RsaEncryptedBytes
import gs.smolban.db.AuthDb import gs.smolban.db.AuthDb
import gs.smolban.db.DbError import gs.smolban.db.DbError
import gs.smolban.db.doobie.DoobieTypes.*
import gs.smolban.model.account.Account import gs.smolban.model.account.Account
import gs.smolban.model.account.AccountId import gs.smolban.model.account.AccountId
import gs.smolban.model.account.AccountName import gs.smolban.model.account.AccountName
import gs.smolban.model.account.AccountStatus
import gs.smolban.model.account.AccountType
import gs.smolban.model.account.AiAgent import gs.smolban.model.account.AiAgent
import gs.smolban.model.account.Permission
import gs.smolban.model.account.PermissionSet import gs.smolban.model.account.PermissionSet
import gs.smolban.model.account.ServiceAccount import gs.smolban.model.account.ServiceAccount
import gs.smolban.model.account.User import gs.smolban.model.account.User
import gs.smolban.model.group.GroupName
import gs.smolban.model.metadata.CreatedAt
final class DoobieAuthDb extends AuthDb[ConnectionIO] { final class DoobieAuthDb[F[_]: Sync](
val argon2: Argon2[F],
val rsaDecryption: RsaDecryption[F],
val sqlStates: CuratedSqlStates,
val implTypes: ImplTypes
) extends AuthDb[ConnectionIO, F]:
private val Sql = new DoobieAuthDb.Sql(implTypes)
/** @inheritDocs /** @inheritDocs
*/ */
override def getUser(name: AccountName): ConnectionIO[Option[User]] = ??? override def getUser(name: AccountName): ConnectionIO[Option[User]] =
Sql.getUser(name).option.flatMap {
case Some((id, status, createdAt)) =>
getUserPermissions(id).map { permissions =>
Some(
new User(
id = id,
name = name,
status = status,
permissions = permissions,
createdAt = createdAt
)
)
}
case None => None.pure[ConnectionIO]
}
private def getUserPermissions(id: AccountId): ConnectionIO[PermissionSet] =
for
global <- Sql.getUserGlobalPermissions(id).to[Set]
group <- Sql.getUserGroupPermissions(id).to[Set]
yield PermissionSet(
global = global,
group = group.groupMap(_._1)(_._2)
)
/** @inheritDocs /** @inheritDocs
*/ */
override def createUser( override def createUser(
name: AccountName, name: AccountName,
initialPassword: Password, initialPassword: Password,
initialPermissions: PermissionSet initialPermissions: PermissionSet,
): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ??? createdAt: CreatedAt
): F[ConnectionIO[Either[DbError, User]]] =
// Prepare Password
exchangeEncryptedForHash(initialPassword.unwrap()).map { passwordHash =>
// Insert the base user record.
val accountId = AccountId.generate()
Sql
.insertUser(accountId, name, AccountStatus.Active, createdAt)
.run
.attemptSqlState
.flatMap {
case Left(sqlState) =>
if sqlState.value == sqlStates.uniqueViolation.value then
Left(DbError.AccountAlreadyExists(name, AccountType.User))
.pure[ConnectionIO]
else Left(DbError.GenericDatabaseError(sqlState)).pure[ConnectionIO]
case Right(_) =>
for
// Insert the user's credential.
_ <- Sql
.insertCredential(
id = CredentialId.generate(),
hash = passwordHash,
accountId = accountId,
accountType = AccountType.User,
credentialType = CredentialType.Password,
status = CredentialStatus.Active,
effective = None,
createdAt = createdAt
)
.run
// Insert the user's global permissions.
_ <- Sql.insertUserGlobalPermissions(
accountId,
initialPermissions.global
)
// Insert the user's group permissions.
_ <- Sql.insertUserGroupPermissions(
accountId,
initialPermissions.group
)
yield Right(
new User(
id = accountId,
name = name,
status = AccountStatus.Active,
permissions = initialPermissions,
createdAt = createdAt
)
)
}
}
private def exchangeEncryptedForHash(rsa: RsaEncryptedBytes): F[Argon2Hash] =
rsaDecryption.decrypt(rsa).flatMap(argon2.calculateHash)
/** @inheritDocs /** @inheritDocs
*/ */
override def setUserPassword( override def setUserPassword(
id: AccountId, id: AccountId,
newPassword: Password newPassword: Password
): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ??? ): EitherT[ConnectionIO, DbError, User] = ???
/** @inheritDocs /** @inheritDocs
*/ */
override def listActiveUsers(): ConnectionIO[List[User]] = ??? override def listActiveUsers(): fs2.Stream[ConnectionIO, User] =
Sql
.listUsersForStatus(AccountStatus.Active)
.stream
.evalMap { case (id, name, createdAt) =>
getUserPermissions(id).map { permissions =>
new User(
id = id,
name = name,
status = AccountStatus.Active,
permissions = permissions,
createdAt = createdAt
)
}
}
/** @inheritDocs /** @inheritDocs
*/ */
@ -54,7 +168,7 @@ final class DoobieAuthDb extends AuthDb[ConnectionIO] {
name: AccountName, name: AccountName,
owner: AccountId, owner: AccountId,
initialPermissions: PermissionSet initialPermissions: PermissionSet
): EitherT[[A] =>> ConnectionIO[A], DbError, NewServiceAccount] = ??? ): EitherT[ConnectionIO, DbError, NewServiceAccount] = ???
/** @inheritDocs /** @inheritDocs
*/ */
@ -108,4 +222,94 @@ final class DoobieAuthDb extends AuthDb[ConnectionIO] {
override def expireCredential(id: CredentialId) override def expireCredential(id: CredentialId)
: EitherT[[A] =>> ConnectionIO[A], DbError, Unit] = ??? : EitherT[[A] =>> ConnectionIO[A], DbError, Unit] = ???
} object DoobieAuthDb:
class Sql(implTypes: ImplTypes):
import implTypes.given
def getUser(name: AccountName)
: Query0[(AccountId, AccountStatus, CreatedAt)] =
sql"""
SELECT id, status, created_at
FROM user
WHERE name = $name
""".query[(AccountId, AccountStatus, CreatedAt)]
def getUserGlobalPermissions(id: AccountId): Query0[Permission] =
sql"""
SELECT permission
FROM user_permission_global
WHERE user_id = $id
""".query[Permission]
def getUserGroupPermissions(id: AccountId)
: Query0[(GroupName, Permission)] =
sql"""
SELECT group_name, permission
FROM user_permission_group
WHERE user_id = $id
""".query[(GroupName, Permission)]
def listUsersForStatus(status: AccountStatus)
: Query0[(AccountId, AccountName, CreatedAt)] =
sql"""
SELECT id, name, created_at
FROM user
WHERE status = $status
""".query[(AccountId, AccountName, CreatedAt)]
def insertUser(
id: AccountId,
name: AccountName,
status: AccountStatus,
createdAt: CreatedAt
): Update0 =
sql"""
INSERT INTO user(id, name, status, created_at)
VALUES ($id, $name, $status, $createdAt)
""".update
def insertCredential(
id: CredentialId,
hash: Argon2Hash,
accountId: AccountId,
accountType: AccountType,
credentialType: CredentialType,
status: CredentialStatus,
effective: Option[CredentialEffectivity],
createdAt: CreatedAt
): Update0 =
sql"""
INSERT INTO credential(credential_id, credential_hash, account_id,
account_type, credential_type, status, effective_at, effective_through,
created_at)
VALUES ($id, $hash, $accountId, $accountType, $credentialType, $status,
${effective.map(_.startDate)}, ${effective.flatMap(_.endDate)},
$createdAt)
""".update
def insertUserGlobalPermissions(
id: AccountId,
permissions: Set[Permission]
): ConnectionIO[Int] =
val command = """
INSERT INTO user_permission_global (user_id, permission) VALUES (?, ?)
""".stripMargin
Update[(AccountId, Permission)](command)
.updateMany(permissions.map(p => id -> p).toList)
def insertUserGroupPermissions(
id: AccountId,
permissions: Map[GroupName, Set[Permission]]
): ConnectionIO[Int] =
val command = """
INSERT INTO user_permission_group (user_id, group_name, permission) VALUES (?, ?, ?)
""".stripMargin
Update[(AccountId, GroupName, Permission)](command)
.updateMany(permissions.flatMap { case (gn, ps) =>
ps.map(p => (id, gn, p))
}.toList)
end Sql
end DoobieAuthDb

View file

@ -11,10 +11,10 @@ import gs.smolban.model.metadata.Tag
import gs.smolban.model.metadata.TagValue import gs.smolban.model.metadata.TagValue
final class DoobieTagDb( final class DoobieTagDb(
sqlStates: CuratedSqlStates sqlStates: CuratedSqlStates,
implTypes: ImplTypes
) extends TagDb[ConnectionIO]: ) extends TagDb[ConnectionIO]:
private val Sql = new DoobieTagDb.Sql(implTypes)
import DoobieTagDb.Sql
/** @inheritdoc /** @inheritdoc
*/ */
@ -46,7 +46,8 @@ final class DoobieTagDb(
object DoobieTagDb: object DoobieTagDb:
private object Sql: private class Sql(implTypes: ImplTypes):
import implTypes.given
def createTag( def createTag(
tag: TagValue, tag: TagValue,

View file

@ -2,16 +2,80 @@ package gs.smolban.db.doobie
import DoobieTypes.ErrorMessages import DoobieTypes.ErrorMessages
import doobie.* import doobie.*
import gs.smolban.model.metadata.CreatedAt import gs.smolban.auth.Argon2Hash
import gs.smolban.auth.CredentialId
import gs.smolban.auth.CredentialStatus
import gs.smolban.auth.CredentialType
import gs.smolban.model.account.AccountId
import gs.smolban.model.account.AccountName
import gs.smolban.model.account.AccountStatus
import gs.smolban.model.account.AccountType
import gs.smolban.model.account.Permission
import gs.smolban.model.group.GroupName
import gs.smolban.model.metadata.TagValue import gs.smolban.model.metadata.TagValue
import gs.smolban.model.ticket.CommentId import gs.smolban.model.ticket.CommentId
import gs.smolban.model.ticket.TicketStatus import gs.smolban.model.ticket.TicketStatus
import gs.uuid.v0.UUID import gs.uuid.v0.UUID
import gs.uuid.v0.UUIDFormat import gs.uuid.v0.UUIDFormat
import java.time.Instant
trait DoobieTypes: trait DoobieTypes:
implicit val accountIdGet: Get[AccountId] =
Get[Array[Byte]].tmap(bytes => AccountId(UUID(UUIDFormat.fromBytes(bytes))))
implicit val accountIdPut: Put[AccountId] =
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
implicit val accountNameGet: Get[AccountName] =
Get[String].temap { value =>
AccountName.validate(value) match
case None => Left(ErrorMessages.invalidAccountName(value))
case Some(s) => Right(s)
}
implicit val accountNamePut: Put[AccountName] =
Put[String].tcontramap(_.toString())
implicit val accountStatusGet: Get[AccountStatus] =
Get[String].temap(dbValue =>
AccountStatus.parse(dbValue) match
case None => Left(ErrorMessages.invalidAccountStatus(dbValue))
case Some(status) => Right(status)
)
implicit val accountStatusPut: Put[AccountStatus] =
Put[String].tcontramap(_.name)
implicit val accountTypeGet: Get[AccountType] =
Get[String].temap(dbValue =>
AccountType.parse(dbValue) match
case None => Left(ErrorMessages.invalidAccountType(dbValue))
case Some(status) => Right(status)
)
implicit val accountTypePut: Put[AccountType] =
Put[String].tcontramap(_.name)
implicit val permissionGet: Get[Permission] =
Get[String].temap(dbValue =>
Permission.parse(dbValue) match
case None => Left(ErrorMessages.invalidPermission(dbValue))
case Some(status) => Right(status)
)
implicit val permissionPut: Put[Permission] =
Put[String].tcontramap(_.name)
implicit val groupNameGet: Get[GroupName] =
Get[String].temap { value =>
GroupName.validate(value) match
case None => Left(ErrorMessages.invalidGroupName(value))
case Some(s) => Right(s)
}
implicit val groupNamePut: Put[GroupName] =
Put[String].tcontramap(_.toString())
implicit val ticketStatusGet: Get[TicketStatus] = implicit val ticketStatusGet: Get[TicketStatus] =
Get[String].temap(dbValue => Get[String].temap(dbValue =>
TicketStatus.parse(dbValue) match TicketStatus.parse(dbValue) match
@ -28,18 +92,6 @@ trait DoobieTypes:
implicit val commentIdPut: Put[CommentId] = implicit val commentIdPut: Put[CommentId] =
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID())) Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
implicit val createdAtGet: Get[CreatedAt] =
Get[Long].tmap(CreatedAt.fromMilliseconds)
implicit val createdAtPut: Put[CreatedAt] =
Put[Long].tcontramap(_.toMilliseconds())
implicit val instantGet: Get[Instant] =
Get[Long].tmap(Instant.ofEpochMilli)
implicit val instantPut: Put[Instant] =
Put[Long].tcontramap(_.toEpochMilli())
implicit val tagValueGet: Get[TagValue] = implicit val tagValueGet: Get[TagValue] =
Get[String].temap { value => Get[String].temap { value =>
TagValue.validate(value) match TagValue.validate(value) match
@ -50,16 +102,78 @@ trait DoobieTypes:
implicit val tagValuePut: Put[TagValue] = implicit val tagValuePut: Put[TagValue] =
Put[String].tcontramap(_.toString()) Put[String].tcontramap(_.toString())
implicit val credentialIdGet: Get[CredentialId] =
Get[Array[Byte]].tmap(bytes =>
CredentialId(UUID(UUIDFormat.fromBytes(bytes)))
)
implicit val credentialIdPut: Put[CredentialId] =
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
implicit val argon2HashPut: Put[Argon2Hash] =
Put[String].tcontramap(_.encode())
implicit val argon2HashGet: Get[Argon2Hash] =
Get[String].temap(dbValue =>
Argon2Hash.decode(dbValue) match
case None => Left(ErrorMessages.invalidAccountStatus(dbValue))
case Some(hash) => Right(hash)
)
implicit val credentialTypeGet: Get[CredentialType] =
Get[String].temap(dbValue =>
CredentialType.parse(dbValue) match
case None => Left(ErrorMessages.invalidCredentialType(dbValue))
case Some(status) => Right(status)
)
implicit val credentialTypePut: Put[CredentialType] =
Put[String].tcontramap(_.name)
implicit val credentialStatusGet: Get[CredentialStatus] =
Get[String].temap(dbValue =>
CredentialStatus.parse(dbValue) match
case None => Left(ErrorMessages.invalidCredentialStatus(dbValue))
case Some(status) => Right(status)
)
implicit val credentialStatusPut: Put[CredentialStatus] =
Put[String].tcontramap(_.name)
object DoobieTypes extends DoobieTypes: object DoobieTypes extends DoobieTypes:
object ErrorMessages: object ErrorMessages:
def invalidAccountName(candidate: String): String =
s"'$candidate' is not a valid account name."
def invalidAccountStatus(candidate: String): String =
s"'$candidate' is not a valid account status."
def invalidAccountType(candidate: String): String =
s"'$candidate' is not a valid account type."
def invalidPermission(candidate: String): String =
s"'$candidate' is not a valid permission."
def invalidGroupName(candidate: String): String =
s"'$candidate' is not a valid group name."
def invalidTagValue(candidate: String): String = def invalidTagValue(candidate: String): String =
s"'$candidate' is not a valid tag value." s"'$candidate' is not a valid tag value."
def invalidTicketStatus(candidate: String): String = def invalidTicketStatus(candidate: String): String =
s"'$candidate' is not a valid ticket status." s"'$candidate' is not a valid ticket status."
def invalidArgon2Hash(candidate: String): String =
s"'$candidate' is not a valid Argon2 hash."
def invalidCredentialType(candidate: String): String =
s"'$candidate' is not a valid credential type."
def invalidCredentialStatus(candidate: String): String =
s"'$candidate' is not a valid credential status."
end ErrorMessages end ErrorMessages
end DoobieTypes end DoobieTypes

View file

@ -0,0 +1,16 @@
package gs.smolban.db.doobie
import doobie.*
import gs.smolban.model.metadata.CreatedAt
import java.time.Instant
import java.time.LocalDate
/** Abstraction for supporting implementation-specific types.
*/
trait ImplTypes:
given createdAtGet: Get[CreatedAt]
given createdAtPut: Put[CreatedAt]
given instantGet: Get[Instant]
given instantPut: Put[Instant]
given localDateGet: Get[LocalDate]
given localDatePut: Put[LocalDate]

View file

@ -0,0 +1,28 @@
package gs.smolban.db.doobie
import doobie.*
import gs.smolban.model.metadata.CreatedAt
import java.time.Instant
import java.time.LocalDate
/** SQLite implementation of specific types.
*/
final class SqliteImplTypes extends ImplTypes:
given createdAtGet: Get[CreatedAt] =
Get[Long].tmap(CreatedAt.fromMilliseconds)
given createdAtPut: Put[CreatedAt] =
Put[Long].tcontramap(_.toMilliseconds())
given instantGet: Get[Instant] =
Get[Long].tmap(Instant.ofEpochMilli)
given instantPut: Put[Instant] =
Put[Long].tcontramap(_.toEpochMilli())
given localDateGet: Get[LocalDate] =
Get[String].tmap(LocalDate.parse)
given localDatePut: Put[LocalDate] =
Put[String].tcontramap(_.toString())

View file

@ -29,7 +29,8 @@ class DoobieTagDbTests extends munit.FunSuite:
test(name)(f.unsafeRunSync()) test(name)(f.unsafeRunSync())
private val tagDb: TagDb[ConnectionIO] = new DoobieTagDb( private val tagDb: TagDb[ConnectionIO] = new DoobieTagDb(
CuratedSqlStates.sqlite CuratedSqlStates.sqlite,
new SqliteImplTypes
) )
private val clock = Clock.systemDefaultZone() private val clock = Clock.systemDefaultZone()