Compare commits

..
4 Commits
Author SHA1 Message Date
artemis d90ae86e1f Processing new bid MQTT messages 2026-09-21 15:03:45 +03:00
artemis e9d1f93723 Auctions show 2026-09-18 19:38:38 +03:00
artemis d1360b2917 Some ETag refactoring 2026-09-18 19:36:53 +03:00
artemis e144a7f3c0 Refactored checkFresh 2026-09-17 15:08:38 +03:00
10 changed files with 177 additions and 48 deletions
+31 -5
View File
@@ -3,23 +3,25 @@ package controllers
import actors.auctions.AuctionPublisher import actors.auctions.AuctionPublisher
import controllers.authentication.AuthenticatedAction import controllers.authentication.AuthenticatedAction
import dao.{AuctionsDAO, AuthTokensDAO} import dao.{AuctionsDAO, AuthTokensDAO}
import models.AuctionStatus.Ready
import models.{Auction, User}
import org.apache.pekko.actor.typed.ActorRef import org.apache.pekko.actor.typed.ActorRef
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.* import play.api.libs.json.*
import play.api.mvc.{BaseController, ControllerComponents} import play.api.mvc.{BaseController, ControllerComponents}
import javax.inject.{Inject, Singleton} import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import models.Auction
import caching.{CheckFresh, DigestEtagable, Etagable}
import java.io.DataOutputStream
@Singleton @Singleton
class AuctionsController @Inject()(val controllerComponents: ControllerComponents, class AuctionsController @Inject()(val controllerComponents: ControllerComponents,
val auctionsDAO: AuctionsDAO, val auctionsDAO: AuctionsDAO,
val authTokensDAO: AuthTokensDAO, val authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction, authenticatedAction: AuthenticatedAction,
val auctionPublisher: ActorRef[AuctionPublisher.Command], val auctionPublisher: ActorRef[AuctionPublisher.Command])
implicit val ec: ExecutionContext) extends BaseController { (implicit val ec: ExecutionContext)
extends BaseController with CheckFresh {
def index = authenticatedAction.async { request => def index = authenticatedAction.async { request =>
for { for {
auctions <- auctionsDAO.list_available(request.user) auctions <- auctionsDAO.list_available(request.user)
@@ -27,6 +29,16 @@ class AuctionsController @Inject()(val controllerComponents: ControllerComponent
} yield Ok(json) } yield Ok(json)
} }
def show(id: Long) = authenticatedAction.async { implicit request =>
auctionsDAO.showAvailable(request.user, id).map { auctionOpt =>
auctionOpt.fold(NotFound) { auction =>
checkFresh(auction) {
Ok(Json.toJson(auction)).withHeaders("etag" -> auction.etag)
}
}
}
}
def create = authenticatedAction.async(parse.json) { implicit request => def create = authenticatedAction.async(parse.json) { implicit request =>
val auctionCreateDataResult = request.body.validate[AuctionCreateData] val auctionCreateDataResult = request.body.validate[AuctionCreateData]
auctionCreateDataResult.fold( auctionCreateDataResult.fold(
@@ -44,4 +56,18 @@ class AuctionsController @Inject()(val controllerComponents: ControllerComponent
private case class AuctionCreateData(name: String, startingPrice: BigDecimal) private case class AuctionCreateData(name: String, startingPrice: BigDecimal)
private given Reads[AuctionCreateData] = Json.reads[AuctionCreateData] private given Reads[AuctionCreateData] = Json.reads[AuctionCreateData]
private given DigestEtagable[Auction] with {
override protected def writeDigestData(a: Auction, s: DataOutputStream): Unit = {
s.writeLong(a.id.getOrElse(0))
s.writeLong(a.authorId)
s.writeUTF(a.gid)
s.writeUTF(a.name)
s.writeLong(a.createdAt.getEpochSecond)
s.writeLong(a.currentBidderId.getOrElse(0))
a.currentBid.foreach(b => s.writeLong((b * 1000).toLong))
a.currentBidAt.foreach(ba => s.writeLong(ba.getEpochSecond))
}
}
} }
+14 -22
View File
@@ -1,17 +1,15 @@
package controllers package controllers
import javax.inject.*
import play.api.* import play.api.*
import play.api.mvc.* import _root_.controllers.authentication.{AuthenticatedAction, AuthenticatedRequest}
import authentication.{AuthenticatedAction, AuthenticatedRequest} import _root_.controllers.caching.{CheckFresh, DigestEtagable}
import caching.{Etagable, checkFresh}
import models.User import models.User
import play.api.libs.json.Json import play.api.libs.json.Json
import play.api.mvc.*
import java.nio.ByteBuffer import java.io.DataOutputStream
import java.nio.charset.StandardCharsets.UTF_8 import java.nio.charset.StandardCharsets.UTF_8
import java.security.MessageDigest import javax.inject.*
import java.util.Base64
/** /**
* This controller creates an `Action` to handle HTTP requests to the * This controller creates an `Action` to handle HTTP requests to the
@@ -20,7 +18,7 @@ import java.util.Base64
@Singleton @Singleton
class HomeController @Inject()(val controllerComponents: ControllerComponents, class HomeController @Inject()(val controllerComponents: ControllerComponents,
authenticatedAction: AuthenticatedAction) authenticatedAction: AuthenticatedAction)
extends BaseController with Logging { extends BaseController with Logging with CheckFresh {
/** /**
* Create an Action to render an HTML page. * Create an Action to render an HTML page.
@@ -35,23 +33,17 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents,
def userData = authenticatedAction { implicit request: AuthenticatedRequest[AnyContent] => def userData = authenticatedAction { implicit request: AuthenticatedRequest[AnyContent] =>
val user = request.user val user = request.user
request.checkFresh(user) { checkFresh(user) {
// TODO: put the max-age somewhere into config // TODO: put the max-age somewhere into config
Ok(Json.toJson(user)).withHeaders("Etag" -> user.etag, "Cache-Control" -> "private, max-age=15, must-revalidate") Ok(Json.toJson(user)).withHeaders("Cache-Control" -> "private, max-age=15, must-revalidate")
} }
} }
private given Etagable[models.User] with { private given DigestEtagable[models.User] with {
private val base64 = Base64.getUrlEncoder override protected def writeDigestData(a: User, s: DataOutputStream): Unit = {
s.writeLong(a.id.getOrElse(0))
extension (a: User) s.write(a.gid.getBytes(UTF_8))
override def etag: String = { s.write(a.username.getBytes(UTF_8))
val md = MessageDigest.getInstance("SHA-1") }
md.update(ByteBuffer.allocate(8).putLong(a.id.getOrElse(0)))
md.update(a.gid.getBytes(UTF_8))
md.update(a.username.getBytes(UTF_8))
base64.encodeToString(md.digest())
}
} }
} }
+39 -8
View File
@@ -3,24 +3,55 @@ package controllers.caching
import play.api.mvc.Results.NotModified import play.api.mvc.Results.NotModified
import play.api.mvc.{Request, Result} import play.api.mvc.{Request, Result}
import java.io.{ByteArrayOutputStream, DataOutputStream, OutputStream}
import java.security.{DigestOutputStream, MessageDigest}
import java.util.Base64
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.Using
trait Etagable[A]: trait Etagable[A]:
extension (a: A) def etag: String extension (a: A) def etag: String
extension [A, R[X] <: Request[X], E: Etagable](request: R[A]) { trait DigestEtagable[A] extends Etagable[A]:
def checkFreshAsync(entity: E)(onStale: => Future[Result])(using ec: ExecutionContext): Future[Result] = { private val base64 = Base64.getUrlEncoder.withoutPadding
compareEtag(entity).fold(onStale)(
etag => Future.successful(NotModified.withHeaders("ETag" -> etag)) protected def writeDigestData(a: A, s: DataOutputStream): Unit
extension (a: A) {
override def etag: String = {
val md = MessageDigest.getInstance("SHA-1")
Using(DataOutputStream(DigestOutputStream(OutputStream.nullOutputStream(), md))) { dos =>
writeDigestData(a, dos)
dos.flush()
}
base64.encodeToString(md.digest())
}
}
trait CheckFresh {
protected def checkFreshAsync[A, E: Etagable](entity: E)(onStale: => Future[Result])(using request: Request[A], ec: ExecutionContext): Future[Result] = {
compareEtag(request, entity).fold(
entityEtag => onStale.map(_.withHeaders("etag" -> entityEtag)),
requestEtag => Future.successful(notModified(requestEtag))
) )
} }
def checkFresh(entity: E)(onStale: => Result): Result = { protected def checkFresh[A, E: Etagable](entity: E)(onStale: => Result)(using request: Request[A]): Result = {
compareEtag(entity).fold(onStale)( compareEtag(request, entity).fold(
etag => NotModified.withHeaders("ETag" -> etag) entityEtag => onStale.withHeaders("etag" -> entityEtag),
requestEtag => notModified(requestEtag)
) )
} }
private def compareEtag(entity: E) = request.headers.get("If-None-Match").filter(e => e == entity.etag) private def compareEtag[A, E: Etagable](request: Request[A], entity: E) = {
val entityEtag = entity.etag
request.headers.get("If-None-Match").filter(e => e == entityEtag) match {
case Some(etag) => Right(etag)
case None => Left(entityEtag)
}
}
private def notModified(etag: String) = NotModified.withHeaders("etag" -> etag)
} }
+30 -9
View File
@@ -1,31 +1,35 @@
package dao package dao
import models.{Auction, AuctionStatus}
import schemas.Auctions
import schemas.given
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import slick.jdbc.PostgresProfile import slick.jdbc.PostgresProfile
import slick.jdbc.PostgresProfile.api.* import slick.jdbc.PostgresProfile.api.*
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.Random import scala.util.Random
import models.{Auction, AuctionStatus, User}
import schemas.Auctions
import schemas.given
class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext) class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] { extends HasDatabaseConfigProvider[PostgresProfile] {
def list_available(user: models.User): Future[Seq[Auction]] = { def list_available(user: User): Future[Seq[Auction]] = {
db.run(availableScope(user).result)
}
def showAvailable(user: User, id: Long): Future[Option[Auction]] = {
db.run { db.run {
Auctions.filter(row => (row.status === AuctionStatus.Ready) && (row.authorId =!= user.id.get)).result availableScope(user).filter(_.id === id).take(1).result.headOption
} }
} }
def list_created_by(user: models.User): Future[Seq[Auction]] = { def list_created_by(user: User): Future[Seq[Auction]] = {
db.run(Auctions.filter(_.authorId === user.id).result) db.run(Auctions.filter(_.authorId === user.id).result)
} }
def create(name: String, startingPrice: BigDecimal, author: models.User): Future[Auction] = { def create(name: String, startingPrice: BigDecimal, author: User): Future[Auction] = {
val auction = Auction( val auction = Auction(
id = None, name = name, id = None, name = name,
gid = Random.alphanumeric.take(24).mkString, gid = Random.alphanumeric.take(24).mkString,
@@ -45,6 +49,23 @@ class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvid
def changeStatus(auctionId: Long, status: AuctionStatus): Future[Boolean] = { def changeStatus(auctionId: Long, status: AuctionStatus): Future[Boolean] = {
db.run(Auctions.filter(_.id === auctionId).map(_.status).update(status)) db.run(Auctions.filter(_.id === auctionId).map(_.status).update(status))
.map(_ == 1) .map(_ == 1)
}
def find_by_gid(gid: String): Future[Option[Auction]] = {
db.run(Auctions.filter(_.gid === gid).take(1).result.headOption)
}
def newBid(auction: Auction, bidder: User, bid: BigDecimal, timestamp: Instant): Future[Option[Auction]] = {
db.run {
Auctions.filter(_.id === auction.id.get).map{ it => (it.currentBid, it.currentBidAt, it.currentBidderId) }
.update((Some(bid), Some(timestamp), Some(bidder.id.get)))
.andThen { Auctions.filter(_.id === auction.id.get).result.headOption }
.transactionally
}
}
private def availableScope(user: User) = {
Auctions.filter(row => (row.status === AuctionStatus.Ready) && (row.authorId =!= user.id.get))
} }
} }
+4
View File
@@ -69,6 +69,10 @@ class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider,
} }
} }
def find_by_gid(gid: String): Future[Option[User]] = {
db.run(Users.filter(_.gid === gid).take(1).result.headOption)
}
private def argonHash(data: String) = argon2.hash(ArgonParameters.iterations, ArgonParameters.memory, ArgonParameters.parallelism, data.getBytes(UTF_8)) private def argonHash(data: String) = argon2.hash(ArgonParameters.iterations, ArgonParameters.memory, ArgonParameters.parallelism, data.getBytes(UTF_8))
} }
+1 -1
View File
@@ -36,7 +36,7 @@ object AuctionStatus {
case class Auction(id: Option[Long], name: String, status: AuctionStatus, gid: String, authorId: Long, case class Auction(id: Option[Long], name: String, status: AuctionStatus, gid: String, authorId: Long,
startingPrice: BigDecimal, currentBid: Option[BigDecimal], startingPrice: BigDecimal, currentBid: Option[BigDecimal],
currentBidderId: Option[Int], currentBidAt: Option[Instant], currentBidderId: Option[Long], currentBidAt: Option[Instant],
createdAt: Instant) createdAt: Instant)
object Auction { object Auction {
+17 -2
View File
@@ -8,8 +8,10 @@ import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.Logging import play.api.Logging
import play.api.libs.concurrent.ActorModule import play.api.libs.concurrent.ActorModule
import play.api.libs.json._
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import scala.util.matching.Regex
object MessageProcessorActor extends ActorModule with Logging { object MessageProcessorActor extends ActorModule with Logging {
sealed trait Command sealed trait Command
@@ -18,14 +20,16 @@ object MessageProcessorActor extends ActorModule with Logging {
override type Message = Command override type Message = Command
private final val newBidPattern: Regex = """notify/new_bid/(\w+)""".r
@Singleton @Singleton
class Reg @Inject()(private val mqttMessageProcessorActor: ActorRef[Command]) class Reg @Inject()(private val mqttMessageProcessorActor: ActorRef[Command])
@Provides @Provides
def create(client: Mqtt5AsyncClient): Behavior[Command] = Behaviors.setup { context => def create(client: Mqtt5AsyncClient, newBidService: services.auctions.NewBid): Behavior[Command] = Behaviors.setup { context =>
client.connect().whenComplete { (_action, _throwable) => client.connect().whenComplete { (_action, _throwable) =>
client.subscribeWith() client.subscribeWith()
.topicFilter("test/+") .topicFilter("notify/new_bid/+")
.callback{ (publish) => .callback{ (publish) =>
context.self ! MqttMessageReceived(publish) context.self ! MqttMessageReceived(publish)
} }
@@ -37,6 +41,17 @@ object MessageProcessorActor extends ActorModule with Logging {
val topic = publish.getTopic val topic = publish.getTopic
val payload = String(publish.getPayloadAsBytes, StandardCharsets.UTF_8) val payload = String(publish.getPayloadAsBytes, StandardCharsets.UTF_8)
logger.info(s"Received message $topic $payload") logger.info(s"Received message $topic $payload")
topic.toString match {
case newBidPattern(auctionGid) =>
Json.parse(payload).validate[services.auctions.NewBid.Request] match {
case JsSuccess(request, _) => newBidService.perform(auctionGid, request)
case JsError(errors) =>
logger.warn(errors.toString())
}
println(s"New bid $auctionGid")
}
Behaviors.same Behaviors.same
case Stop => Behaviors.stopped{ () => case Stop => Behaviors.stopped{ () =>
client.disconnect() client.disconnect()
+1 -1
View File
@@ -47,7 +47,7 @@ class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") {
def authorId = column[Long]("author_id") def authorId = column[Long]("author_id")
def startingPrice = column[BigDecimal]("starting_price", O.SqlType("DECIMAL(20, 3)")) def startingPrice = column[BigDecimal]("starting_price", O.SqlType("DECIMAL(20, 3)"))
def currentBid = column[Option[BigDecimal]]("current_bid", O.SqlType("DECIMAL(20, 3)")) def currentBid = column[Option[BigDecimal]]("current_bid", O.SqlType("DECIMAL(20, 3)"))
def currentBidderId = column[Option[Int]]("current_bidder_id") def currentBidderId = column[Option[Long]]("current_bidder_id")
def currentBidAt = column[Option[Instant]]("current_bid_at") def currentBidAt = column[Option[Instant]]("current_bid_at")
def createdAt = column[Instant]("created_at") def createdAt = column[Instant]("created_at")
+39
View File
@@ -0,0 +1,39 @@
package services.auctions
import dao.{AuctionsDAO, UsersDAO}
import models.Auction
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{JsPath, JsResult, Reads}
import java.time.Instant
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
object NewBid {
case class Request(bid: BigDecimal, bidderGid: String, timestamp: Instant)
given Reads[Request] = (
(JsPath \ "bid").read[BigDecimal] and
(JsPath \ "bidder_gid").read[String] and
(JsPath \ "timestamp").read[Instant]
)(Request.apply)
}
class NewBid @Inject()(auctionsDao: AuctionsDAO, usersDAO: UsersDAO)(using ec: ExecutionContext) {
import NewBid.Request
def perform(auctionGid: String, request: Request): Future[Option[Auction]] = {
val dataFuture = for {
auction <- auctionsDao.find_by_gid(auctionGid)
bidder <- usersDAO.find_by_gid(request.bidderGid)
} yield (auction, bidder)
dataFuture.flatMap {
case (Some(auction), Some(bidder)) =>
auctionsDao.newBid(auction, bidder, bid = request.bid, timestamp = request.timestamp)
case (_, _) =>
// One of them (or both) aren't found
Future.successful(None)
}
}
}
+1
View File
@@ -15,6 +15,7 @@ GET /user_data controllers.HomeController.userData()
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
GET /auctions controllers.AuctionsController.index() GET /auctions controllers.AuctionsController.index()
GET /auctions/:id controllers.AuctionsController.show(id: Long)
POST /auctions controllers.AuctionsController.create() POST /auctions controllers.AuctionsController.create()
-> /-devel controllers.devel.DevRouter -> /-devel controllers.devel.DevRouter