diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 2cfefec..402138e 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -11,6 +11,6 @@ - + diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index 378cf84..34e2efd 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -42,6 +42,8 @@ me.arcanis.ffxivbis { ] # general request timeout, duratin, required request-timeout = 10s + # party in-memory storage lifetime + cache-timeout = 1m } web { diff --git a/src/main/scala/me/arcanis/ffxivbis/Application.scala b/src/main/scala/me/arcanis/ffxivbis/Application.scala index 86ce194..98588ea 100644 --- a/src/main/scala/me/arcanis/ffxivbis/Application.scala +++ b/src/main/scala/me/arcanis/ffxivbis/Application.scala @@ -13,7 +13,7 @@ import akka.http.scaladsl.Http import akka.stream.ActorMaterializer import com.typesafe.scalalogging.StrictLogging import me.arcanis.ffxivbis.http.RootEndpoint -import me.arcanis.ffxivbis.service.Ariyala +import me.arcanis.ffxivbis.service.{Ariyala, PartyService} import me.arcanis.ffxivbis.service.impl.DatabaseImpl import me.arcanis.ffxivbis.storage.Migration @@ -35,7 +35,8 @@ class Application extends Actor with StrictLogging { case Success(_) => val ariyala = context.system.actorOf(Ariyala.props, "ariyala") val storage = context.system.actorOf(DatabaseImpl.props, "storage") - val http = new RootEndpoint(context.system, storage, ariyala) + val party = context.system.actorOf(PartyService.props(storage), "party") + val http = new RootEndpoint(context.system, party, ariyala) logger.info(s"start server at $host:$port") val bind = Http()(context.system).bindAndHandle(http.route, host, port) diff --git a/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala index b426499..d2e38af 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala @@ -12,6 +12,7 @@ import akka.actor.ActorRef import akka.pattern.ask import akka.util.Timeout import me.arcanis.ffxivbis.models.User +import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler import scala.concurrent.{ExecutionContext, Future} @@ -22,6 +23,9 @@ class UserHelper(storage: ActorRef) { (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = (storage ? DatabaseUserHandler.AddUser(user, isHashedPassword)).mapTo[Int] + def newPartyId(implicit executionContext: ExecutionContext, timeout: Timeout): Future[String] = + (storage ? PartyService.GetNewPartyId).mapTo[String] + def user(partyId: String, username: String) (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[User]] = (storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]] diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala index 13dbc12..214df6f 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala @@ -29,7 +29,7 @@ import scala.util.{Failure, Success} @Path("api/v1") class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) - extends BiSHelper(storage, ariyala) with Authorization with JsonSupport with HttpHandler { + extends BiSHelper(storage, ariyala) with Authorization with JsonSupport { def route: Route = createBiS ~ getBiS ~ modifyBiS @@ -54,18 +54,14 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti ) def createBiS: Route = path("party" / Segment / "bis") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => - put { - entity(as[PlayerBiSLinkResponse]) { bisLink => - val playerId = bisLink.playerId.withPartyId(partyId) - onComplete(putBiS(playerId, bisLink.link)) { - case Success(_) => complete(StatusCodes.Created, HttpEntity.Empty) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + put { + entity(as[PlayerBiSLinkResponse]) { bisLink => + val playerId = bisLink.playerId.withPartyId(partyId) + onComplete(putBiS(playerId, bisLink.link)) { + case Success(_) => complete(StatusCodes.Created, HttpEntity.Empty) + case Failure(exception) => throw exception } } } @@ -96,23 +92,19 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti ) def getBiS: Route = path("party" / Segment / "bis") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => - get { - parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => - val playerId = PlayerId(partyId, maybeNick, maybeJob) - onComplete(bis(partyId, playerId)) { - case Success(response) => complete(response.map(PlayerResponse.fromPlayer)) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + get { + parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => + val playerId = PlayerId(partyId, maybeNick, maybeJob) + onComplete(bis(partyId, playerId)) { + case Success(response) => complete(response.map(PlayerResponse.fromPlayer)) + case Failure(exception) => throw exception } } - } } + } } @@ -137,21 +129,18 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti ) def modifyBiS: Route = path("party" / Segment / "bis") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => - post { - entity(as[PieceActionResponse]) { action => - val playerId = action.playerIdResponse.withPartyId(partyId) - onComplete(doModifyBiS(action.action, playerId, action.piece.toPiece)) { - case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + post { + entity(as[PieceActionResponse]) { action => + val playerId = action.playerIdResponse.withPartyId(partyId) + onComplete(doModifyBiS(action.action, playerId, action.piece.toPiece)) { + case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) + case Failure(exception) => throw exception } } } + } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala index 35a39b2..1f7db4c 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala @@ -55,18 +55,14 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) ) def getLoot: Route = path("party" / Segment / "loot") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => - get { - parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => - val playerId = PlayerId(partyId, maybeNick, maybeJob) - onComplete(loot(partyId, playerId)) { - case Success(response) => complete(response.map(PlayerResponse.fromPlayer)) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + get { + parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => + val playerId = PlayerId(partyId, maybeNick, maybeJob) + onComplete(loot(partyId, playerId)) { + case Success(response) => complete(response.map(PlayerResponse.fromPlayer)) + case Failure(exception) => throw exception } } } @@ -95,18 +91,14 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) ) def modifyLoot: Route = path("party" / Segment / "loot") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => - post { - entity(as[PieceActionResponse]) { action => - val playerId = action.playerIdResponse.withPartyId(partyId) - onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece)) { - case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + post { + entity(as[PieceActionResponse]) { action => + val playerId = action.playerIdResponse.withPartyId(partyId) + onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece)) { + case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) + case Failure(exception) => throw exception } } } @@ -139,17 +131,13 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) ) def suggestLoot: Route = path("party" / Segment / "loot") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => - put { - entity(as[PieceResponse]) { piece => - onComplete(suggestPiece(partyId, piece.toPiece)) { - case Success(response) => complete(response.map(PlayerIdWithCountersResponse.fromPlayerId)) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + put { + entity(as[PieceResponse]) { piece => + onComplete(suggestPiece(partyId, piece.toPiece)) { + case Success(response) => complete(response.map(PlayerIdWithCountersResponse.fromPlayerId)) + case Failure(exception) => throw exception } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala index fb6e7c3..75bfeb3 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala @@ -55,18 +55,14 @@ class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ) def getParty: Route = path("party" / Segment) { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => - get { - parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => - val playerId = PlayerId(partyId, maybeNick, maybeJob) - onComplete(getPlayers(partyId, playerId)) { - case Success(response) => complete(response.map(PlayerResponse.fromPlayer)) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + get { + parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => + val playerId = PlayerId(partyId, maybeNick, maybeJob) + onComplete(getPlayers(partyId, playerId)) { + case Success(response) => complete(response.map(PlayerResponse.fromPlayer)) + case Failure(exception) => throw exception } } } @@ -95,17 +91,13 @@ class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ) def modifyParty: Route = path("party" / Segment) { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => - entity(as[PlayerActionResponse]) { action => - val player = action.playerIdResponse.toPlayer.copy(partyId = partyId) - onComplete(doModifyPlayer(action.action, player)) { - case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + entity(as[PlayerActionResponse]) { action => + val player = action.playerIdResponse.toPlayer.copy(partyId = partyId) + onComplete(doModifyPlayer(action.action, player)) { + case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) + case Failure(exception) => throw exception } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/RootApiV1Endpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/RootApiV1Endpoint.scala index dec7172..8d3ebfd 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/RootApiV1Endpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/RootApiV1Endpoint.scala @@ -12,8 +12,11 @@ import akka.actor.ActorRef import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.util.Timeout +import me.arcanis.ffxivbis.http.api.v1.json.JsonSupport -class RootApiV1Endpoint(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) { +class RootApiV1Endpoint(storage: ActorRef, ariyala: ActorRef) + (implicit timeout: Timeout) + extends JsonSupport with HttpHandler { private val biSEndpoint = new BiSEndpoint(storage, ariyala) private val lootEndpoint = new LootEndpoint(storage) @@ -21,5 +24,9 @@ class RootApiV1Endpoint(storage: ActorRef, ariyala: ActorRef)(implicit timeout: private val userEndpoint = new UserEndpoint(storage) def route: Route = - biSEndpoint.route ~ lootEndpoint.route ~ playerEndpoint.route ~ userEndpoint.route + handleExceptions(exceptionHandler) { + handleRejections(rejectionHandler) { + biSEndpoint.route ~ lootEndpoint.route ~ playerEndpoint.route ~ userEndpoint.route + } + } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala index f7d51d3..da7104b 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala @@ -28,21 +28,18 @@ import scala.util.{Failure, Success} @Path("api/v1") class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) - extends UserHelper(storage) with Authorization with JsonSupport with HttpHandler { + extends UserHelper(storage) with Authorization with JsonSupport { def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers @PUT - @Path("party/{partyId}/create") + @Path("party") @Consumes(value = Array("application/json")) @Operation(summary = "create new party", description = "Create new party with specified ID", - parameters = Array( - new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), - ), requestBody = new RequestBody(description = "party administrator description", required = true, content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))), responses = Array( - new ApiResponse(responseCode = "201", description = "Party has been created"), + new ApiResponse(responseCode = "200", description = "Party has been created"), new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), new ApiResponse(responseCode = "406", description = "Party with the specified ID already exists"), new ApiResponse(responseCode = "500", description = "Internal server error"), @@ -50,18 +47,18 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) tags = Array("party"), ) def createParty: Route = - path("party" / Segment / "create") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - put { - entity(as[UserResponse]) { user => + path("party") { + extractExecutionContext { implicit executionContext => + put { + entity(as[UserResponse]) { user => + onComplete(newPartyId) { + case Success(partyId) => val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin) onComplete(addUser(admin, isHashedPassword = false)) { - case Success(_) => complete(StatusCodes.Created, HttpEntity.Empty) + case Success(_) => complete(PartyIdResponse(partyId)) case Failure(exception) => throw exception } - } + case Failure(exception) => throw exception } } } @@ -89,18 +86,14 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) ) def createUser: Route = path("party" / Segment / "users") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => - post { - entity(as[UserResponse]) { user => - val withPartyId = user.toUser.copy(partyId = partyId) - onComplete(addUser(withPartyId, isHashedPassword = false)) { - case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => + post { + entity(as[UserResponse]) { user => + val withPartyId = user.toUser.copy(partyId = partyId) + onComplete(addUser(withPartyId, isHashedPassword = false)) { + case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) + case Failure(exception) => throw exception } } } @@ -126,16 +119,12 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) ) def deleteUser: Route = path("party" / Segment / "users" / Segment) { (partyId, username) => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => - delete { - onComplete(removeUser(partyId, username)) { - case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => + delete { + onComplete(removeUser(partyId, username)) { + case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) + case Failure(exception) => throw exception } } } @@ -163,16 +152,12 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) ) def getUsers: Route = path("party" / Segment / "users") { partyId => - handleExceptions(exceptionHandler) { - handleRejections(rejectionHandler) { - extractExecutionContext { implicit executionContext => - authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => - get { - onComplete(users(partyId)) { - case Success(response) => complete(response.map(UserResponse.fromUser)) - case Failure(exception) => throw exception - } - } + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => + get { + onComplete(users(partyId)) { + case Success(response) => complete(response.map(UserResponse.fromUser)) + case Failure(exception) => throw exception } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala index a1b4b09..81df641 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala @@ -28,6 +28,7 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol { implicit val permissionFormat: RootJsonFormat[Permission.Value] = enumFormat(Permission) implicit val errorFormat: RootJsonFormat[ErrorResponse] = jsonFormat1(ErrorResponse.apply) + implicit val partyIdFormat: RootJsonFormat[PartyIdResponse] = jsonFormat1(PartyIdResponse.apply) implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply) implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply) implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply) diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PartyIdResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PartyIdResponse.scala new file mode 100644 index 0000000..a650350 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PartyIdResponse.scala @@ -0,0 +1,6 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema + +case class PartyIdResponse( + @Schema(description = "party id", required = true) partyId: String) diff --git a/src/main/scala/me/arcanis/ffxivbis/http/view/IndexView.scala b/src/main/scala/me/arcanis/ffxivbis/http/view/IndexView.scala index 1c52c2f..c4a9b84 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/view/IndexView.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/view/IndexView.scala @@ -16,19 +16,25 @@ import akka.util.Timeout import me.arcanis.ffxivbis.http.UserHelper import me.arcanis.ffxivbis.models.{Party, Permission, User} +import scala.util.{Failure, Success} + class IndexView(storage: ActorRef)(implicit timeout: Timeout) extends UserHelper(storage) { def route: Route = createParty ~ getIndex def createParty: Route = - path("party" / Segment / "create") { partyId => + path("party") { extractExecutionContext { implicit executionContext => post { formFields("username".as[String], "password".as[String]) { (username, password) => - val user = User(partyId, username, password, Permission.admin) - onComplete(addUser(user, isHashedPassword = false)) { - case _ => redirect(s"/party/$partyId", StatusCodes.Found) + onComplete(newPartyId) { + case Success(partyId) => + val user = User(partyId, username, password, Permission.admin) + onComplete(addUser(user, isHashedPassword = false)) { + case _ => redirect(s"/party/$partyId", StatusCodes.Found) + } + case Failure(exception) => throw exception } } } @@ -40,7 +46,7 @@ class IndexView(storage: ActorRef)(implicit timeout: Timeout) get { parameters("partyId".as[String].?) { case Some(partyId) => redirect(s"/party/$partyId", StatusCodes.Found) - case _ => complete((StatusCodes.OK, RootView.toHtml(IndexView.template))) + case _ => complete(StatusCodes.OK, RootView.toHtml(IndexView.template)) } } } @@ -58,7 +64,7 @@ object IndexView { ), body( - form(action:=s"party/${Party.randomPartyId}/create", method:="post")( + form(action:=s"party", method:="post")( label("create a new party"), input(name:="username", id:="username", placeholder:="username", title:="username", `type`:="text"), input(name:="password", id:="password", placeholder:="password", title:="password", `type`:="password"), diff --git a/src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala b/src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala index cac3c09..a1d3e59 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala @@ -22,7 +22,7 @@ import me.arcanis.ffxivbis.models.{BiS, Job, Piece} import spray.json._ import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} +import scala.util.Try class Ariyala extends Actor with StrictLogging { import Ariyala._ diff --git a/src/main/scala/me/arcanis/ffxivbis/service/Database.scala b/src/main/scala/me/arcanis/ffxivbis/service/Database.scala index faa0db2..b7dba24 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/Database.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/Database.scala @@ -37,3 +37,9 @@ trait Database extends Actor with StrictLogging { loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty) } yield Party(partyId, context.system.settings.config, players, bis, loot) } + +object Database { + trait DatabaseRequest { + def partyId: String + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/PartyService.scala b/src/main/scala/me/arcanis/ffxivbis/service/PartyService.scala new file mode 100644 index 0000000..d3bc22c --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/PartyService.scala @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019 Evgeniy Alekseev. + * + * This file is part of ffxivbis + * (see https://github.com/arcan1s/ffxivbis). + * + * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause + */ +package me.arcanis.ffxivbis.service + +import akka.actor.{Actor, ActorRef, Props} +import akka.pattern.{ask, pipe} +import akka.util.Timeout +import com.typesafe.scalalogging.StrictLogging +import me.arcanis.ffxivbis.models.Party + +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.{ExecutionContext, Future} + +class PartyService(storage: ActorRef) extends Actor with StrictLogging { + import PartyService._ + import me.arcanis.ffxivbis.utils.Implicits._ + + private val cacheTimeout: FiniteDuration = + context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.cache-timeout") + implicit private val executionContext: ExecutionContext = context.dispatcher + implicit private val timeout: Timeout = + context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") + + override def receive: Receive = handle(Map.empty) + + private def handle(cache: Map[String, Party]): Receive = { + case ForgetParty(partyId) => + context become handle(cache - partyId) + + case GetNewPartyId => + val client = sender() + getPartyId.pipeTo(client) + + case req @ impl.DatabasePartyHandler.GetParty(partyId) => + val client = sender() + val party = cache.get(partyId) match { + case Some(party) => Future.successful(party) + case None => + (storage ? req).mapTo[Party].map { party => + context become handle(cache + (partyId -> party)) + context.system.scheduler.scheduleOnce(cacheTimeout, self, ForgetParty(partyId)) + party + } + } + party.pipeTo(client) + + case req: Database.DatabaseRequest => + self ! ForgetParty(req.partyId) + storage.forward(req) + } + + private def getPartyId: Future[String] = { + val partyId = Party.randomPartyId + (storage ? impl.DatabaseUserHandler.Exists(partyId)).mapTo[Boolean].flatMap { + case true => getPartyId + case false => Future.successful(partyId) + } + } +} + +object PartyService { + def props(storage: ActorRef): Props = Props(new PartyService(storage)) + + case class ForgetParty(partyId: String) + case object GetNewPartyId +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala index 6b29033..b9d2eb6 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala @@ -9,7 +9,7 @@ package me.arcanis.ffxivbis.service.impl import akka.pattern.pipe -import me.arcanis.ffxivbis.models.{BiS, Piece, PlayerId} +import me.arcanis.ffxivbis.models.{Piece, PlayerId} import me.arcanis.ffxivbis.service.Database trait DatabaseBiSHandler { this: Database => @@ -33,7 +33,11 @@ trait DatabaseBiSHandler { this: Database => } object DatabaseBiSHandler { - case class AddPieceToBis(playerId: PlayerId, piece: Piece) - case class GetBiS(partyId: String, playerId: Option[PlayerId]) - case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece) + case class AddPieceToBis(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest { + override def partyId: String = playerId.partyId + } + case class GetBiS(partyId: String, playerId: Option[PlayerId]) extends Database.DatabaseRequest + case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest { + override def partyId: String = playerId.partyId + } } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala index 39f179e..ae10f24 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala @@ -37,8 +37,12 @@ trait DatabaseLootHandler { this: Database => } object DatabaseLootHandler { - case class AddPieceTo(playerId: PlayerId, piece: Piece) - case class GetLoot(partyId: String, playerId: Option[PlayerId]) - case class RemovePieceFrom(playerId: PlayerId, piece: Piece) - case class SuggestLoot(partyId: String, piece: Piece) + case class AddPieceTo(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest { + override def partyId: String = playerId.partyId + } + case class GetLoot(partyId: String, playerId: Option[PlayerId]) extends Database.DatabaseRequest + case class RemovePieceFrom(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest { + override def partyId: String = playerId.partyId + } + case class SuggestLoot(partyId: String, piece: Piece) extends Database.DatabaseRequest } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala index 3b86b2a..ed05b35 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala @@ -47,8 +47,14 @@ trait DatabasePartyHandler { this: Database => } object DatabasePartyHandler { - case class AddPlayer(player: Player) - case class GetParty(partyId: String) - case class GetPlayer(playerId: PlayerId) - case class RemovePlayer(playerId: PlayerId) + case class AddPlayer(player: Player) extends Database.DatabaseRequest { + override def partyId: String = player.partyId + } + case class GetParty(partyId: String) extends Database.DatabaseRequest + case class GetPlayer(playerId: PlayerId) extends Database.DatabaseRequest { + override def partyId: String = playerId.partyId + } + case class RemovePlayer(playerId: PlayerId) extends Database.DatabaseRequest { + override def partyId: String = playerId.partyId + } } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala index 8298120..ddb62b0 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala @@ -25,6 +25,10 @@ trait DatabaseUserHandler { this: Database => val client = sender() profile.deleteUser(partyId, username).pipeTo(client) + case Exists(partyId) => + val client = sender() + profile.exists(partyId).pipeTo(client) + case GetUser(partyId, username) => val client = sender() profile.getUser(partyId, username).pipeTo(client) @@ -36,8 +40,11 @@ trait DatabaseUserHandler { this: Database => } object DatabaseUserHandler { - case class AddUser(user: User, isHashedPassword: Boolean) - case class DeleteUser(partyId: String, username: String) - case class GetUser(partyId: String, username: String) - case class GetUsers(partyId: String) + case class AddUser(user: User, isHashedPassword: Boolean) extends Database.DatabaseRequest { + override def partyId: String = user.partyId + } + case class DeleteUser(partyId: String, username: String) extends Database.DatabaseRequest + case class Exists(partyId: String) extends Database.DatabaseRequest + case class GetUser(partyId: String, username: String) extends Database.DatabaseRequest + case class GetUsers(partyId: String) extends Database.DatabaseRequest } diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala index 8680d48..2f8ef43 100644 --- a/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala +++ b/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala @@ -42,6 +42,8 @@ trait UsersProfile { this: DatabaseProfile => def deleteUser(partyId: String, username: String): Future[Int] = db.run(user(partyId, Some(username)).delete) + def exists(partyId: String): Future[Boolean] = + db.run(user(partyId, None).exists.result) def getUser(partyId: String, username: String): Future[Option[User]] = db.run(user(partyId, Some(username)).result.headOption).map(_.map(_.toUser)) def getUsers(partyId: String): Future[Seq[User]] = diff --git a/src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala b/src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala index ce924ae..ec43870 100644 --- a/src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala +++ b/src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala @@ -17,11 +17,14 @@ import scala.concurrent.duration.FiniteDuration import scala.language.implicitConversions object Implicits { - implicit def getBooleanFromOptionString(maybeYes: Option[String]): Boolean = maybeYes match { + implicit def getBooleanFromOptionString(maybeYes: Option[String]): Boolean = maybeYes.map(_.toLowerCase) match { case Some("yes" | "on") => true case _ => false } - implicit def getFiniteDuration(duration: Duration): Timeout = + implicit def getFiniteDuration(duration: Duration): FiniteDuration = + FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS) + + implicit def getTimeout(duration: Duration): Timeout = FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS) } diff --git a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpointTest.scala b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpointTest.scala index 3691d38..a3cddfe 100644 --- a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpointTest.scala +++ b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpointTest.scala @@ -11,7 +11,7 @@ import com.typesafe.config.Config import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.models.BiS -import me.arcanis.ffxivbis.service.{Ariyala, impl} +import me.arcanis.ffxivbis.service.{Ariyala, PartyService, impl} import me.arcanis.ffxivbis.storage.Migration import org.scalatest.{Matchers, WordSpec} @@ -31,7 +31,8 @@ class BiSEndpointTest extends WordSpec private val storage: ActorRef = system.actorOf(impl.DatabaseImpl.props) private val ariyala: ActorRef = system.actorOf(Ariyala.props) - private val route: Route = new BiSEndpoint(storage, ariyala)(timeout).route + private val party: ActorRef = system.actorOf(PartyService.props(storage)) + private val route: Route = new BiSEndpoint(party, ariyala)(timeout).route override def testConfig: Config = Settings.withRandomDatabase diff --git a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpointTest.scala b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpointTest.scala index 7f6bc22..1ed7042 100644 --- a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpointTest.scala +++ b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpointTest.scala @@ -10,7 +10,7 @@ import akka.testkit.TestKit import com.typesafe.config.Config import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.http.api.v1.json._ -import me.arcanis.ffxivbis.service.impl +import me.arcanis.ffxivbis.service.{PartyService, impl} import me.arcanis.ffxivbis.storage.Migration import org.scalatest.{Matchers, WordSpec} @@ -29,7 +29,8 @@ class LootEndpointTest extends WordSpec implicit private val routeTimeout: RouteTestTimeout = RouteTestTimeout(timeout) private val storage: ActorRef = system.actorOf(impl.DatabaseImpl.props) - private val route: Route = new LootEndpoint(storage)(timeout).route + private val party: ActorRef = system.actorOf(PartyService.props(storage)) + private val route: Route = new LootEndpoint(party)(timeout).route override def testConfig: Config = Settings.withRandomDatabase diff --git a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/PartyEndpointTest.scala b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/PartyEndpointTest.scala index 5b69c89..c17d3af 100644 --- a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/PartyEndpointTest.scala +++ b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/PartyEndpointTest.scala @@ -10,7 +10,7 @@ import akka.testkit.TestKit import com.typesafe.config.Config import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.http.api.v1.json._ -import me.arcanis.ffxivbis.service.{Ariyala, impl} +import me.arcanis.ffxivbis.service.{Ariyala, PartyService, impl} import me.arcanis.ffxivbis.storage.Migration import org.scalatest.{Matchers, WordSpec} @@ -30,7 +30,8 @@ class PartyEndpointTest extends WordSpec private val storage: ActorRef = system.actorOf(impl.DatabaseImpl.props) private val ariyala: ActorRef = system.actorOf(Ariyala.props) - private val route: Route = new PlayerEndpoint(storage, ariyala)(timeout).route + private val party: ActorRef = system.actorOf(PartyService.props(storage)) + private val route: Route = new PlayerEndpoint(party, ariyala)(timeout).route override def testConfig: Config = Settings.withRandomDatabase diff --git a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpointTest.scala b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpointTest.scala index 609ef74..3b99653 100644 --- a/src/test/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpointTest.scala +++ b/src/test/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpointTest.scala @@ -9,7 +9,7 @@ import akka.testkit.TestKit import com.typesafe.config.Config import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.http.api.v1.json._ -import me.arcanis.ffxivbis.service.impl +import me.arcanis.ffxivbis.service.{PartyService, impl} import me.arcanis.ffxivbis.storage.Migration import org.scalatest.{Matchers, WordSpec} @@ -22,12 +22,14 @@ class UserEndpointTest extends WordSpec private val auth: Authorization = Authorization(BasicHttpCredentials(Fixtures.userAdmin.username, Fixtures.userPassword)) - private val endpoint: Uri = Uri(s"/party/${Fixtures.partyId}/users") + private def endpoint: Uri = Uri(s"/party/$partyId/users") private val timeout: FiniteDuration = 60 seconds implicit private val routeTimeout: RouteTestTimeout = RouteTestTimeout(timeout) + private var partyId: String = Fixtures.partyId private val storage: ActorRef = system.actorOf(impl.DatabaseImpl.props) - private val route: Route = new UserEndpoint(storage)(timeout).route + private val party: ActorRef = system.actorOf(PartyService.props(storage)) + private val route: Route = new UserEndpoint(party)(timeout).route override def testConfig: Config = Settings.withRandomDatabase @@ -43,18 +45,17 @@ class UserEndpointTest extends WordSpec "api v1 users endpoint" must { "create a party" in { - val uri = Uri(s"/party/${Fixtures.partyId}/create") + val uri = Uri(s"/party") val entity = UserResponse.fromUser(Fixtures.userAdmin).copy(password = Fixtures.userPassword) - println(entity) Put(uri, entity) ~> route ~> check { - status shouldEqual StatusCodes.Created - responseAs[String] shouldEqual "" + status shouldEqual StatusCodes.OK + partyId = responseAs[PartyIdResponse].partyId } } "add user" in { - val entity = UserResponse.fromUser(Fixtures.userGet).copy(password = Fixtures.userPassword2) + val entity = UserResponse.fromUser(Fixtures.userGet).copy(partyId = partyId, password = Fixtures.userPassword2) Post(endpoint, entity).withHeaders(auth) ~> route ~> check { status shouldEqual StatusCodes.Accepted @@ -70,13 +71,28 @@ class UserEndpointTest extends WordSpec status shouldEqual StatusCodes.OK val users = responseAs[Seq[UserResponse]] - users.map(_.partyId).distinct shouldEqual Seq(Fixtures.partyId) + users.map(_.partyId).distinct shouldEqual Seq(partyId) users.map(user => user.username -> user.permission).toMap shouldEqual party } } "remove user" in { + val entity = UserResponse.fromUser(Fixtures.userGet).copy(partyId = partyId) + Delete(endpoint.toString + s"/${entity.username}").withHeaders(auth) ~> route ~> check { + status shouldEqual StatusCodes.Accepted + } + + val party = Seq(Fixtures.userAdmin) + .map(user => user.username -> Some(user.permission)).toMap + + Get(endpoint).withHeaders(auth) ~> route ~> check { + status shouldEqual StatusCodes.OK + + val users = responseAs[Seq[UserResponse]] + users.map(_.partyId).distinct shouldEqual Seq(partyId) + users.map(user => user.username -> user.permission).toMap shouldEqual party + } } }