Compare commits

...
2 Commits
Author SHA1 Message Date
artemis 88b187e6a3 Auction status change and confirmation 2026-08-27 20:16:43 +03:00
artemis cab27eddab Sending actor data to MQTT 2026-08-27 15:28:02 +03:00
11 changed files with 198 additions and 60 deletions
+103
View File
@@ -0,0 +1,103 @@
package actors.auctions
import com.google.inject.Provides
import com.hivemq.client.mqtt.datatypes.MqttQos
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient
import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish
import dao.AuctionsDAO
import models.{Auction, AuctionStatus, User}
import mqttClient.MqttClientFactory
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.{Configuration, Logging}
import play.api.libs.concurrent.ActorModule
import play.api.libs.json.{JsBoolean, JsObject, JsValue, Json}
import java.nio.charset.StandardCharsets
import javax.inject.Singleton
import scala.jdk.OptionConverters.RichOptional
import scala.util.Random
object AuctionPublisher extends ActorModule with Logging {
sealed trait Command
private case object Ready extends Command
case class Publish(auction: Auction, author: User) extends Command
private case class AuctionConfirmed(id: Int, responseTopic: String) extends Command
private final val topic = "auctions/created"
override type Message = Command
@Provides
@Singleton
def apply(configuration: Configuration, auctionsDAO: AuctionsDAO): Behavior[Message] = Behaviors.setup { context =>
val client = MqttClientFactory.createClient(configuration, "-auction-publisher")
client.connect().whenComplete { (_action, _throwable) =>
context.self ! Ready
}
idle(context.self, client, auctionsDAO)
}
private def idle(self: ActorRef[Message], client: Mqtt5AsyncClient, auctionsDAO: AuctionsDAO, waitingPublishes: Seq[Publish] = List()): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case p: Publish =>
idle(self, client, auctionsDAO, waitingPublishes :+ p)
case Ready =>
for { publish <- waitingPublishes } publishAuction(client, publish.auction, publish.author, self)
ready(self, client, auctionsDAO)
case message @ AuctionConfirmed(_, _) =>
logger.warn(s"Received impossible message $message")
idle(self, client, auctionsDAO, waitingPublishes)
}
private def ready(self: ActorRef[Message], client: Mqtt5AsyncClient, auctionsDAO: AuctionsDAO): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Publish(auction, author) =>
publishAuction(client, auction, author, self)
ready(self, client, auctionsDAO)
case AuctionConfirmed(auctionId, responseTopic) =>
client.unsubscribeWith().topicFilter(responseTopic).send()
auctionsDAO.changeStatus(auctionId, AuctionStatus.Ready)
ready(self, client, auctionsDAO)
case Ready =>
logger.warn("Message Ready received after having already received it before")
ready(self, client, auctionsDAO)
}
private def publishAuction(client: Mqtt5AsyncClient, auction: Auction, author: User, self: ActorRef[Command]): Unit = {
val payload = Json.toBytes(auction_payload(auction, author))
val responseTopic = s"auction-validate/${auction.gid}-${Random.alphanumeric.take(8).mkString}"
client.subscribeWith()
.topicFilter(responseTopic)
.callback{ message =>
logger.info(s"Receiving message on ${message.getTopic}")
logger.info(message.getPayloadAsBytes.mkString("Array(", ", ", ")"))
message.getPayload.toScala
.map(StandardCharsets.UTF_8.decode(_).toString)
.map(Json.parse)
.flatMap(v => (v \ "ok").toOption)
.map{
case b: JsBoolean if b.value => self ! AuctionConfirmed(auction.id.get, responseTopic)
// TODO: add error handling
}
}
.send()
val publish = Mqtt5Publish.builder()
.topic(topic)
.payload(payload)
.responseTopic(responseTopic)
.qos(MqttQos.EXACTLY_ONCE)
.build()
logger.info(s"Publishing auction ${auction.id.getOrElse(-1)}")
client.publish(publish)
}
private def auction_payload(auction: Auction, author: User): JsObject = {
Json.obj(
"gid" -> auction.gid,
"author_gid" -> author.gid,
"starting_price" -> auction.startingPrice
)
}
}
+9 -13
View File
@@ -1,8 +1,10 @@
package controllers package controllers
import actors.auctions.AuctionPublisher
import controllers.authentication.AuthenticatedAction import controllers.authentication.AuthenticatedAction
import dao.{AuctionsDAO, AuthTokensDAO} import dao.{AuctionsDAO, AuthTokensDAO}
import models.Auction import models.Auction
import org.apache.pekko.actor.typed.ActorRef
import play.api.libs.functional.syntax.toFunctionalBuilderOps import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{JsError, JsPath, JsValue, Json, Reads, Writes} import play.api.libs.json.{JsError, JsPath, JsValue, Json, Reads, Writes}
import play.api.mvc.{BaseController, ControllerComponents} import play.api.mvc.{BaseController, ControllerComponents}
@@ -12,7 +14,11 @@ import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
@Singleton @Singleton
class AuctionsController @Inject()(val controllerComponents: ControllerComponents, val auctionsDAO: AuctionsDAO, val authTokensDAO: AuthTokensDAO, implicit val ec: ExecutionContext) extends BaseController { class AuctionsController @Inject()(val controllerComponents: ControllerComponents,
val auctionsDAO: AuctionsDAO,
val authTokensDAO: AuthTokensDAO,
val auctionPublisher: ActorRef[AuctionPublisher.Command],
implicit val ec: ExecutionContext) extends BaseController {
def create = Action.andThen(AuthenticatedAction(authTokensDAO)).async(parse.json) { implicit request => def create = Action.andThen(AuthenticatedAction(authTokensDAO)).async(parse.json) { implicit request =>
val auctionCreateDataResult = request.body.validate[AuctionCreateData] val auctionCreateDataResult = request.body.validate[AuctionCreateData]
auctionCreateDataResult.fold( auctionCreateDataResult.fold(
@@ -22,24 +28,14 @@ class AuctionsController @Inject()(val controllerComponents: ControllerComponent
auction <- auctionsDAO.create(auctionCreateData.name, auctionCreateData.startingPrice, request.user) auction <- auctionsDAO.create(auctionCreateData.name, auctionCreateData.startingPrice, request.user)
json = Json.toJson(auction) json = Json.toJson(auction)
response = Created(json) response = Created(json)
_ = auctionPublisher ! AuctionPublisher.Publish(auction, request.user)
} yield response } yield response
} }
) )
} }
case class AuctionCreateData(name: String, startingPrice: BigDecimal) private case class AuctionCreateData(name: String, startingPrice: BigDecimal)
private implicit val auctionCreateDataReads: Reads[AuctionCreateData] = ( private implicit val auctionCreateDataReads: Reads[AuctionCreateData] = (
(JsPath \ "name").read[String] and (JsPath \ "starting_price").read[BigDecimal] (JsPath \ "name").read[String] and (JsPath \ "starting_price").read[BigDecimal]
)(AuctionCreateData.apply) )(AuctionCreateData.apply)
private implicit val auctionWrites: Writes[Auction] = new Writes[Auction] {
override def writes(a: Auction): JsValue = Json.obj(
"id" -> a.id,
"name" -> a.name,
"gid" -> a.gid,
"starting_price" -> a.startingPrice,
"current_bid" -> a.currentBid,
"current_bidder_id" -> a.currentBidderId,
"current_bid_at" -> a.currentBidAt
)
}
} }
+10 -3
View File
@@ -1,15 +1,16 @@
package dao package dao
import models.Auction import models.{Auction, AuctionStatus}
import schemas.Auctions import schemas.Auctions
import schemas.auctionStatusColumnType
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 utils.Random
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
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] {
@@ -17,7 +18,8 @@ class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvid
def create(name: String, startingPrice: BigDecimal, author: models.User): Future[Auction] = { def create(name: String, startingPrice: BigDecimal, author: models.User): Future[Auction] = {
val auction = Auction( val auction = Auction(
id = None, name = name, id = None, name = name,
gid = Random.randomBase64(24), gid = Random.alphanumeric.take(24).mkString,
status = AuctionStatus.Created,
authorId = author.id.get, authorId = author.id.get,
startingPrice = startingPrice, startingPrice = startingPrice,
currentBid = None, currentBidderId = None, currentBidAt = None, currentBid = None, currentBidderId = None, currentBidAt = None,
@@ -30,4 +32,9 @@ class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvid
def create(auction: Auction) : Future[Auction] = { def create(auction: Auction) : Future[Auction] = {
db.run((Auctions returning Auctions) += auction) db.run((Auctions returning Auctions) += auction)
} }
def changeStatus(auctionId: Int, status: AuctionStatus): Future[Boolean] = {
db.run(Auctions.filter(_.id === auctionId).map(_.status).update(status))
.map(_ == 1)
}
} }
+2 -2
View File
@@ -12,9 +12,9 @@ import java.time.{Duration, Instant}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import models.AuthToken import models.AuthToken
import schemas.AuthTokens import schemas.AuthTokens
import utils.Random
import scala.language.postfixOps import scala.language.postfixOps
import scala.util.Random
class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext) extends HasDatabaseConfigProvider[PostgresProfile] { class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext) extends HasDatabaseConfigProvider[PostgresProfile] {
private final lazy val tokenValidDuration = Duration.ofDays(30) private final lazy val tokenValidDuration = Duration.ofDays(30)
@@ -28,7 +28,7 @@ class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProv
val token = AuthToken( val token = AuthToken(
id = None, id = None,
userId = user.id.get, userId = user.id.get,
token = Random.randomBase64(12), token = Random.alphanumeric.take(12).mkString,
createdAt = Instant.now, createdAt = Instant.now,
expiresAt = Instant.now.plus(tokenValidDuration) expiresAt = Instant.now.plus(tokenValidDuration)
) )
+25 -1
View File
@@ -1,12 +1,36 @@
package models package models
import play.api.libs.json.{JsValue, Json, Writes}
import java.time.Instant import java.time.Instant
case class User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String]) case class User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String])
case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant) case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant)
case class Auction(id: Option[Int], name: String, gid: String, authorId: Int, enum AuctionStatus(val value: Short) {
case Created extends AuctionStatus(0)
case Ready extends AuctionStatus(10)
case Finished extends AuctionStatus(10000)
}
object AuctionStatus {
lazy val byValue: Map[Short, AuctionStatus] = Map.from(AuctionStatus.values.map{it => (it.value, it)})
}
case class Auction(id: Option[Int], name: String, status: AuctionStatus, gid: String, authorId: Int,
startingPrice: BigDecimal, currentBid: Option[BigDecimal], startingPrice: BigDecimal, currentBid: Option[BigDecimal],
currentBidderId: Option[Int], currentBidAt: Option[Instant], currentBidderId: Option[Int], currentBidAt: Option[Instant],
createdAt: Instant) createdAt: Instant)
object Auction {
implicit val writes: Writes[Auction] = (a: Auction) => Json.obj(
"id" -> a.id,
"name" -> a.name,
"gid" -> a.gid,
"starting_price" -> a.startingPrice,
"current_bid" -> a.currentBid,
"current_bidder_id" -> a.currentBidderId,
"current_bid_at" -> a.currentBidAt
)
}
+5 -23
View File
@@ -1,36 +1,18 @@
package mqttClient package mqttClient
import com.google.inject.{AbstractModule, Provides} import actors.auctions.AuctionPublisher
import com.google.inject.{AbstractModule, Provides, Singleton}
import com.hivemq.client.mqtt.mqtt5.{Mqtt5AsyncClient, Mqtt5Client} import com.hivemq.client.mqtt.mqtt5.{Mqtt5AsyncClient, Mqtt5Client}
import jakarta.inject.Inject
import play.api.libs.concurrent.PekkoGuiceSupport import play.api.libs.concurrent.PekkoGuiceSupport
import play.api.{Configuration, Logging} import play.api.{Configuration, Logging}
import java.nio.charset.StandardCharsets
class Module extends AbstractModule with PekkoGuiceSupport with Logging { class Module extends AbstractModule with PekkoGuiceSupport with Logging {
override def configure(): Unit = { override def configure(): Unit = {
bindTypedActor(MessageProcessorActor, "mqtt-message-processor-actor") bindTypedActor(MessageProcessorActor, "mqtt-message-processor-actor")
bind(classOf[MessageProcessorActor.Reg]).asEagerSingleton() bind(classOf[MessageProcessorActor.Reg]).asEagerSingleton()
bindTypedActor(AuctionPublisher, "auction-publisher") // TODO move somewhere else
} }
@Provides @Provides @Singleton
def mqtt5Client(config: Configuration): Mqtt5AsyncClient = { def mqtt5Client(config: Configuration): Mqtt5AsyncClient = MqttClientFactory.createClient(config)
val mqttConfig = config.get[Configuration]("mqtt")
var builder = Mqtt5Client.builder()
.identifier(mqttConfig.get[String]("clientId"))
.serverHost(mqttConfig.get[String]("host"))
.serverPort(mqttConfig.get("port"))
mqttConfig.getOptional[String]("username").foreach{username =>
val password = mqttConfig.get[String]("password").getBytes(StandardCharsets.US_ASCII)
builder = builder.simpleAuth()
.username(username)
.password(password)
.applySimpleAuth()
}
builder.buildAsync()
}
} }
+27
View File
@@ -0,0 +1,27 @@
package mqttClient
import com.hivemq.client.mqtt.mqtt5.{Mqtt5AsyncClient, Mqtt5Client}
import play.api.Configuration
import java.nio.charset.StandardCharsets
object MqttClientFactory {
def createClient(configuration: Configuration, suffix: String = ""): Mqtt5AsyncClient = {
val mqttConfig = configuration.get[Configuration]("mqtt")
var builder = Mqtt5Client.builder()
.identifier(mqttConfig.get[String]("clientId") + suffix)
.serverHost(mqttConfig.get[String]("host"))
.serverPort(mqttConfig.get("port"))
mqttConfig.getOptional[String]("username").foreach { username =>
val password = mqttConfig.get[String]("password").getBytes(StandardCharsets.US_ASCII)
builder = builder.simpleAuth()
.username(username)
.password(password)
.applySimpleAuth()
}
builder.buildAsync()
}
}
+8 -1
View File
@@ -33,9 +33,16 @@ class AuthTokensTable(tag: Tag) extends Table[AuthToken](tag, "auth_tokens") {
lazy val AuthTokens = TableQuery[AuthTokensTable] lazy val AuthTokens = TableQuery[AuthTokensTable]
implicit val auctionStatusColumnType: BaseColumnType[AuctionStatus] =
MappedColumnType.base[AuctionStatus, Short](
enumValue => enumValue.value,
intValue => AuctionStatus.byValue(intValue)
)
class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") { class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc) def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name") def name = column[String]("name")
def status = column[AuctionStatus]("status")
def gid = column[String]("gid", O.Unique) def gid = column[String]("gid", O.Unique)
def authorId = column[Int]("author_id") def authorId = column[Int]("author_id")
def startingPrice = column[BigDecimal]("starting_price") def startingPrice = column[BigDecimal]("starting_price")
@@ -44,7 +51,7 @@ class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") {
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")
override def * : ProvenShape[Auction] = (id.?, name, gid, authorId, startingPrice, currentBid, currentBidderId, currentBidAt, createdAt).mapTo[Auction] override def * : ProvenShape[Auction] = (id.?, name, status, gid, authorId, startingPrice, currentBid, currentBidderId, currentBidAt, createdAt).mapTo[Auction]
def author = foreignKey("users", authorId, Users)(_.id, onDelete = Restrict) def author = foreignKey("users", authorId, Users)(_.id, onDelete = Restrict)
} }
-16
View File
@@ -1,16 +0,0 @@
package utils
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.util.Base64
object Random {
private lazy val random = SecureRandom()
private lazy val base64 = Base64.getEncoder
def randomBase64(bytes: Int): String = {
val tokenRaw = ByteBuffer.allocate(bytes)
random.nextBytes(tokenRaw.array())
base64.encodeToString(tokenRaw.array())
}
}
+7
View File
@@ -0,0 +1,7 @@
-- !Ups
ALTER TABLE auctions
ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;
-- !Downs
ALTER TABLE auctions
DROP COLUMN status;
+1
View File
@@ -42,6 +42,7 @@
<logger name="play" level="INFO"/> <logger name="play" level="INFO"/>
<logger name="application" level="DEBUG"/> <logger name="application" level="DEBUG"/>
<logger name="mqttClient" level="DEBUG"/> <logger name="mqttClient" level="DEBUG"/>
<logger name="actors" level="INFO"/>
<root level="WARN"> <root level="WARN">
<appender-ref ref="ASYNCFILE"/> <appender-ref ref="ASYNCFILE"/>