Compare commits

...

3 Commits

Author SHA1 Message Date
efim 539b20f419 rooms service methods implementation
removed stubbed room, but thing compiles and maybe will work?
lot's of pain with trying to store Topic inside Ref together with Room.

storing them together in a tuple means for the room there should always
be a topic
but then modifying the room in side of ref is uncomfortable, because i
want to do F[Unit] send updated room together with room modification,
but Ref seems to take in only pure function to update the value

there got to be maybe some semaphors or something like that?
and storing topics where? in a separate state?
that would maybe simplify things, but i'm coding well into the night and
that's not a good idea really

now before testing this with front end, i need a way to create a room.
2023-04-28 02:42:02 +04:00
efim bd38a29b6d backend: move roomService usage to httpService
no longer used in auth module,
ready to connect rest routes to room service
2023-04-28 00:46:25 +04:00
efim ed6d30ec42 models: separate backend room model
not viewable on front end, since doesn't have json codecs, yay!
2023-04-27 22:17:47 +04:00
9 changed files with 198 additions and 144 deletions

View File

@ -25,53 +25,33 @@ trait Auth[F[_]] {
* check that room exists, password is valid call to add user to the players create session
* mapping and return cookie
*/
def joinRoom(
roomName: String,
roomPassword: String,
nickName: String,
nickPassword: String
): F[Either[String, ResponseCookie]]
def joinRoom(roomId: RoomID, playerId: PlayerID): F[ResponseCookie]
def deleteSession(
sessionId: Long
): F[Unit]
def deleteSession(sessionId: Long): F[Unit]
}
object Auth {
type SessionsMap = Map[Long, (RoomID, PlayerID)]
class SimpleAuth[F[_]: Sync](
sessions: Ref[F, SessionsMap],
roomService: RoomService[F]
) extends Auth[F] {
class SimpleAuth[F[_]: Sync](sessions: Ref[F, SessionsMap]) extends Auth[F] {
val authcookieName = "authcookie"
override def joinRoom(
roomName: String,
roomPassword: String,
nickName: String,
nickPassword: String
): F[Either[String, ResponseCookie]] = {
override def joinRoom(roomId: RoomID, playerId: PlayerID): F[ResponseCookie] = {
// TODO check for existing session for same room
// and do i want to logout if existing session for another room? ugh
val roomId = RoomID(roomName)
val result = for {
playerId <- EitherT(
roomService.joinRoom(roomId, nickName, nickPassword, roomPassword)
// newSessionId = Random.nextLong() // TODO return after i stop mocking RoomService
val newSessionId = TestModels.testSessionId
sessions
.update(_.updated(newSessionId, (roomId, playerId)))
.as(
ResponseCookie(
name = authcookieName,
content = newSessionId.toString(),
secure = true
)
)
.leftMap(_.toString())
// newSessionId = Random.nextLong() // TODO return after i stop mocking RoomService
newSessionId = TestModels.testSessionId
_ <- EitherT.liftF(sessions.update(_.updated(newSessionId, (roomId, playerId))))
} yield ResponseCookie(
name = authcookieName,
content = newSessionId.toString(),
secure = true
)
result.value
}
override def authUser
@ -92,8 +72,8 @@ object Auth {
}
}
def make[F[_]: Sync](roomsService: RoomService[F]): F[Auth[F]] =
def make[F[_]: Sync](): F[Auth[F]] =
for {
sessionsMap <- Ref.of[F, SessionsMap](TestModels.testSessions)
} yield new SimpleAuth(sessionsMap, roomsService)
} yield new SimpleAuth(sessionsMap)
}

View File

@ -14,8 +14,8 @@ object BackendApp extends IOApp {
val wiring = for {
roomService <- Resource.eval(RoomService.make[IO])
auth <- Resource.eval(Auth.make(roomService))
httpService = MyHttpService.create(auth)
auth <- Resource.eval(Auth.make[IO]())
httpService = MyHttpService.create(auth, roomService)
server <- EmberServerBuilder
.default[IO]
.withHost(host)
@ -25,9 +25,7 @@ object BackendApp extends IOApp {
} yield server
wiring.use(server =>
IO.delay(println(s"Server Has Started at ${server.address}")) >> IO.never
.as(ExitCode.Success)
)
IO.delay(println(s"Server Has Started at ${server.address}")) >> IO.never.as(ExitCode.Success))
}
}

View File

@ -4,7 +4,6 @@ import cats.effect._
import cats.syntax.all._
import org.http4s._, org.http4s.dsl.io._, org.http4s.implicits._
import org.http4s.websocket.WebSocketFrame
// import io.circe.generic.auto._
import io.circe.syntax._
import org.http4s.circe.CirceEntityDecoder._
import scala.concurrent.duration._
@ -16,8 +15,8 @@ import org.http4s.server.AuthMiddleware.apply
import org.http4s.server.AuthMiddleware
object MyHttpService {
def create(auth: Auth[IO])(
wsb: WebSocketBuilder[cats.effect.IO]
def create(auth: Auth[IO], roomService: RoomService[IO])(
wsb: WebSocketBuilder[IO]
): HttpApp[cats.effect.IO] = {
val authedRoomRoutes: AuthedRoutes[(PlayerID, RoomID), IO] =
@ -56,26 +55,23 @@ object MyHttpService {
val authMiddleware = AuthMiddleware(auth.authUser)
val aa = authMiddleware(authedRoomRoutes)
import cats.data.EitherT
val authenticationRoute = HttpRoutes
.of[IO] {
case req @ POST -> Root / "login" => {
for {
data <- req.as[Requests.LogIn]
authResult <- auth.joinRoom(
data.roomName,
data.password,
data.nickname,
data.nickPassword
)
resp <- authResult match {
case Left(error) =>
Forbidden(error)
case Right(authCookie) => {
IO(println(s"> logging in ${data.nickname} to ${data.roomName}")) >>
Ok().map(_.addCookie(authCookie))
}
}
val responseOrError = for {
data <- EitherT.right(req.as[Requests.LogIn])
Requests.LogIn(roomName, nickName, roomPassword, nickPassword) = data
roomId = RoomID(roomName)
playerId <- EitherT(roomService.joinRoom(roomId, nickName, nickPassword, roomPassword))
authCookie <- EitherT.liftF(auth.joinRoom(roomId, playerId))
_ <- EitherT.liftF(IO(println(s"> logging in $nickName to $roomName")))
resp <- EitherT.liftF(Ok().flatTap(resp => IO(resp.addCookie(authCookie))))
} yield resp
val response = responseOrError.leftSemiflatMap(error => Forbidden(error.toString())).merge
response
}
}

View File

@ -1,8 +1,10 @@
package industries.sunshine.planningpoker
import industries.sunshine.planningpoker.common.Models.*
import cats.effect.{Ref, Sync}
import cats.effect.{Ref, Concurrent}
import cats.syntax.all._
import fs2.Stream
import fs2.concurrent.Topic
import cats.data.EitherT
enum RoomError {
@ -23,38 +25,69 @@ trait RoomService[F[_]] {
): F[Either[RoomError, PlayerID]]
def deleteRoom(roomID: RoomID): F[Unit]
def getRoom(roomID: RoomID): F[Option[Room]]
def subscribe(roomID: RoomID): Stream[F, Room]
}
class InMemoryRoomService[F[_]: Sync](stateRef: Ref[F, Map[RoomID, Room]]) extends RoomService[F] {
class InMemoryRoomService[F[_]: Concurrent](stateRef: Ref[F, Map[RoomID, (Room, Topic[F, Room])]])
extends RoomService[F] {
override def createRoom(newRoom: Room): F[Either[RoomError, Room]] = {
stateRef.modify { rooms =>
rooms.get(newRoom.id) match {
case Some(_) =>
rooms -> RoomError.RoomAlreadyExists(newRoom.id.name).asLeft[Room]
case None =>
(rooms.updated(newRoom.id, newRoom)) -> newRoom.asRight[RoomError]
for {
updatesTopic <- Topic[F, Room]
room <- stateRef.modify { rooms =>
rooms.get(newRoom.id) match {
case Some(_) =>
rooms -> RoomError.RoomAlreadyExists(newRoom.id.name).asLeft[Room]
case None =>
(rooms.updated(newRoom.id, (newRoom, updatesTopic))) -> newRoom.asRight[RoomError]
}
}
}
} yield room
}
override def updateRoom(room: Room): F[Unit] = stateRef.update { state =>
state.get(room.id).fold(state)(oldRoom => state.updated(room.id, room))
override def updateRoom(room: Room): F[Unit] = {
for {
// modify is function to update state and compute auxillary value to return, here - topic
topic <- stateRef.modify[Topic[F, Room]] { state =>
state.get(room.id) match {
case Some((oldRoom, topic)) => state.updated(room.id, (room, topic)) -> topic
case None =>
throw new IllegalStateException(s"updateRoom with ${room.id} on nonexistent room")
}
}
_ <- topic.publish1(room) // update and publish are not atomic, sadly races can happen
} yield ()
}
override def deleteRoom(roomID: RoomID): F[Unit] = stateRef.update(_.removed(roomID))
override def deleteRoom(roomID: RoomID): F[Unit] = {
for {
topic <- stateRef.modify[Topic[F, Room]](state =>
state.get(roomID) match {
case Some((oldRoom, topic)) => state.removed(roomID) -> topic
case None =>
throw new IllegalStateException(s"call to delete with $roomID on nonexistent room")
// TODO - i'd prefer to swallow these errors
}
)
_ <- topic.close
} yield ()
}
override def getRoom(roomID: RoomID): F[Option[Room]] = stateRef.get.map(_.get(roomID))
override def getRoom(roomID: RoomID): F[Option[Room]] = {
stateRef.get.map(_.get(roomID).map(_._1))
}
override def joinRoom(
id: RoomID,
nickName: String,
nickPassword: String,
roomPassword: String
): F[Either[RoomError, PlayerID]] = stateRef.modify { rooms =>
// need to cover cases:
// - player already present, then return as is, i guess
// - nick not known - add new player and new nick-password mapping
// - nick known - add new player
): F[Either[RoomError, PlayerID]] = {
/** pure function that adds the player to the room need to cover cases:
* - player already present, then return as is, i guess
* - nick not known - add new player and new nick-password mapping
* - nick known - add new player
*/
def addPlayer(room: Room): (PlayerID, Room) = {
room.players.find(_.name == nickName) match {
case Some(player) => player.id -> room
@ -71,8 +104,19 @@ class InMemoryRoomService[F[_]: Sync](stateRef: Ref[F, Map[RoomID, Room]]) exten
}
}
val joiningWithChecks = for {
room <- rooms.get(id).toRight(RoomError.RoomMissing(id.name))
/** to be executed under Ref.modify (i.e with state acquired) checks of whether player can be
* added to the room:
* - room password is correct
* - nickname is either not taken, or correct password was provided
* @returns
* playerId (new or existing), updatedRoom (to be put into state), topic (to send the udpdate
* notification)
*/
def getWithChecks(
rooms: Map[RoomID, (Room, Topic[F, Room])]
): Either[RoomError, (PlayerID, Room, Topic[F, Room])] = for {
roomAndTopic <- rooms.get(id).toRight(RoomError.RoomMissing(id.name))
(room, topic) = roomAndTopic
_ <- Either.cond(room.password == roomPassword, (), RoomError.RoomPassIncorrect)
isNickPassCorrect = room.playersPasswords
.get(nickName)
@ -83,14 +127,54 @@ class InMemoryRoomService[F[_]: Sync](stateRef: Ref[F, Map[RoomID, Room]]) exten
RoomError.NickPassIncorrect
)
(playerId, updatedRoom) = addPlayer(room)
} yield playerId
} yield (playerId, updatedRoom, topic)
rooms -> joiningWithChecks
// modify returns tuple (updatedState, valueToReturn)
// this particular update either keeps room as is, or adds the player
// and returns playerId and topic to be used outside
//
// NOTE here i have a lot of handwaving to pass topic outside
// because it's not possible to send F[Unit] update
// inside of the stateRef update (which works with pure functions?),
// so room notification change has to be returned to outside
val maybeUpdatedStateAndNotification = stateRef.modify { rooms =>
val maybeAddedUser = getWithChecks(rooms)
val updatedState =
maybeAddedUser.fold(
_ => rooms,
{ case (playerId, updatedRoom, topic) => rooms.updated(id, (updatedRoom, topic)) }
)
val toReturn = maybeAddedUser.map { case (id, updatedRoom, topic) =>
(id, topic.publish1(updatedRoom).void)
}
updatedState -> toReturn
}
// now combining the effects : getting (updatedState & notificationEffect) or error
// executing notification
// returning only playerId
val result = for {
updatedState <- EitherT(maybeUpdatedStateAndNotification)
(playerId, notification) = updatedState
_ <- EitherT.liftF(notification)
} yield (playerId)
result.value
}
override def subscribe(roomID: RoomID): Stream[F, Room] =
Stream
.eval(stateRef.get)
.flatMap(rooms =>
rooms.get(roomID) match {
case Some((room, topic)) => topic.subscribe(10)
case None => Stream.empty
}
)
}
object RoomService {
def make[F[_]: Sync]: F[RoomService[F]] = {
Ref.of[F, Map[RoomID, Room]](TestModels.testRooms).map(new InMemoryRoomService[F](_))
def make[F[_]: Concurrent]: F[RoomService[F]] = {
Ref.of[F, Map[RoomID, (Room, Topic[F, Room])]](Map.empty).map(new InMemoryRoomService[F](_))
}
}

View File

@ -20,30 +20,27 @@ object Models {
players: List[Player],
me: PlayerID,
allowedCards: List[String],
round: RoundState,
round: RoundStateView,
canCloseRound: Boolean = false
) derives Codec.AsObject {
def playersCount: Int = players.size
}
) derives Codec.AsObject
object RoomStateView {
val empty = RoomStateView(
List.empty,
PlayerID(0),
List.empty,
RoundState.Voting(None, List.empty),
RoundStateView.Voting(None, List.empty),
false
)
}
enum RoundState derives Codec.AsObject:
enum RoundStateView derives Codec.AsObject:
/** view state for round before votes are open player can know their vote and who of the other
* players have voted
*/
case Voting(
myCard: Option[String],
alreadyVoted: List[Player]
alreadyVoted: List[PlayerID]
)
/** view state for round after opening the votes
@ -51,15 +48,14 @@ object Models {
case Viewing(
votes: List[(PlayerID, String)]
)
final case class PlayerID(id: Long) derives Codec.AsObject
final case class PlayerID(id: Long) derives Codec.AsObject
final case class Player(name: String, id: PlayerID) derives Codec.AsObject
object Player {
def create(name: String) = Player(name, PlayerID(Random.nextLong))
}
final case class RoomID(name: String) derives Codec.AsObject
final case class Room(
id: RoomID,
players: List[Player],
@ -67,9 +63,9 @@ object Models {
password: String,
allowedCards: List[String],
round: RoundState,
playersPasswords: Map[String, String] = Map.empty // nickname into password
playersPasswords: Map[String, String] = Map.empty, // nickname into password
) {
def toViewFor(playerId: PlayerID): RoomStateView = {
def getViewFor(playerId: PlayerID): RoomStateView = {
players
.find(_.id == playerId)
.fold(ifEmpty = RoomStateView.empty)((me: Player) =>
@ -77,27 +73,28 @@ object Models {
players,
me.id,
allowedCards,
round,
round.toViewFor(playerId),
playerId == owner
)
)
}
}
object Room {
val testRoom = Room(
id = RoomID("testroom"),
players = List(
Player("me", PlayerID(1L)),
Player("horsey", PlayerID(444L)),
Player("froggy", PlayerID(555L)),
Player("owley", PlayerID(777L))
),
owner = PlayerID(1L),
password = "password",
allowedCards = List("S", "M", "L"),
// TODO - this needs to be a different hting
round = RoundState.Voting(None, List.empty)
)
}
// no need to send to the front end, no deed to derive codec, cool
sealed trait RoundState {
def toViewFor(player: PlayerID): RoundStateView
}
object RoundState {
final case class Voting(votes: Map[PlayerID, String]) extends RoundState {
def toViewFor(playerId: PlayerID): RoundStateView.Voting = RoundStateView.Voting(
myCard = votes.get(playerId),
alreadyVoted = votes.filterKeys(id => id != playerId).keys.toList
)
}
final case class Viewing(votes: Map[PlayerID, String]) extends RoundState {
def toViewFor(player: PlayerID): RoundStateView.Viewing =
RoundStateView.Viewing(votes.toList)
}
}
}

View File

@ -9,15 +9,15 @@ object TestModels {
val horsey = Player("horsey", PlayerID(12))
val testRoomBackend = Room(
id = RoomID("testroom"),
players = List(me, birdy, pony, horsey),
owner = me.id,
password = "password",
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Viewing(
List(me.id -> "xs", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
playersPasswords = Map("me" -> "nickpassword") // nickname into password
id = RoomID("testroom"),
players = List(me, birdy, pony, horsey),
owner = me.id,
password = "password",
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Viewing(
Map(me.id -> "xs", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
playersPasswords = Map("me" -> "nickpassword") // nickname into password
)
val testSessionId = 1L
@ -29,70 +29,71 @@ object TestModels {
players = List(me),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
round = RoundStateView.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
round = RoundStateView.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
round = RoundStateView.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List(birdy)),
round = RoundStateView.Voting(myCard = None, alreadyVoted = List(birdy.id)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey)),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id, horsey.id)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey, pony)),
round = RoundStateView
.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id, horsey.id, pony.id)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Viewing(
round = RoundStateView.Viewing(
List(me.id -> "m", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
canCloseRound = true

View File

@ -32,9 +32,9 @@ object OwnHandControls {
private def myUnselectedCards(state: RoomStateView): List[String] = {
state.round match {
case RoundState.Voting(myCard, _) =>
case RoundStateView.Voting(myCard, _) =>
state.allowedCards.filterNot(value => myCard.contains(value))
case RoundState.Viewing(votes) =>
case RoundStateView.Viewing(votes) =>
state.allowedCards.filterNot(value => votes.toMap.get(state.me).contains(value))
}
}

View File

@ -2,21 +2,19 @@ package industries.sunshine.planningpoker
import scala.scalajs.js
import com.raquo.laminar.api.L.{*, given}
import industries.sunshine.planningpoker.common.Models.RoundState
import industries.sunshine.planningpoker.common.Models.RoundStateView
import com.raquo.airstream.core.Signal
import industries.sunshine.planningpoker.common.Models.RoundState
import industries.sunshine.planningpoker.common.Models.RoundState
import io.laminext.fetch.Fetch
import scala.scalajs.js.Dynamic.{global => g}
import concurrent.ExecutionContext.Implicits.global
object TableControls {
def render(roundSignal: Signal[RoundState]): Element = {
def render(roundSignal: Signal[RoundStateView]): Element = {
div(
child <-- roundSignal.map {
case RoundState.Viewing(_) => newPollButton()
case RoundState.Voting(_, _) => endPollButton()
case RoundStateView.Viewing(_) => newPollButton()
case RoundStateView.Voting(_, _) => endPollButton()
}
)
}

View File

@ -39,16 +39,16 @@ object TableView {
def getPlayerCardType(
id: PlayerID,
state: RoundState,
state: RoundStateView,
name: String,
myId: PlayerID
): CardState = {
state match {
case isOpen: RoundState.Voting =>
case isOpen: RoundStateView.Voting =>
if (myId == id) {
isOpen.myCard.fold(NoCard(name))(vote => Open(vote))
} else isOpen.alreadyVoted.find(_.id == id).fold(NoCard(name))(_ => CardBack)
case isClosed: RoundState.Viewing =>
} else isOpen.alreadyVoted.find(_ == id).fold(NoCard(name))(_ => CardBack)
case isClosed: RoundStateView.Viewing =>
isClosed.votes
.find(_._1 == id)
.fold {