Compare commits

..

No commits in common. "539b20f419bb9a190f8139198576bb8737e9ab8f" and "da9b96de84a547f03e36e8ea35e09dd22745b3b6" have entirely different histories.

9 changed files with 143 additions and 197 deletions

View File

@ -25,33 +25,53 @@ 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(roomId: RoomID, playerId: PlayerID): F[ResponseCookie]
def joinRoom(
roomName: String,
roomPassword: String,
nickName: String,
nickPassword: String
): F[Either[String, 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]) extends Auth[F] {
class SimpleAuth[F[_]: Sync](
sessions: Ref[F, SessionsMap],
roomService: RoomService[F]
) extends Auth[F] {
val authcookieName = "authcookie"
override def joinRoom(roomId: RoomID, playerId: PlayerID): F[ResponseCookie] = {
override def joinRoom(
roomName: String,
roomPassword: String,
nickName: String,
nickPassword: String
): F[Either[String, 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)
)
.leftMap(_.toString())
// newSessionId = Random.nextLong() // TODO return after i stop mocking RoomService
val newSessionId = TestModels.testSessionId
sessions
.update(_.updated(newSessionId, (roomId, playerId)))
.as(
ResponseCookie(
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
@ -72,8 +92,8 @@ object Auth {
}
}
def make[F[_]: Sync](): F[Auth[F]] =
def make[F[_]: Sync](roomsService: RoomService[F]): F[Auth[F]] =
for {
sessionsMap <- Ref.of[F, SessionsMap](TestModels.testSessions)
} yield new SimpleAuth(sessionsMap)
} yield new SimpleAuth(sessionsMap, roomsService)
}

View File

@ -14,8 +14,8 @@ object BackendApp extends IOApp {
val wiring = for {
roomService <- Resource.eval(RoomService.make[IO])
auth <- Resource.eval(Auth.make[IO]())
httpService = MyHttpService.create(auth, roomService)
auth <- Resource.eval(Auth.make(roomService))
httpService = MyHttpService.create(auth)
server <- EmberServerBuilder
.default[IO]
.withHost(host)
@ -25,7 +25,9 @@ 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,6 +4,7 @@ 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._
@ -15,8 +16,8 @@ import org.http4s.server.AuthMiddleware.apply
import org.http4s.server.AuthMiddleware
object MyHttpService {
def create(auth: Auth[IO], roomService: RoomService[IO])(
wsb: WebSocketBuilder[IO]
def create(auth: Auth[IO])(
wsb: WebSocketBuilder[cats.effect.IO]
): HttpApp[cats.effect.IO] = {
val authedRoomRoutes: AuthedRoutes[(PlayerID, RoomID), IO] =
@ -55,23 +56,26 @@ 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" => {
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))))
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))
}
}
} yield resp
val response = responseOrError.leftSemiflatMap(error => Forbidden(error.toString())).merge
response
}
}

View File

@ -1,10 +1,8 @@
package industries.sunshine.planningpoker
import industries.sunshine.planningpoker.common.Models.*
import cats.effect.{Ref, Concurrent}
import cats.effect.{Ref, Sync}
import cats.syntax.all._
import fs2.Stream
import fs2.concurrent.Topic
import cats.data.EitherT
enum RoomError {
@ -25,69 +23,38 @@ 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[_]: Concurrent](stateRef: Ref[F, Map[RoomID, (Room, Topic[F, Room])]])
extends RoomService[F] {
class InMemoryRoomService[F[_]: Sync](stateRef: Ref[F, Map[RoomID, Room]]) extends RoomService[F] {
override def createRoom(newRoom: Room): F[Either[RoomError, Room]] = {
for {
updatesTopic <- Topic[F, Room]
room <- stateRef.modify { rooms =>
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]
(rooms.updated(newRoom.id, newRoom)) -> newRoom.asRight[RoomError]
}
}
} yield 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 updateRoom(room: Room): F[Unit] = stateRef.update { state =>
state.get(room.id).fold(state)(oldRoom => state.updated(room.id, room))
}
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 deleteRoom(roomID: RoomID): F[Unit] = stateRef.update(_.removed(roomID))
override def getRoom(roomID: RoomID): F[Option[Room]] = {
stateRef.get.map(_.get(roomID).map(_._1))
}
override def getRoom(roomID: RoomID): F[Option[Room]] = stateRef.get.map(_.get(roomID))
override def joinRoom(
id: RoomID,
nickName: String,
nickPassword: String,
roomPassword: String
): F[Either[RoomError, PlayerID]] = {
): 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
/** 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
@ -104,19 +71,8 @@ class InMemoryRoomService[F[_]: Concurrent](stateRef: Ref[F, Map[RoomID, (Room,
}
}
/** 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
val joiningWithChecks = for {
room <- rooms.get(id).toRight(RoomError.RoomMissing(id.name))
_ <- Either.cond(room.password == roomPassword, (), RoomError.RoomPassIncorrect)
isNickPassCorrect = room.playersPasswords
.get(nickName)
@ -127,54 +83,14 @@ class InMemoryRoomService[F[_]: Concurrent](stateRef: Ref[F, Map[RoomID, (Room,
RoomError.NickPassIncorrect
)
(playerId, updatedRoom) = addPlayer(room)
} yield (playerId, updatedRoom, topic)
} yield playerId
// 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)
rooms -> joiningWithChecks
}
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[_]: Concurrent]: F[RoomService[F]] = {
Ref.of[F, Map[RoomID, (Room, Topic[F, Room])]](Map.empty).map(new InMemoryRoomService[F](_))
def make[F[_]: Sync]: F[RoomService[F]] = {
Ref.of[F, Map[RoomID, Room]](TestModels.testRooms).map(new InMemoryRoomService[F](_))
}
}

View File

@ -20,27 +20,30 @@ object Models {
players: List[Player],
me: PlayerID,
allowedCards: List[String],
round: RoundStateView,
round: RoundState,
canCloseRound: Boolean = false
) derives Codec.AsObject
) derives Codec.AsObject {
def playersCount: Int = players.size
}
object RoomStateView {
val empty = RoomStateView(
List.empty,
PlayerID(0),
List.empty,
RoundStateView.Voting(None, List.empty),
RoundState.Voting(None, List.empty),
false
)
}
enum RoundStateView derives Codec.AsObject:
enum RoundState 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[PlayerID]
alreadyVoted: List[Player]
)
/** view state for round after opening the votes
@ -48,14 +51,15 @@ object Models {
case Viewing(
votes: List[(PlayerID, String)]
)
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],
@ -63,9 +67,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 getViewFor(playerId: PlayerID): RoomStateView = {
def toViewFor(playerId: PlayerID): RoomStateView = {
players
.find(_.id == playerId)
.fold(ifEmpty = RoomStateView.empty)((me: Player) =>
@ -73,28 +77,27 @@ object Models {
players,
me.id,
allowedCards,
round.toViewFor(playerId),
round,
playerId == owner
)
)
}
}
// 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
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)
)
}
final case class Viewing(votes: Map[PlayerID, String]) extends RoundState {
def toViewFor(player: PlayerID): RoundStateView.Viewing =
RoundStateView.Viewing(votes.toList)
}
}
}

View File

@ -15,7 +15,7 @@ object TestModels {
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")
List(me.id -> "xs", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
playersPasswords = Map("me" -> "nickpassword") // nickname into password
)
@ -29,71 +29,70 @@ object TestModels {
players = List(me),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView.Voting(myCard = None, alreadyVoted = List.empty),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView.Voting(myCard = None, alreadyVoted = List.empty),
round = RoundState.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 = RoundStateView.Voting(myCard = None, alreadyVoted = List.empty),
round = RoundState.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 = RoundStateView.Voting(myCard = None, alreadyVoted = List(birdy.id)),
round = RoundState.Voting(myCard = None, alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id)),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id)),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id)),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id, horsey.id)),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView
.Voting(myCard = Some("m"), alreadyVoted = List(birdy.id, horsey.id, pony.id)),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey, pony)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundStateView.Viewing(
round = RoundState.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 RoundStateView.Voting(myCard, _) =>
case RoundState.Voting(myCard, _) =>
state.allowedCards.filterNot(value => myCard.contains(value))
case RoundStateView.Viewing(votes) =>
case RoundState.Viewing(votes) =>
state.allowedCards.filterNot(value => votes.toMap.get(state.me).contains(value))
}
}

View File

@ -2,19 +2,21 @@ package industries.sunshine.planningpoker
import scala.scalajs.js
import com.raquo.laminar.api.L.{*, given}
import industries.sunshine.planningpoker.common.Models.RoundStateView
import industries.sunshine.planningpoker.common.Models.RoundState
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[RoundStateView]): Element = {
def render(roundSignal: Signal[RoundState]): Element = {
div(
child <-- roundSignal.map {
case RoundStateView.Viewing(_) => newPollButton()
case RoundStateView.Voting(_, _) => endPollButton()
case RoundState.Viewing(_) => newPollButton()
case RoundState.Voting(_, _) => endPollButton()
}
)
}

View File

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