40 Commits

Author SHA1 Message Date
a6991a0a91 Release 0.9.13 2022-01-07 15:49:05 +03:00
5ec372be87 add item cache 2022-01-07 15:24:29 +03:00
bcdc88fa2c swagger fixes 2022-01-06 19:24:05 +03:00
53b42a6fa8 exception safety, github actions and scalafmt 2022-01-06 19:01:30 +03:00
99ed2705a2 another test for bis part 2022-01-06 06:19:57 +03:00
0ed9e92441 release 0.9.12 2021-12-19 22:44:39 +03:00
1866a1bb12 endwalker support
* added sge and rpr
* changed way to define savage gear
* libraries update
2021-12-19 22:39:20 +03:00
08f7f4571e Release 0.9.11
change loggin, more tests, cosmetic changes
2020-12-12 20:15:14 +03:00
d9cbb6cf00 Release 0.9.10 2020-12-04 12:08:34 +03:00
df8e09f02c fix column names 2020-12-04 12:08:08 +03:00
df1f28c7ef Release 0.9.9 2020-12-04 11:47:36 +03:00
8d516cdb15 use typed actors (#12)
* initial typed actor impl
* fix sending response on ask
2020-12-04 11:45:23 +03:00
2e16a8c1fa multi item support 2020-12-03 03:35:06 +03:00
25b05aa289 identify url in runtime 2020-11-26 23:43:14 +03:00
534ed98459 etro support 2020-11-26 23:27:33 +03:00
0171b229a1 demo with optional loot support 2020-03-17 02:13:21 +03:00
10c107d2c2 crafted items support (#5) 2020-03-13 03:36:25 +03:00
16ce0bf61c add support of party alias 2020-03-09 01:48:24 +03:00
1e6064e081 Feature/timestamp support (#8)
* initial timestamp support

* compilation & test fixes

* do not take default argument
2020-03-08 03:48:25 +03:00
92e2c1d383 less conversions 2020-03-08 02:39:54 +03:00
5eae1d46a2 Release 0.9.8 2020-02-26 01:17:33 +03:00
eb24019965 another iteration with parsing xivapi 2020-02-26 01:16:57 +03:00
173ea9079f release 0.9.7 2020-02-25 03:18:51 +03:00
12c99bd52c fix to recent xivapi changes 2020-02-25 03:17:03 +03:00
bdfb5aedeb Update README.md 2020-02-12 01:56:16 +03:00
666a1b8b7a fix api urls in swagger 2019-12-29 03:47:43 +03:00
65a4a25b3a force type number 2019-11-22 01:40:52 +03:00
37c444a5b9 badges 2019-11-18 23:38:31 +03:00
f5a644747d change travis config 2019-11-18 23:24:50 +03:00
ab790e87ff better job handling 2019-11-18 23:22:23 +03:00
9faceb4f61 some fixes 2019-11-18 23:00:54 +03:00
65b9e53b66 additional methods to types endpoint 2019-11-17 16:23:43 +03:00
ad144534a9 0.9.4
* types api
* dist instead of assembly (assembly does not allow to use swagger easily)
* small swagger improvements
2019-11-15 01:27:15 +03:00
4700768aed report errors 2019-11-11 10:03:07 +03:00
557038c262 rename property 2019-11-05 00:28:21 +03:00
6e8b64feef some improvements in loot system 2019-11-04 15:19:18 +03:00
0a71a98482 postgres config update 2019-11-03 15:48:05 +03:00
69d35c95d9 fix migration 2019-11-03 14:14:48 +03:00
155790465e add notes about downloading jar 2019-11-02 15:29:48 +03:00
da00a60332 fix assembly build 2019-11-02 15:26:29 +03:00
144 changed files with 5728 additions and 1894 deletions

40
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: release
on:
push:
tags:
- '*.*.*'
jobs:
make-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: extract version
id: version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: create changelog
id: changelog
uses: jaywcjlove/changelog-generator@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
filter: 'Release \d+\.\d+\.\d+'
- name: setup JDK
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 8
- name: create dist
run: sbt -v dist
- name: release
uses: softprops/action-gh-release@v1
with:
body: |
${{ steps.changelog.outputs.compareurl }}
${{ steps.changelog.outputs.changelog }}
files: target/universal/ffxivbis-*.zip
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

22
.github/workflows/run-tests.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: setup JDK
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 8
- name: run tests
run: sbt -v +test

35
.scalafmt.conf Normal file
View File

@ -0,0 +1,35 @@
version = 3.3.1
runner.dialect = "scala213"
maxColumn = 120
align.preset = none
continuationIndent {
defnSite = 2
extendSite = 2
}
rewrite {
rules = [
AvoidInfix,
RedundantBraces,
RedundantParens,
SortImports,
SortModifiers
]
redundantBraces {
generalExpressions = yes
ifElseExpressions = yes
includeUnitMethods = yes
methodBodies = yes
parensForOneLineApply = yes
stringInterpolation = yes
}
}
importSelectors = singleLine
trailingCommas = preserve

View File

@ -1,9 +0,0 @@
sudo: required
language: generic
services:
- docker
script:
- docker run -it --rm -v "$(pwd):/opt/build" -w /opt/build mozilla/sbt sbt compile
- docker run -it --rm -v "$(pwd):/opt/build" -w /opt/build mozilla/sbt sbt test

View File

@ -1,23 +1,31 @@
# FFXIV BiS # FFXIV BiS
Service which allows to manage savage loot distribution easy. [![Build status](https://github.com/arcan1s/ffxivbis/actions/workflows/run-tests.yml/badge.svg)](https://github.com/arcan1s/ffxivbis/actions/workflows/run-tests.yml) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/arcan1s/ffxivbis)
Service which allows managing savage loot distribution easy.
## Installation and usage ## Installation and usage
In general installation process looks like: In general compilation process looks like:
```bash ```bash
sbt assembly sbt dist
``` ```
Service can be run by using command: Or alternatively you can download the latest distribution zip from the releases page. Service can be run by using command:
```bash ```bash
java -cp ./target/scala-2.13/ffxivbis-scala-assembly-0.1.jar me.arcanis.ffxivbis.ffxivbis bin/ffxivbis
``` ```
from the extracted archive root.
## Web service ## Web service
REST API documentation is available at `http://0.0.0.0:8000/swagger`. HTML representation is available at `http://0.0.0.0:8000`. REST API documentation is available at `http://0.0.0.0:8000/swagger`. HTML representation is available at `http://0.0.0.0:8000`.
*Note*: host and port depend on configuration settings. *Note*: host and port depend on configuration settings.
## Public service
There is also public service which is available at http://ffxivbis.arcanis.me.

View File

@ -1,5 +1,16 @@
name := "ffxivbis" name := "ffxivbis"
scalaVersion := "2.13.1" scalaVersion := "2.13.6"
scalacOptions ++= Seq("-deprecation", "-feature") scalacOptions ++= Seq("-deprecation", "-feature")
enablePlugins(JavaAppPackaging)
assemblyMergeStrategy in assembly := {
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case "application.conf" => MergeStrategy.concat
case "module-info.class" => MergeStrategy.first
case x =>
val oldStrategy = (assemblyMergeStrategy in assembly).value
oldStrategy(x)
}

56
extract_items.py Normal file
View File

@ -0,0 +1,56 @@
import json
import requests
# NOTE: it does not cover all items, just workaround to extract most gear pieces from patches
MIN_ILVL = 580
MAX_ILVL = 605
TOME = (
'radiant',
)
SAVAGE = (
'asphodelos',
)
payload = {
'queries': [
{
'slots': []
},
{
'jobs': [],
'minItemLevel': 580,
'maxItemLevel': 605
}
],
'existing': []
}
# it does not support application/json
r = requests.post('https://ffxiv.ariyala.com/items.app', data=json.dumps(payload))
r.raise_for_status()
result = []
for item in r.json():
item_id = item['itemID']
source_dict = item['source']
name = item['name']['en']
if 'crafting' in source_dict:
source = 'Crafted'
elif 'gathering' in source_dict:
continue # some random shit
elif 'purchase' in source_dict:
if any(tome in name.lower() for tome in TOME):
source = 'Tome'
elif any(savage in name.lower() for savage in SAVAGE):
source = 'Savage'
else:
source = None
continue
else:
raise RuntimeError(f'Unknown source {source_dict}')
result.append({'id': item_id, 'source': source, 'name': name})
output = {'cached-items': result}
print(json.dumps(output, indent=4, sort_keys=True))

View File

@ -1,19 +1,33 @@
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.3" val AkkaVersion = "2.6.17"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2" val AkkaHttpVersion = "10.2.7"
val ScalaTestVersion = "3.2.10"
val SlickVersion = "3.3.3"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.1.10" libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.9"
libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.10" libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4"
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % "2.5.23"
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.0.4"
libraryDependencies += "javax.ws.rs" % "javax.ws.rs-api" % "2.1.1"
libraryDependencies += "io.spray" %% "spray-json" % "1.3.5" libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion
libraryDependencies += "com.lihaoyi" %% "scalatags" % "0.7.0" libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.6.0"
libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.0"
libraryDependencies += "com.typesafe.slick" %% "slick" % "3.3.2" libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"
libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % "3.3.2" libraryDependencies += "com.lihaoyi" %% "scalatags" % "0.9.2"
libraryDependencies += "org.flywaydb" % "flyway-core" % "6.0.6"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.28.0"
libraryDependencies += "org.postgresql" % "postgresql" % "9.3-1104-jdbc4"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.3m" libraryDependencies += "com.typesafe.slick" %% "slick" % SlickVersion
libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % SlickVersion
libraryDependencies += "org.flywaydb" % "flyway-core" % "8.2.2"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3"
libraryDependencies += "org.postgresql" % "postgresql" % "42.3.1"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
// testing
libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-stream-testkit" % AkkaVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-http-testkit" % AkkaHttpVersion % "test"

View File

@ -1 +0,0 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6")

4
project/plugins.sbt Normal file
View File

@ -0,0 +1,4 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3")
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4")
addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.10.0-RC1")

View File

@ -1,6 +1,6 @@
create table players ( create table players (
party_id text not null, party_id text not null,
player_id bigserial, player_id bigserial unique,
created bigint not null, created bigint not null,
nick text not null, nick text not null,
job text not null, job text not null,
@ -9,7 +9,7 @@ create table players (
create unique index players_nick_job_idx on players(party_id, nick, job); create unique index players_nick_job_idx on players(party_id, nick, job);
create table loot ( create table loot (
loot_id bigserial, loot_id bigserial unique,
player_id bigint not null, player_id bigint not null,
created bigint not null, created bigint not null,
piece text not null, piece text not null,
@ -29,7 +29,7 @@ create unique index bis_piece_player_id_idx on bis(player_id, piece);
create table users ( create table users (
party_id text not null, party_id text not null,
user_id bigserial, user_id bigserial unique,
username text not null, username text not null,
password text not null, password text not null,
permission text not null); permission text not null);

View File

@ -0,0 +1,5 @@
update loot set piece = 'left ring' where piece = 'leftRing';
update loot set piece = 'right ring' where piece = 'rightRing';
update bis set piece = 'left ring' where piece = 'leftRing';
update bis set piece = 'right ring' where piece = 'rightRing';

View File

@ -0,0 +1,5 @@
create table parties (
player_id bigserial unique,
party_name text not null,
party_alias text);
create unique index parties_party_name_idx on parties(party_name);

View File

@ -0,0 +1,17 @@
-- loot
alter table loot add column piece_type text;
update loot set piece_type = 'Tome' where is_tome = 1;
update loot set piece_type = 'Savage' where is_tome = 0;
alter table loot alter column piece_type set not null;
alter table loot drop column is_tome;
-- bis
alter table bis add column piece_type text;
update bis set piece_type = 'Tome' where is_tome = 1;
update bis set piece_type = 'Savage' where is_tome = 0;
alter table bis alter column piece_type set not null;
alter table bis drop column is_tome;

View File

@ -0,0 +1 @@
alter table loot add column is_free_loot integer not null default 0;

View File

@ -0,0 +1,2 @@
drop index bis_piece_player_id_idx;
create index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -0,0 +1 @@
alter table parties rename column player_id to party_id;

View File

@ -0,0 +1,5 @@
update loot set piece = 'left ring' where piece = 'leftRing';
update loot set piece = 'right ring' where piece = 'rightRing';
update bis set piece = 'left ring' where piece = 'leftRing';
update bis set piece = 'right ring' where piece = 'rightRing';

View File

@ -0,0 +1,5 @@
create table parties (
player_id integer primary key autoincrement,
party_name text not null,
party_alias text);
create unique index parties_party_name_idx on parties(party_name);

View File

@ -0,0 +1,42 @@
-- loot
alter table loot add column piece_type text;
update loot set piece_type = 'Tome' where is_tome = 1;
update loot set piece_type = 'Savage' where is_tome = 0;
create table loot_new (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into loot_new select loot_id, player_id, created, piece, piece_type, job from loot;
drop index loot_owner_idx;
drop table loot;
alter table loot_new rename to loot;
create index loot_owner_idx on loot(player_id);
-- bis
alter table bis add column piece_type text;
update bis set piece_type = 'Tome' where is_tome = 1;
update bis set piece_type = 'Savage' where is_tome = 0;
create table bis_new (
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into bis_new select player_id, created, piece, piece_type, job from bis;
drop index bis_piece_player_id_idx;
drop table bis;
alter table bis_new rename to bis;
create unique index bis_piece_player_id_idx on bis(player_id, piece);

View File

@ -0,0 +1,20 @@
alter table loot add column is_free_loot integer;
update loot set is_free_loot = 0;
create table loot_new (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
is_free_loot integer not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into loot_new select loot_id, player_id, created, piece, piece_type, job, is_free_loot from loot;
drop index loot_owner_idx;
drop table loot;
alter table loot_new rename to loot;
create index loot_owner_idx on loot(player_id);

View File

@ -0,0 +1,2 @@
drop index bis_piece_player_id_idx;
create index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -0,0 +1,11 @@
create table parties_new (
party_id integer primary key autoincrement,
party_name text not null,
party_alias text);
insert into parties_new select player_id, party_name, party_alias from parties;
drop index parties_party_name_idx;
drop table parties;
alter table parties_new rename to parties;
create unique index parties_party_name_idx on parties(party_name);

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>ReDoc</title> <title>FFXIV loot tracker API</title>
<!-- needed for adaptive design --> <!-- needed for adaptive design -->
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,10 @@
</root> </root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" /> <logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" level="DEBUG"> <logger name="http" level="DEBUG" additivity="false">
<appender-ref ref="http" /> <appender-ref ref="http" />
</logger> </logger>
<logger name="slick" level="INFO" /> <logger name="slick" level="INFO" />
<logger name="org.flywaydb.core.internal" level="INFO" />
</configuration> </configuration>

View File

@ -1,11 +1,12 @@
me.arcanis.ffxivbis { me.arcanis.ffxivbis {
ariyala {
# ariyala base url, string, required bis-provider {
ariyala-url = "https://ffxiv.ariyala.com" include "item_data.json"
# xivapi base url, string, required # xivapi base url, string, required
xivapi-url = "https://xivapi.com" xivapi-url = "https://xivapi.com"
# xivapi developer key, string, optional # xivapi developer key, string, optional
# xivapi-key = "abcdef" #xivapi-key = "abcdef"
} }
database { database {
@ -27,8 +28,11 @@ me.arcanis.ffxivbis {
profile = "slick.jdbc.PostgresProfile$" profile = "slick.jdbc.PostgresProfile$"
db { db {
url = "jdbc:postgresql://localhost/ffxivbis" url = "jdbc:postgresql://localhost/ffxivbis"
user = "user" user = "ffxivbis"
password = "password" password = "ffxivbis"
connectionPool = disabled
keepAliveConnection = yes
} }
numThreads = 10 numThreads = 10
} }
@ -48,8 +52,19 @@ me.arcanis.ffxivbis {
web { web {
# address to bind, string, required # address to bind, string, required
host = "0.0.0.0" host = "127.0.0.1"
# port to bind, int, required # port to bind, int, required
port = 8000 port = 8000
# hostname to use in docs, if not set host:port will be used
#hostname = "127.0.0.1:8000"
}
default-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor {
fixed-pool-size = 16
}
throughput = 1
} }
} }

View File

@ -0,0 +1,24 @@
REST json API description to interact with FFXIVBiS service.
# Basic workflow
* Create party using `PUT /api/v1/party` endpoint. It consumes username and password of administrator (which can't be restored). As the result it returns unique id of created party.
* Create new users which have access to this party. Note that user belongs to specific party id and in scope of the specified party it must be unique.
* Add players with their best in slot sets (probably by using ariyala links).
* Add loot items if any.
* By using `PUT /api/v1/party/{partyId}/loot` API find players which are better for the specified loot.
* Add new loot item to the selected player.
# Limitations
# Authentication
For the most party utils service requires user to be authenticated. User permission can be one of `get`, `post` or `admin`.
* `admin` permission means that the user is allowed to do anything, especially this permission is required to be able to add or modify users.
* `post` permission is required to deal with the most POST API endpoints, but to be precise only endpoints which modifies party content require this permission.
* `get` permission is required to have access to party.
`admin` permission includes any other permissions, `post` allows to perform get requests.
<security-definitions />

View File

@ -8,45 +8,66 @@
*/ */
package me.arcanis.ffxivbis package me.arcanis.ffxivbis
import akka.actor.{Actor, Props} import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.http.scaladsl.Http import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer import akka.http.scaladsl.server.Route
import akka.stream.Materializer
import com.typesafe.scalalogging.StrictLogging import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.{Ariyala, PartyService} import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.impl.DatabaseImpl import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.storage.Migration import me.arcanis.ffxivbis.storage.Migration
import scala.concurrent.{Await, ExecutionContext} import scala.concurrent.ExecutionContext
import scala.concurrent.duration.Duration import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
class Application extends Actor with StrictLogging { class Application(context: ActorContext[Nothing]) extends AbstractBehavior[Nothing](context) with StrictLogging {
implicit private val executionContext: ExecutionContext = context.system.dispatcher
implicit private val materializer: ActorMaterializer = ActorMaterializer()
private val config = context.system.settings.config logger.info("root supervisor started")
private val host = config.getString("me.arcanis.ffxivbis.web.host") startApplication()
private val port = config.getInt("me.arcanis.ffxivbis.web.port")
override def receive: Receive = Actor.emptyBehavior override def onMessage(msg: Nothing): Behavior[Nothing] = Behaviors.unhandled
Migration(config).onComplete { override def onSignal: PartialFunction[Signal, Behavior[Nothing]] = { case PostStop =>
case Success(_) => logger.info("root supervisor stopped")
val ariyala = context.system.actorOf(Ariyala.props, "ariyala") Behaviors.same
val storage = context.system.actorOf(DatabaseImpl.props, "storage") }
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") private def startApplication(): Unit = {
val bind = Http()(context.system).bindAndHandle(http.route, host, port) val config = context.system.settings.config
Await.result(context.system.whenTerminated, Duration.Inf) val host = config.getString("me.arcanis.ffxivbis.web.host")
bind.foreach(_.unbind()) val port = config.getInt("me.arcanis.ffxivbis.web.port")
case Failure(exception) => throw exception implicit val executionContext: ExecutionContext = context.system.executionContext
implicit val materializer: Materializer = Materializer(context)
Migration(config) match {
case Success(result) if result.success =>
val bisProvider = context.spawn(BisProvider(), "bis-provider")
val storage = context.spawn(Database(), "storage")
val party = context.spawn(PartyService(storage), "party")
val http = new RootEndpoint(context.system, party, bisProvider)
val flow = Route.toFlow(http.route)(context.system)
Http(context.system).newServerAt(host, port).bindFlow(flow)
case Success(result) =>
logger.error(s"migration completed with error, executed ${result.migrationsExecuted}")
result.migrations.asScala.foreach(o => logger.info(s"=> ${o.description} (${o.executionTime})"))
context.system.terminate()
case Failure(exception) =>
logger.error("exception during migration", exception)
context.system.terminate()
}
} }
} }
object Application { object Application {
def props: Props = Props(new Application)
def apply(): Behavior[Nothing] =
Behaviors.setup[Nothing](context => new Application(context))
} }

View File

@ -8,13 +8,13 @@
*/ */
package me.arcanis.ffxivbis package me.arcanis.ffxivbis
import akka.actor.ActorSystem import akka.actor.typed.ActorSystem
import com.typesafe.config.ConfigFactory import com.typesafe.config.ConfigFactory
object ffxivbis { object ffxivbis {
def main(args: Array[String]): Unit = { def main(args: Array[String]): Unit = {
val config = ConfigFactory.load() val config = ConfigFactory.load()
val actorSystem = ActorSystem("ffxivbis", config) ActorSystem[Nothing](Application(), "ffxivbis", config)
actorSystem.actorOf(Application.props, "ffxivbis")
} }
} }

View File

@ -1,24 +0,0 @@
/*
* 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.http
import akka.actor.ActorRef
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.{BiS, Job, Piece}
import me.arcanis.ffxivbis.service.Ariyala
import scala.concurrent.{ExecutionContext, Future}
class AriyalaHelper(ariyala: ActorRef) {
def downloadBiS(link: String, job: Job.Job)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[BiS] =
(ariyala ? Ariyala.GetBiS(link, job)).mapTo[BiS]
}

View File

@ -8,25 +8,24 @@
*/ */
package me.arcanis.ffxivbis.http package me.arcanis.ffxivbis.http
import akka.actor.ActorRef import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.model.headers._
import akka.http.scaladsl.server.AuthenticationFailedRejection._ import akka.http.scaladsl.server.AuthenticationFailedRejection._
import akka.http.scaladsl.server._
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.pattern.ask import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.models.{Permission, User} import me.arcanis.ffxivbis.messages.{GetUser, Message}
import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler import me.arcanis.ffxivbis.models.Permission
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
// idea comes from https://synkre.com/bcrypt-for-akka-http-password-encryption/ // idea comes from https://synkre.com/bcrypt-for-akka-http-password-encryption/
trait Authorization { trait Authorization {
def storage: ActorRef def storage: ActorRef[Message]
def authenticateBasicBCrypt[T](realm: String, def authenticateBasicBCrypt[T](realm: String, authenticate: (String, String) => Future[Option[T]]): Directive1[T] = {
authenticate: (String, String) => Future[Option[T]]): Directive1[T] = {
def challenge = HttpChallenges.basic(realm) def challenge = HttpChallenges.basic(realm)
extractCredentials.flatMap { extractCredentials.flatMap {
@ -39,23 +38,34 @@ trait Authorization {
} }
} }
def authenticator(scope: Permission.Value)(partyId: String) def authenticator(scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
(username: String, password: String) executionContext: ExecutionContext,
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = timeout: Timeout,
(storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]].map { scheduler: Scheduler
): Future[Option[String]] =
storage.ask(GetUser(partyId, username, _)).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(username) case Some(user) if user.verify(password) && user.verityScope(scope) => Some(username)
case _ => None case _ => None
} }
def authAdmin(partyId: String)(username: String, password: String) def authAdmin(partyId: String)(username: String, password: String)(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = executionContext: ExecutionContext,
authenticator(Permission.admin)(partyId)(username, password) timeout: Timeout,
scheduler: Scheduler
): Future[Option[String]] =
authenticator(Permission.admin, partyId)(username, password)
def authGet(partyId: String)(username: String, password: String) def authGet(partyId: String)(username: String, password: String)(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = executionContext: ExecutionContext,
authenticator(Permission.get)(partyId)(username, password) timeout: Timeout,
scheduler: Scheduler
): Future[Option[String]] =
authenticator(Permission.get, partyId)(username, password)
def authPost(partyId: String)(username: String, password: String) def authPost(partyId: String)(username: String, password: String)(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = executionContext: ExecutionContext,
authenticator(Permission.post)(partyId)(username, password) timeout: Timeout,
scheduler: Scheduler
): Future[Option[String]] =
authenticator(Permission.post, partyId)(username, password)
} }

View File

@ -8,40 +8,51 @@
*/ */
package me.arcanis.ffxivbis.http package me.arcanis.ffxivbis.http
import akka.actor.ActorRef import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.pattern.ask import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages.{AddPieceToBis, GetBiS, Message, RemovePieceFromBiS, RemovePiecesFromBiS}
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId} import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import me.arcanis.ffxivbis.service.impl.DatabaseBiSHandler
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class BiSHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) { trait BiSHelper extends BisProviderHelper {
def addPieceBiS(playerId: PlayerId, piece: Piece) def storage: ActorRef[Message]
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseBiSHandler.AddPieceToBis(playerId, piece.withJob(playerId.job))).mapTo[Int]
def bis(partyId: String, playerId: Option[PlayerId]) def addPieceBiS(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = storage.ask(AddPieceToBis(playerId, piece.withJob(playerId.job), _))
(storage ? DatabaseBiSHandler.GetBiS(partyId, playerId)).mapTo[Seq[Player]]
def doModifyBiS(action: ApiAction.Value, playerId: PlayerId, piece: Piece) def bis(partyId: String, playerId: Option[PlayerId])(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = timeout: Timeout,
scheduler: Scheduler
): Future[Seq[Player]] =
storage.ask(GetBiS(partyId, playerId, _))
def doModifyBiS(action: ApiAction.Value, playerId: PlayerId, piece: Piece)(implicit
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
action match { action match {
case ApiAction.add => addPieceBiS(playerId, piece) case ApiAction.add => addPieceBiS(playerId, piece)
case ApiAction.remove => removePieceBiS(playerId, piece) case ApiAction.remove => removePieceBiS(playerId, piece)
} }
def putBiS(playerId: PlayerId, link: String) def putBiS(playerId: PlayerId, link: String)(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = executionContext: ExecutionContext,
downloadBiS(link, playerId.job).flatMap { bis => timeout: Timeout,
Future.traverse(bis.pieces)(addPieceBiS(playerId, _)) scheduler: Scheduler
}.map(_ => ()) ): Future[Unit] =
storage.ask(RemovePiecesFromBiS(playerId, _)).flatMap { _ =>
downloadBiS(link, playerId.job)
.flatMap { bis =>
Future.traverse(bis.pieces)(addPieceBiS(playerId, _))
}
.map(_ => ())
}
def removePieceBiS(playerId: PlayerId, piece: Piece) def removePieceBiS(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = storage.ask(RemovePieceFromBiS(playerId, piece, _))
(storage ? DatabaseBiSHandler.RemovePieceFromBiS(playerId, piece)).mapTo[Int]
} }

View File

@ -0,0 +1,25 @@
/*
* 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.http
import akka.actor.typed.{ActorRef, Scheduler}
import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, DownloadBiS}
import me.arcanis.ffxivbis.models.{BiS, Job}
import scala.concurrent.Future
trait BisProviderHelper {
def provider: ActorRef[BiSProviderMessage]
def downloadBiS(link: String, job: Job.Job)(implicit timeout: Timeout, scheduler: Scheduler): Future[BiS] =
provider.ask(DownloadBiS(link, job, _))
}

View File

@ -8,38 +8,48 @@
*/ */
package me.arcanis.ffxivbis.http package me.arcanis.ffxivbis.http
import akka.actor.ActorRef import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.pattern.ask import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.messages.{AddPieceTo, GetLoot, Message, RemovePieceFrom, SuggestLoot}
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters} import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters}
import me.arcanis.ffxivbis.service.LootSelector.LootSelectorResult
import me.arcanis.ffxivbis.service.impl.DatabaseLootHandler
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class LootHelper(storage: ActorRef) { trait LootHelper {
def addPieceLoot(playerId: PlayerId, piece: Piece) def storage: ActorRef[Message]
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseLootHandler.AddPieceTo(playerId, piece)).mapTo[Int]
def doModifyLoot(action: ApiAction.Value, playerId: PlayerId, piece: Piece) def addPieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = timeout: Timeout,
action match { scheduler: Scheduler
case ApiAction.add => addPieceLoot(playerId, piece) ): Future[Unit] =
case ApiAction.remove => removePieceLoot(playerId, piece) storage.ask(AddPieceTo(playerId, piece, isFreeLoot, _))
def doModifyLoot(action: ApiAction.Value, playerId: PlayerId, piece: Piece, maybeFree: Option[Boolean])(implicit
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
(action, maybeFree) match {
case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot)
case (ApiAction.remove, _) => removePieceLoot(playerId, piece)
case _ => throw new IllegalArgumentException(s"Invalid combinantion of action $action and fee loot $maybeFree")
} }
def loot(partyId: String, playerId: Option[PlayerId]) def loot(partyId: String, playerId: Option[PlayerId])(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = timeout: Timeout,
(storage ? DatabaseLootHandler.GetLoot(partyId, playerId)).mapTo[Seq[Player]] scheduler: Scheduler
): Future[Seq[Player]] =
storage.ask(GetLoot(partyId, playerId, _))
def removePieceLoot(playerId: PlayerId, piece: Piece) def removePieceLoot(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = storage.ask(RemovePieceFrom(playerId, piece, _))
(storage ? DatabaseLootHandler.RemovePieceFrom(playerId, piece)).mapTo[Int]
def suggestPiece(partyId: String, piece: Piece) def suggestPiece(partyId: String, piece: Piece)(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[PlayerIdWithCounters]] = executionContext: ExecutionContext,
(storage ? DatabaseLootHandler.SuggestLoot(partyId, piece)).mapTo[LootSelectorResult].map(_.result) timeout: Timeout,
scheduler: Scheduler
): Future[Seq[PlayerIdWithCounters]] =
storage.ask(SuggestLoot(partyId, piece, _)).map(_.result)
} }

View File

@ -8,46 +8,67 @@
*/ */
package me.arcanis.ffxivbis.http package me.arcanis.ffxivbis.http
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.pattern.ask import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.ApiAction import me.arcanis.ffxivbis.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.models.{Party, Player, PlayerId} import me.arcanis.ffxivbis.messages.{AddPieceToBis, AddPlayer, GetParty, GetPartyDescription, GetPlayer, Message, RemovePlayer, UpdateParty}
import me.arcanis.ffxivbis.service.impl.{DatabaseBiSHandler, DatabasePartyHandler} import me.arcanis.ffxivbis.models.{PartyDescription, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class PlayerHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) { trait PlayerHelper extends BisProviderHelper {
def addPlayer(player: Player) def storage: ActorRef[Message]
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabasePartyHandler.AddPlayer(player)).mapTo[Int].map { res => def addPlayer(
player.link match { player: Player
case Some(link) => )(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
downloadBiS(link, player.job).map { bis => storage
bis.pieces.map(storage ? DatabaseBiSHandler.AddPieceToBis(player.playerId, _)) .ask(ref => AddPlayer(player, ref))
}.map(_ => res) .map { res =>
case None => Future.successful(res) player.link.map(_.trim).filter(_.nonEmpty) match {
case Some(link) =>
downloadBiS(link, player.job)
.map { bis =>
bis.pieces.map(piece => storage.ask(AddPieceToBis(player.playerId, piece, _)))
}
.map(_ => res)
case None => Future.successful(res)
}
} }
}.flatten .flatten
def doModifyPlayer(action: ApiAction.Value, player: Player) def doModifyPlayer(action: ApiAction.Value, player: Player)(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
action match { action match {
case ApiAction.add => addPlayer(player) case ApiAction.add => addPlayer(player)
case ApiAction.remove => removePlayer(player.playerId) case ApiAction.remove => removePlayer(player.playerId)
} }
def getPlayers(partyId: String, maybePlayerId: Option[PlayerId]) def getPartyDescription(partyId: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[PartyDescription] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = storage.ask(GetPartyDescription(partyId, _))
def getPlayers(partyId: String, maybePlayerId: Option[PlayerId])(implicit
executionContext: ExecutionContext,
timeout: Timeout,
scheduler: Scheduler
): Future[Seq[Player]] =
maybePlayerId match { maybePlayerId match {
case Some(playerId) => case Some(playerId) =>
(storage ? DatabasePartyHandler.GetPlayer(playerId)).mapTo[Option[Player]].map(_.toSeq) storage.ask(GetPlayer(playerId, _)).map(_.toSeq)
case None => case None =>
(storage ? DatabasePartyHandler.GetParty(partyId)).mapTo[Party].map(_.players.values.toSeq) storage.ask(GetParty(partyId, _)).map(_.players.values.toSeq)
} }
def removePlayer(playerId: PlayerId) def removePlayer(playerId: PlayerId)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = storage.ask(RemovePlayer(playerId, _))
(storage ? DatabasePartyHandler.RemovePlayer(playerId)).mapTo[Int]
def updateDescription(
partyDescription: PartyDescription
)(implicit executionContext: ExecutionContext, timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(UpdateParty(partyDescription, _))
} }

View File

@ -10,25 +10,28 @@ package me.arcanis.ffxivbis.http
import java.time.Instant import java.time.Instant
import akka.actor.{ActorRef, ActorSystem} import akka.actor.typed.{ActorRef, ActorSystem, Scheduler}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.scalalogging.{Logger, StrictLogging} import com.typesafe.scalalogging.{Logger, StrictLogging}
import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint
import me.arcanis.ffxivbis.http.view.RootView import me.arcanis.ffxivbis.http.view.RootView
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef) class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], provider: ActorRef[BiSProviderMessage])
extends StrictLogging { extends StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._ import me.arcanis.ffxivbis.utils.Implicits._
private val config = system.settings.config private val config = system.settings.config
implicit val scheduler: Scheduler = system.scheduler
implicit val timeout: Timeout = implicit val timeout: Timeout =
config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") config.getDuration("me.arcanis.ffxivbis.settings.request-timeout")
private val rootApiV1Endpoint: RootApiV1Endpoint = new RootApiV1Endpoint(storage, ariyala) private val rootApiV1Endpoint: RootApiV1Endpoint = new RootApiV1Endpoint(storage, provider, config)
private val rootView: RootView = new RootView(storage, ariyala) private val rootView: RootView = new RootView(storage, provider)
private val swagger: Swagger = new Swagger(config)
private val httpLogger = Logger("http") private val httpLogger = Logger("http")
private val withHttpLog: Directive0 = private val withHttpLog: Directive0 =
@ -36,35 +39,36 @@ class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef)
val start = Instant.now.toEpochMilli val start = Instant.now.toEpochMilli
mapResponse { response => mapResponse { response =>
val time = (Instant.now.toEpochMilli - start) / 1000.0 val time = (Instant.now.toEpochMilli - start) / 1000.0
httpLogger.debug(s"""- - [${Instant.now}] "${context.request.method.name()} ${context.request.uri.path}" ${response.status.intValue()} ${response.entity.getContentLengthOption.getAsLong} $time""") httpLogger.debug(
s"""- - [${Instant.now}] "${context.request.method.name()} ${context.request.uri.path}" ${response.status
.intValue()} ${response.entity.getContentLengthOption.getAsLong} $time"""
)
response response
} }
} }
def route: Route = def route: Route =
withHttpLog { withHttpLog {
apiRoute ~ htmlRoute ~ Swagger.routes ~ swaggerUIRoute ignoreTrailingSlash {
apiRoute ~ htmlRoute ~ swagger.routes ~ swaggerUIRoute
}
} }
private def apiRoute: Route = private def apiRoute: Route =
ignoreTrailingSlash { pathPrefix("api") {
pathPrefix("api") { pathPrefix(Segment) {
pathPrefix(Segment) { case "v1" => rootApiV1Endpoint.route
case "v1" => rootApiV1Endpoint.route case _ => reject
case _ => reject
}
} }
} }
private def htmlRoute: Route = private def htmlRoute: Route =
ignoreTrailingSlash { pathPrefix("static") {
pathPrefix("static") { getFromResourceDirectory("static")
getFromResourceDirectory("static") } ~ rootView.route
} ~ rootView.route
}
private def swaggerUIRoute: Route = private def swaggerUIRoute: Route =
path("swagger") { path("swagger") {
getFromResource("swagger/index.html") getFromResource("html/swagger.html")
} ~ getFromResourceDirectory("swagger") }
} }

View File

@ -9,23 +9,40 @@
package me.arcanis.ffxivbis.http package me.arcanis.ffxivbis.http
import com.github.swagger.akka.SwaggerHttpService import com.github.swagger.akka.SwaggerHttpService
import com.github.swagger.akka.model.Info import com.github.swagger.akka.model.{Info, License}
import com.typesafe.config.Config
import io.swagger.v3.oas.models.security.SecurityScheme import io.swagger.v3.oas.models.security.SecurityScheme
object Swagger extends SwaggerHttpService { import scala.io.Source
class Swagger(config: Config) extends SwaggerHttpService {
override val apiClasses: Set[Class[_]] = Set( override val apiClasses: Set[Class[_]] = Set(
classOf[api.v1.BiSEndpoint], classOf[api.v1.LootEndpoint], classOf[api.v1.BiSEndpoint],
classOf[api.v1.PlayerEndpoint], classOf[api.v1.UserEndpoint] classOf[api.v1.LootEndpoint],
classOf[api.v1.PartyEndpoint],
classOf[api.v1.PlayerEndpoint],
classOf[api.v1.TypesEndpoint],
classOf[api.v1.UserEndpoint]
) )
override val info: Info = Info() override val info: Info = Info(
description = Source.fromResource("swagger-info/description.md").mkString,
version = getClass.getPackage.getImplementationVersion,
title = "FFXIV static loot tracker",
license = Some(License("BSD", "https://raw.githubusercontent.com/arcan1s/ffxivbis/master/LICENSE"))
)
override val host: String =
if (config.hasPath("me.arcanis.ffxivbis.web.hostname")) config.getString("me.arcanis.ffxivbis.web.hostname")
else s"${config.getString("me.arcanis.ffxivbis.web.host")}:${config.getString("me.arcanis.ffxivbis.web.port")}"
private val basicAuth = new SecurityScheme() private val basicAuth = new SecurityScheme()
.description("basic http auth") .description("basic http auth")
.`type`(SecurityScheme.Type.HTTP) .`type`(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.scheme("bearer") .scheme("bearer")
override def securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth) override val securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth)
override val unwantedDefinitions: Seq[String] = override val unwantedDefinitions: Seq[String] =
Seq("Function1", "Function1RequestContextFutureRouteResult") Seq("Function1", "Function1RequestContextFutureRouteResult")

View File

@ -8,33 +8,30 @@
*/ */
package me.arcanis.ffxivbis.http package me.arcanis.ffxivbis.http
import akka.actor.ActorRef import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.pattern.ask import akka.actor.typed.{ActorRef, Scheduler}
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{AddUser, DeleteUser, GetNewPartyId, GetUser, GetUsers, Message}
import me.arcanis.ffxivbis.models.User import me.arcanis.ffxivbis.models.User
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.Future
class UserHelper(storage: ActorRef) { trait UserHelper {
def addUser(user: User, isHashedPassword: Boolean) def storage: ActorRef[Message]
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseUserHandler.AddUser(user, isHashedPassword)).mapTo[Int]
def newPartyId(implicit executionContext: ExecutionContext, timeout: Timeout): Future[String] = def addUser(user: User, isHashedPassword: Boolean)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
(storage ? PartyService.GetNewPartyId).mapTo[String] storage.ask(AddUser(user, isHashedPassword, _))
def user(partyId: String, username: String) def newPartyId(implicit timeout: Timeout, scheduler: Scheduler): Future[String] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[User]] = storage.ask(GetNewPartyId)
(storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]]
def users(partyId: String) def user(partyId: String, username: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[Option[User]] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[User]] = storage.ask(GetUser(partyId, username, _))
(storage ? DatabaseUserHandler.GetUsers(partyId)).mapTo[Seq[User]]
def removeUser(partyId: String, username: String) def users(partyId: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[Seq[User]] =
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = storage.ask(GetUsers(partyId, _))
(storage ? DatabaseUserHandler.DeleteUser(partyId, username)).mapTo[Int]
def removeUser(partyId: String, username: String)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(DeleteUser(partyId, username, _))
} }

View File

@ -8,46 +8,71 @@
*/ */
package me.arcanis.ffxivbis.http.api.v1 package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.scalalogging.StrictLogging
import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces} import io.swagger.v3.oas.annotations.{Operation, Parameter}
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper} import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.PlayerId import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
@Path("api/v1") @Path("/api/v1")
class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) class BiSEndpoint(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
extends BiSHelper(storage, ariyala) with Authorization with JsonSupport { timeout: Timeout,
scheduler: Scheduler
) extends BiSHelper
with Authorization
with JsonSupport {
def route: Route = createBiS ~ getBiS ~ modifyBiS def route: Route = createBiS ~ getBiS ~ modifyBiS
@PUT @PUT
@Path("party/{partyId}/bis") @Path("party/{partyId}/bis")
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Operation(summary = "create best in slot", description = "Create the best in slot set", @Operation(
summary = "create best in slot",
description = "Create the best in slot set",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
), ),
requestBody = new RequestBody(description = "player best in slot description", required = true, requestBody = new RequestBody(
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkResponse])))), description = "player best in slot description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkResponse])))
),
responses = Array( responses = Array(
new ApiResponse(responseCode = "201", description = "Best in slot set has been created"), new ApiResponse(responseCode = "201", description = "Best in slot set has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), new ApiResponse(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), responseCode = "400",
new ApiResponse(responseCode = "403", description = "Access is forbidden"), description = "Invalid parameters were supplied",
new ApiResponse(responseCode = "500", description = "Internal server error"), content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"), tags = Array("best in slot"),
@ -72,20 +97,44 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti
@GET @GET
@Path("party/{partyId}/bis") @Path("party/{partyId}/bis")
@Produces(value = Array("application/json")) @Produces(value = Array("application/json"))
@Operation(summary = "get best in slot", description = "Return the best in slot items", @Operation(
summary = "get best in slot",
description = "Return the best in slot items",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"), new Parameter(
name = "nick",
in = ParameterIn.QUERY,
description = "player nick name to filter",
example = "Siuan Sanche"
),
new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"), new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
), ),
responses = Array( responses = Array(
new ApiResponse(responseCode = "200", description = "Best in slot", new ApiResponse(
content = Array(new Content( responseCode = "200",
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])) description = "Best in slot",
))), content = Array(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), new Content(
new ApiResponse(responseCode = "403", description = "Access is forbidden"), array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
new ApiResponse(responseCode = "500", description = "Internal server error"), )
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("best in slot"), tags = Array("best in slot"),
@ -111,18 +160,39 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti
@POST @POST
@Path("party/{partyId}/bis") @Path("party/{partyId}/bis")
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Operation(summary = "modify best in slot", description = "Add or remove an item from the best in slot", @Operation(
summary = "modify best in slot",
description = "Add or remove an item from the best in slot",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
), ),
requestBody = new RequestBody(description = "action and piece description", required = true, requestBody = new RequestBody(
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))), description = "action and piece description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))
),
responses = Array( responses = Array(
new ApiResponse(responseCode = "202", description = "Best in slot set has been modified"), new ApiResponse(responseCode = "202", description = "Best in slot set has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), new ApiResponse(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), responseCode = "400",
new ApiResponse(responseCode = "403", description = "Access is forbidden"), description = "Invalid parameters were supplied",
new ApiResponse(responseCode = "500", description = "Internal server error"), content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"), tags = Array("best in slot"),
@ -133,7 +203,7 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post { post {
entity(as[PieceActionResponse]) { action => entity(as[PieceActionResponse]) { action =>
val playerId = action.playerIdResponse.withPartyId(partyId) val playerId = action.playerId.withPartyId(partyId)
onComplete(doModifyBiS(action.action, playerId, action.piece.toPiece)) { onComplete(doModifyBiS(action.action, playerId, action.piece.toPiece)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception case Failure(exception) => throw exception

View File

@ -17,18 +17,21 @@ import spray.json._
trait HttpHandler extends StrictLogging { this: JsonSupport => trait HttpHandler extends StrictLogging { this: JsonSupport =>
implicit def exceptionHandler: ExceptionHandler = ExceptionHandler { def exceptionHandler: ExceptionHandler = ExceptionHandler {
case ex: IllegalArgumentException =>
complete(StatusCodes.BadRequest, ErrorResponse(ex.getMessage))
case other: Exception => case other: Exception =>
logger.error("exception during request completion", other) logger.error("exception during request completion", other)
complete(StatusCodes.InternalServerError, ErrorResponse("unknown server error")) complete(StatusCodes.InternalServerError, ErrorResponse("unknown server error"))
} }
implicit def rejectionHandler: RejectionHandler = def rejectionHandler: RejectionHandler =
RejectionHandler.default RejectionHandler.default
.mapRejectionResponse { .mapRejectionResponse {
case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) => case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) =>
val message = ErrorResponse(entity.data.utf8String).toJson val message = ErrorResponse(entity.data.utf8String).toJson
response.copy(entity = HttpEntity(ContentTypes.`application/json`, message.compactPrint)) response.withEntity(HttpEntity(ContentTypes.`application/json`, message.compactPrint))
case other => other case other => other
} }
} }

View File

@ -8,47 +8,75 @@
*/ */
package me.arcanis.ffxivbis.http.api.v1 package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces} import io.swagger.v3.oas.annotations.{Operation, Parameter}
import me.arcanis.ffxivbis.http.{Authorization, LootHelper} import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.PlayerId import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
@Path("api/v1") @Path("/api/v1")
class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) class LootEndpoint(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends LootHelper(storage) with Authorization with JsonSupport with HttpHandler { extends LootHelper
with Authorization
with JsonSupport
with HttpHandler {
def route: Route = getLoot ~ modifyLoot def route: Route = getLoot ~ modifyLoot
@GET @GET
@Path("party/{partyId}/loot") @Path("party/{partyId}/loot")
@Produces(value = Array("application/json")) @Produces(value = Array("application/json"))
@Operation(summary = "get loot list", description = "Return the looted items", @Operation(
summary = "get loot list",
description = "Return the looted items",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"), new Parameter(
name = "nick",
in = ParameterIn.QUERY,
description = "player nick name to filter",
example = "Siuan Sanche"
),
new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"), new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
), ),
responses = Array( responses = Array(
new ApiResponse(responseCode = "200", description = "Loot list", new ApiResponse(
content = Array(new Content( responseCode = "200",
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])) description = "Loot list",
))), content = Array(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), new Content(
new ApiResponse(responseCode = "403", description = "Access is forbidden"), array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
new ApiResponse(responseCode = "500", description = "Internal server error"), )
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"), tags = Array("loot"),
@ -73,18 +101,39 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
@POST @POST
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Path("party/{partyId}/loot") @Path("party/{partyId}/loot")
@Operation(summary = "modify loot list", description = "Add or remove an item from the loot list", @Operation(
summary = "modify loot list",
description = "Add or remove an item from the loot list",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
), ),
requestBody = new RequestBody(description = "action and piece description", required = true, requestBody = new RequestBody(
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))), description = "action and piece description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))
),
responses = Array( responses = Array(
new ApiResponse(responseCode = "202", description = "Loot list has been modified"), new ApiResponse(responseCode = "202", description = "Loot list has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), new ApiResponse(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), responseCode = "400",
new ApiResponse(responseCode = "403", description = "Access is forbidden"), description = "Invalid parameters were supplied",
new ApiResponse(responseCode = "500", description = "Internal server error"), content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("loot"), tags = Array("loot"),
@ -95,8 +144,8 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post { post {
entity(as[PieceActionResponse]) { action => entity(as[PieceActionResponse]) { action =>
val playerId = action.playerIdResponse.withPartyId(partyId) val playerId = action.playerId.withPartyId(partyId)
onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece)) { onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece, action.isFreeLoot)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception case Failure(exception) => throw exception
} }
@ -110,21 +159,47 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
@Path("party/{partyId}/loot") @Path("party/{partyId}/loot")
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Produces(value = Array("application/json")) @Produces(value = Array("application/json"))
@Operation(summary = "suggest loot", description = "Suggest loot piece to party", @Operation(
summary = "suggest loot",
description = "Suggest loot piece to party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
), ),
requestBody = new RequestBody(description = "piece description", required = true, requestBody = new RequestBody(
content = Array(new Content(schema = new Schema(implementation = classOf[PieceResponse])))), description = "piece description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceResponse])))
),
responses = Array( responses = Array(
new ApiResponse(responseCode = "200", description = "Players with counters ordered by priority to get this item", new ApiResponse(
content = Array(new Content( responseCode = "200",
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersResponse])), description = "Players with counters ordered by priority to get this item",
))), content = Array(
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), new Content(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersResponse])),
new ApiResponse(responseCode = "403", description = "Access is forbidden"), )
new ApiResponse(responseCode = "500", description = "Internal server error"), )
),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"), tags = Array("loot"),

View File

@ -0,0 +1,145 @@
/*
* Copyright (c) 2020 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.http.api.v1
import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{Content, Schema}
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import scala.util.{Failure, Success}
@Path("/api/v1")
class PartyEndpoint(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(
implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization
with JsonSupport
with HttpHandler {
def route: Route = getPartyDescription ~ modifyPartyDescription
@GET
@Path("party/{partyId}/description")
@Produces(value = Array("application/json"))
@Operation(
summary = "get party description",
description = "Return the party description",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
responses = Array(
new ApiResponse(
responseCode = "200",
description = "Party description",
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("party"),
)
def getPartyDescription: Route =
path("party" / Segment / "description") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
onComplete(getPartyDescription(partyId)) {
case Success(response) => complete(PartyDescriptionResponse.fromDescription(response))
case Failure(exception) => throw exception
}
}
}
}
}
@POST
@Consumes(value = Array("application/json"))
@Path("party/{partyId}/description")
@Operation(
summary = "modify party description",
description = "Edit party description",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(
description = "new party description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PartyDescriptionResponse])))
),
responses = Array(
new ApiResponse(responseCode = "202", description = "Party description has been modified"),
new ApiResponse(
responseCode = "400",
description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("party"),
)
def modifyPartyDescription: Route =
path("party" / Segment / "description") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PartyDescriptionResponse]) { partyDescription =>
val description = partyDescription.copy(partyId = partyId)
onComplete(updateDescription(description.toDescription)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}
}

View File

@ -8,47 +8,78 @@
*/ */
package me.arcanis.ffxivbis.http.api.v1 package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, Path, Produces} import io.swagger.v3.oas.annotations.{Operation, Parameter}
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper} import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.PlayerId import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
@Path("api/v1") @Path("/api/v1")
class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) class PlayerEndpoint(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(
extends PlayerHelper(storage, ariyala) with Authorization with JsonSupport with HttpHandler { implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization
with JsonSupport
with HttpHandler {
def route: Route = getParty ~ modifyParty def route: Route = getParty ~ modifyParty
@GET @GET
@Path("party/{partyId}") @Path("party/{partyId}")
@Produces(value = Array("application/json")) @Produces(value = Array("application/json"))
@Operation(summary = "get party", description = "Return the players who belong to the party", @Operation(
summary = "get party",
description = "Return the players who belong to the party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"), new Parameter(
name = "nick",
in = ParameterIn.QUERY,
description = "player nick name to filter",
example = "Siuan Sanche"
),
new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"), new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
), ),
responses = Array( responses = Array(
new ApiResponse(responseCode = "200", description = "Players list", new ApiResponse(
content = Array(new Content( responseCode = "200",
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])), description = "Players list",
))), content = Array(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), new Content(
new ApiResponse(responseCode = "403", description = "Access is forbidden"), array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])),
new ApiResponse(responseCode = "500", description = "Internal server error"), )
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("party"), tags = Array("party"),
@ -73,18 +104,39 @@ class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit
@POST @POST
@Path("party/{partyId}") @Path("party/{partyId}")
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Operation(summary = "modify party", description = "Add or remove a player from party list", @Operation(
summary = "modify party",
description = "Add or remove a player from party list",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
), ),
requestBody = new RequestBody(description = "player description", required = true, requestBody = new RequestBody(
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionResponse])))), description = "player description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionResponse])))
),
responses = Array( responses = Array(
new ApiResponse(responseCode = "202", description = "Party has been modified"), new ApiResponse(responseCode = "202", description = "Party has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), new ApiResponse(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), responseCode = "400",
new ApiResponse(responseCode = "403", description = "Access is forbidden"), description = "Invalid parameters were supplied",
new ApiResponse(responseCode = "500", description = "Internal server error"), content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("party"), tags = Array("party"),
@ -94,7 +146,7 @@ class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
entity(as[PlayerActionResponse]) { action => entity(as[PlayerActionResponse]) { action =>
val player = action.playerIdResponse.toPlayer.copy(partyId = partyId) val player = action.playerId.toPlayer.copy(partyId = partyId)
onComplete(doModifyPlayer(action.action, player)) { onComplete(doModifyPlayer(action.action, player)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty) case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception case Failure(exception) => throw exception

View File

@ -8,25 +8,32 @@
*/ */
package me.arcanis.ffxivbis.http.api.v1 package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.Route
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.config.Config
import me.arcanis.ffxivbis.http.api.v1.json.JsonSupport import me.arcanis.ffxivbis.http.api.v1.json.JsonSupport
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
class RootApiV1Endpoint(storage: ActorRef, ariyala: ActorRef) class RootApiV1Endpoint(storage: ActorRef[Message], provider: ActorRef[BiSProviderMessage], config: Config)(implicit
(implicit timeout: Timeout) timeout: Timeout,
extends JsonSupport with HttpHandler { scheduler: Scheduler
) extends JsonSupport
with HttpHandler {
private val biSEndpoint = new BiSEndpoint(storage, ariyala) private val biSEndpoint = new BiSEndpoint(storage, provider)
private val lootEndpoint = new LootEndpoint(storage) private val lootEndpoint = new LootEndpoint(storage)
private val playerEndpoint = new PlayerEndpoint(storage, ariyala) private val partyEndpoint = new PartyEndpoint(storage, provider)
private val playerEndpoint = new PlayerEndpoint(storage, provider)
private val typesEndpoint = new TypesEndpoint(config)
private val userEndpoint = new UserEndpoint(storage) private val userEndpoint = new UserEndpoint(storage)
def route: Route = def route: Route =
handleExceptions(exceptionHandler) { handleExceptions(exceptionHandler) {
handleRejections(rejectionHandler) { handleRejections(rejectionHandler) {
biSEndpoint.route ~ lootEndpoint.route ~ playerEndpoint.route ~ userEndpoint.route biSEndpoint.route ~ lootEndpoint.route ~ partyEndpoint.route ~
playerEndpoint.route ~ typesEndpoint.route ~ userEndpoint.route
} }
} }
} }

View File

@ -0,0 +1,180 @@
/*
* 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.http.api.v1
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import com.typesafe.config.Config
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.Operation
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.{Job, Party, Permission, Piece, PieceType}
@Path("/api/v1")
class TypesEndpoint(config: Config) extends JsonSupport {
def route: Route = getJobs ~ getPermissions ~ getPieces ~ getPieceTypes ~ getPriority
@GET
@Path("types/jobs")
@Produces(value = Array("application/json"))
@Operation(
summary = "jobs list",
description = "Returns the available jobs",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "List of available jobs",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[String]))
)
)
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
),
tags = Array("types"),
)
def getJobs: Route =
path("types" / "jobs") {
get {
complete(Job.availableWithAnyJob.map(_.toString))
}
}
@GET
@Path("types/permissions")
@Produces(value = Array("application/json"))
@Operation(
summary = "permissions list",
description = "Returns the available permissions",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "List of available permissions",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[String]))
)
)
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
),
tags = Array("types"),
)
def getPermissions: Route =
path("types" / "permissions") {
get {
complete(Permission.values.toSeq.sorted.map(_.toString))
}
}
@GET
@Path("types/pieces")
@Produces(value = Array("application/json"))
@Operation(
summary = "pieces list",
description = "Returns the available pieces",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "List of available pieces",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[String]))
)
)
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
),
tags = Array("types"),
)
def getPieces: Route =
path("types" / "pieces") {
get {
complete(Piece.available)
}
}
@GET
@Path("types/pieces/types")
@Produces(value = Array("application/json"))
@Operation(
summary = "piece types list",
description = "Returns the available piece types",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "List of available piece types",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[String]))
)
)
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
),
tags = Array("types"),
)
def getPieceTypes: Route =
path("types" / "pieces" / "types") {
get {
complete(PieceType.available.map(_.toString))
}
}
@GET
@Path("types/priority")
@Produces(value = Array("application/json"))
@Operation(
summary = "priority list",
description = "Returns the current priority list",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "Priority order",
content = Array(
new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[String]))
)
)
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
),
tags = Array("types"),
)
def getPriority: Route =
path("types" / "priority") {
get {
complete(Party.getRules(config))
}
}
}

View File

@ -8,41 +8,61 @@
*/ */
package me.arcanis.ffxivbis.http.api.v1 package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, DELETE, GET, POST, PUT, Path, Produces} import io.swagger.v3.oas.annotations.{Operation, Parameter}
import me.arcanis.ffxivbis.http.{Authorization, UserHelper} import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.Permission import me.arcanis.ffxivbis.models.Permission
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
@Path("api/v1") @Path("/api/v1")
class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) class UserEndpoint(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends UserHelper(storage) with Authorization with JsonSupport { extends UserHelper
with Authorization
with JsonSupport {
def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers
@PUT @PUT
@Path("party") @Path("party")
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Operation(summary = "create new party", description = "Create new party with specified ID", @Operation(
requestBody = new RequestBody(description = "party administrator description", required = true, summary = "create new party",
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))), description = "Create new party with specified ID",
requestBody = new RequestBody(
description = "party administrator description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))
),
responses = Array( responses = Array(
new ApiResponse(responseCode = "200", 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(
new ApiResponse(responseCode = "406", description = "Party with the specified ID already exists"), responseCode = "400",
new ApiResponse(responseCode = "500", description = "Internal server error"), description = "Invalid parameters were supplied",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "406",
description = "Party with the specified ID already exists",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
tags = Array("party"), tags = Array("party"),
) )
@ -68,18 +88,39 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
@POST @POST
@Path("party/{partyId}/users") @Path("party/{partyId}/users")
@Consumes(value = Array("application/json")) @Consumes(value = Array("application/json"))
@Operation(summary = "create new user", description = "Add an user to the specified party", @Operation(
summary = "create new user",
description = "Add an user to the specified party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
), ),
requestBody = new RequestBody(description = "user description", required = true, requestBody = new RequestBody(
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))), description = "user description",
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))
),
responses = Array( responses = Array(
new ApiResponse(responseCode = "201", description = "User has been created"), new ApiResponse(responseCode = "201", description = "User has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), new ApiResponse(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), responseCode = "400",
new ApiResponse(responseCode = "403", description = "Access is forbidden"), description = "Invalid parameters were supplied",
new ApiResponse(responseCode = "500", description = "Internal server error"), content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"), tags = Array("users"),
@ -103,16 +144,30 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
@DELETE @DELETE
@Path("party/{partyId}/users/{username}") @Path("party/{partyId}/users/{username}")
@Operation(summary = "remove user", description = "Remove an user from the specified party", @Operation(
summary = "remove user",
description = "Remove an user from the specified party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "username", in = ParameterIn.PATH, description = "username to remove", example = "siuan"), new Parameter(name = "username", in = ParameterIn.PATH, description = "username to remove", example = "siuan"),
), ),
responses = Array( responses = Array(
new ApiResponse(responseCode = "202", description = "User has been removed"), new ApiResponse(responseCode = "202", description = "User has been removed"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), new ApiResponse(
new ApiResponse(responseCode = "403", description = "Access is forbidden"), responseCode = "401",
new ApiResponse(responseCode = "500", description = "Internal server error"), description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"), tags = Array("users"),
@ -134,18 +189,37 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
@GET @GET
@Path("party/{partyId}/users") @Path("party/{partyId}/users")
@Produces(value = Array("application/json")) @Produces(value = Array("application/json"))
@Operation(summary = "get users", description = "Return the list of users belong to party", @Operation(
summary = "get users",
description = "Return the list of users belong to party",
parameters = Array( parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
), ),
responses = Array( responses = Array(
new ApiResponse(responseCode = "200", description = "Users list", new ApiResponse(
content = Array(new Content( responseCode = "200",
array = new ArraySchema(schema = new Schema(implementation = classOf[UserResponse])), description = "Users list",
))), content = Array(
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), new Content(
new ApiResponse(responseCode = "403", description = "Access is forbidden"), array = new ArraySchema(schema = new Schema(implementation = classOf[UserResponse])),
new ApiResponse(responseCode = "500", description = "Internal server error"), )
)
),
new ApiResponse(
responseCode = "401",
description = "Supplied authorization is invalid",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "403",
description = "Access is forbidden",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorResponse])))
),
), ),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"), tags = Array("users"),

View File

@ -10,5 +10,4 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
case class ErrorResponse( case class ErrorResponse(@Schema(description = "error message", required = true) message: String)
@Schema(description = "error message", required = true) message: String)

View File

@ -8,6 +8,8 @@
*/ */
package me.arcanis.ffxivbis.http.api.v1.json package me.arcanis.ffxivbis.http.api.v1.json
import java.time.Instant
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import me.arcanis.ffxivbis.models.Permission import me.arcanis.ffxivbis.models.Permission
import spray.json._ import spray.json._
@ -24,16 +26,29 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
} }
} }
implicit val instantFormat: RootJsonFormat[Instant] = new RootJsonFormat[Instant] {
override def write(obj: Instant): JsValue = obj.toString.toJson
override def read(json: JsValue): Instant = json match {
case JsNumber(value) => Instant.ofEpochMilli(value.toLongExact)
case JsString(value) => Instant.parse(value)
case other => deserializationError(s"String or number expected, got $other")
}
}
implicit val actionFormat: RootJsonFormat[ApiAction.Value] = enumFormat(ApiAction) implicit val actionFormat: RootJsonFormat[ApiAction.Value] = enumFormat(ApiAction)
implicit val permissionFormat: RootJsonFormat[Permission.Value] = enumFormat(Permission) implicit val permissionFormat: RootJsonFormat[Permission.Value] = enumFormat(Permission)
implicit val errorFormat: RootJsonFormat[ErrorResponse] = jsonFormat1(ErrorResponse.apply) implicit val errorFormat: RootJsonFormat[ErrorResponse] = jsonFormat1(ErrorResponse.apply)
implicit val partyIdFormat: RootJsonFormat[PartyIdResponse] = jsonFormat1(PartyIdResponse.apply) implicit val partyIdFormat: RootJsonFormat[PartyIdResponse] = jsonFormat1(PartyIdResponse.apply)
implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply) implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply)
implicit val lootFormat: RootJsonFormat[LootResponse] = jsonFormat3(LootResponse.apply)
implicit val partyDescriptionFormat: RootJsonFormat[PartyDescriptionResponse] = jsonFormat2(
PartyDescriptionResponse.apply
)
implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply) implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply)
implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply) implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply)
implicit val playerIdFormat: RootJsonFormat[PlayerIdResponse] = jsonFormat3(PlayerIdResponse.apply) implicit val playerIdFormat: RootJsonFormat[PlayerIdResponse] = jsonFormat3(PlayerIdResponse.apply)
implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat3(PieceActionResponse.apply) implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat4(PieceActionResponse.apply)
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkResponse] = jsonFormat2(PlayerBiSLinkResponse.apply) implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkResponse] = jsonFormat2(PlayerBiSLinkResponse.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersResponse] = implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersResponse] =
jsonFormat9(PlayerIdWithCountersResponse.apply) jsonFormat9(PlayerIdWithCountersResponse.apply)

View File

@ -0,0 +1,21 @@
package me.arcanis.ffxivbis.http.api.v1.json
import java.time.Instant
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.Loot
case class LootResponse(
@Schema(description = "looted piece", required = true) piece: PieceResponse,
@Schema(description = "loot timestamp", required = true) timestamp: Instant,
@Schema(description = "is loot free for all", required = true) isFreeLoot: Boolean
) {
def toLoot: Loot = Loot(-1, piece.toPiece, timestamp, isFreeLoot)
}
object LootResponse {
def fromLoot(loot: Loot): LootResponse =
LootResponse(PieceResponse.fromPiece(loot.piece), loot.timestamp, loot.isFreeLoot)
}

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2020 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.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PartyDescription
case class PartyDescriptionResponse(
@Schema(description = "party id", required = true) partyId: String,
@Schema(description = "party name") partyAlias: Option[String]
) {
def toDescription: PartyDescription = PartyDescription(partyId, partyAlias)
}
object PartyDescriptionResponse {
def fromDescription(description: PartyDescription): PartyDescriptionResponse =
PartyDescriptionResponse(description.partyId, description.partyAlias)
}

View File

@ -10,5 +10,4 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
case class PartyIdResponse( case class PartyIdResponse(@Schema(description = "party id", required = true) partyId: String)
@Schema(description = "party id", required = true) partyId: String)

View File

@ -11,6 +11,13 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
case class PieceActionResponse( case class PieceActionResponse(
@Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove")) action: ApiAction.Value, @Schema(
description = "action to perform",
required = true,
`type` = "string",
allowableValues = Array("add", "remove")
) action: ApiAction.Value,
@Schema(description = "piece description", required = true) piece: PieceResponse, @Schema(description = "piece description", required = true) piece: PieceResponse,
@Schema(description = "player description", required = true) playerIdResponse: PlayerIdResponse) @Schema(description = "player description", required = true) playerId: PlayerIdResponse,
@Schema(description = "is piece free to roll or not") isFreeLoot: Option[Boolean]
)

View File

@ -9,16 +9,19 @@
package me.arcanis.ffxivbis.http.api.v1.json package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, Piece} import me.arcanis.ffxivbis.models.{Job, Piece, PieceType}
case class PieceResponse( case class PieceResponse(
@Schema(description = "is piece tome gear", required = true) isTome: Boolean, @Schema(description = "piece type", required = true) pieceType: String,
@Schema(description = "job name to which piece belong or AnyJob", required = true, example = "DNC") job: String, @Schema(description = "job name to which piece belong or AnyJob", required = true, example = "DNC") job: String,
@Schema(description = "piece name", required = true, example = "body") piece: String) { @Schema(description = "piece name", required = true, example = "body") piece: String
def toPiece: Piece = Piece(piece, isTome, Job.withName(job)) ) {
def toPiece: Piece = Piece(piece, PieceType.withName(pieceType), Job.withName(job))
} }
object PieceResponse { object PieceResponse {
def fromPiece(piece: Piece): PieceResponse = def fromPiece(piece: Piece): PieceResponse =
PieceResponse(piece.isTome, piece.job.toString, piece.piece) PieceResponse(piece.pieceType.toString, piece.job.toString, piece.piece)
} }

View File

@ -11,5 +11,12 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
case class PlayerActionResponse( case class PlayerActionResponse(
@Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove"), example = "add") action: ApiAction.Value, @Schema(
@Schema(description = "player description", required = true) playerIdResponse: PlayerResponse) description = "action to perform",
required = true,
`type` = "string",
allowableValues = Array("add", "remove"),
example = "add"
) action: ApiAction.Value,
@Schema(description = "player description", required = true) playerId: PlayerResponse
)

View File

@ -11,5 +11,10 @@ package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
case class PlayerBiSLinkResponse( case class PlayerBiSLinkResponse(
@Schema(description = "link to player best in slot", required = true, example = "https://ffxiv.ariyala.com/19V5R") link: String, @Schema(
@Schema(description = "player description", required = true) playerId: PlayerIdResponse) description = "link to player best in slot",
required = true,
example = "https://ffxiv.ariyala.com/19V5R"
) link: String,
@Schema(description = "player description", required = true) playerId: PlayerIdResponse
)

View File

@ -14,12 +14,15 @@ import me.arcanis.ffxivbis.models.{Job, PlayerId}
case class PlayerIdResponse( case class PlayerIdResponse(
@Schema(description = "unique party ID. Required in responses", example = "abcdefgh") partyId: Option[String], @Schema(description = "unique party ID. Required in responses", example = "abcdefgh") partyId: Option[String],
@Schema(description = "job name", required = true, example = "DNC") job: String, @Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String) { @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String
) {
def withPartyId(partyId: String): PlayerId = def withPartyId(partyId: String): PlayerId =
PlayerId(partyId, Job.withName(job), nick) PlayerId(partyId, Job.withName(job), nick)
} }
object PlayerIdResponse { object PlayerIdResponse {
def fromPlayerId(playerId: PlayerId): PlayerIdResponse = def fromPlayerId(playerId: PlayerId): PlayerIdResponse =
PlayerIdResponse(Some(playerId.partyId), playerId.job.toString, playerId.nick) PlayerIdResponse(Some(playerId.partyId), playerId.job.toString, playerId.nick)
} }

View File

@ -20,9 +20,11 @@ case class PlayerIdWithCountersResponse(
@Schema(description = "count of savage pieces in best in slot", required = true) bisCountTotal: Int, @Schema(description = "count of savage pieces in best in slot", required = true) bisCountTotal: Int,
@Schema(description = "count of looted pieces", required = true) lootCount: Int, @Schema(description = "count of looted pieces", required = true) lootCount: Int,
@Schema(description = "count of looted pieces which are parts of best in slot", required = true) lootCountBiS: Int, @Schema(description = "count of looted pieces which are parts of best in slot", required = true) lootCountBiS: Int,
@Schema(description = "total count of looted pieces", required = true) lootCountTotal: Int) @Schema(description = "total count of looted pieces", required = true) lootCountTotal: Int
)
object PlayerIdWithCountersResponse { object PlayerIdWithCountersResponse {
def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersResponse = def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersResponse =
PlayerIdWithCountersResponse( PlayerIdWithCountersResponse(
playerIdWithCounters.partyId, playerIdWithCounters.partyId,
@ -33,5 +35,6 @@ object PlayerIdWithCountersResponse {
playerIdWithCounters.bisCountTotal, playerIdWithCounters.bisCountTotal,
playerIdWithCounters.lootCount, playerIdWithCounters.lootCount,
playerIdWithCounters.lootCountBiS, playerIdWithCounters.lootCountBiS,
playerIdWithCounters.lootCountTotal) playerIdWithCounters.lootCountTotal
)
} }

View File

@ -16,18 +16,34 @@ case class PlayerResponse(
@Schema(description = "job name", required = true, example = "DNC") job: String, @Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String, @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String,
@Schema(description = "pieces in best in slot") bis: Option[Seq[PieceResponse]], @Schema(description = "pieces in best in slot") bis: Option[Seq[PieceResponse]],
@Schema(description = "looted pieces") loot: Option[Seq[PieceResponse]], @Schema(description = "looted pieces") loot: Option[Seq[LootResponse]],
@Schema(description = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String], @Schema(description = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String],
@Schema(description = "player loot priority") priority: Option[Int]) { @Schema(description = "player loot priority", `type` = "number") priority: Option[Int]
) {
def toPlayer: Player = def toPlayer: Player =
Player(partyId, Job.withName(job), nick, Player(
BiS(bis.getOrElse(Seq.empty).map(_.toPiece)), loot.getOrElse(Seq.empty).map(_.toPiece), -1,
link, priority.getOrElse(0)) partyId,
Job.withName(job),
nick,
BiS(bis.getOrElse(Seq.empty).map(_.toPiece)),
loot.getOrElse(Seq.empty).map(_.toLoot),
link,
priority.getOrElse(0)
)
} }
object PlayerResponse { object PlayerResponse {
def fromPlayer(player: Player): PlayerResponse = def fromPlayer(player: Player): PlayerResponse =
PlayerResponse(player.partyId, player.job.toString, player.nick, PlayerResponse(
Some(player.bis.pieces.map(PieceResponse.fromPiece)), Some(player.loot.map(PieceResponse.fromPiece)), player.partyId,
player.link, Some(player.priority)) player.job.toString,
player.nick,
Some(player.bis.pieces.map(PieceResponse.fromPiece)),
Some(player.loot.map(LootResponse.fromLoot)),
player.link,
Some(player.priority)
)
} }

View File

@ -15,12 +15,19 @@ case class UserResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String, @Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String,
@Schema(description = "username to login to party", required = true, example = "siuan") username: String, @Schema(description = "username to login to party", required = true, example = "siuan") username: String,
@Schema(description = "password to login to party", required = true, example = "pa55w0rd") password: String, @Schema(description = "password to login to party", required = true, example = "pa55w0rd") password: String,
@Schema(description = "user permission", defaultValue = "get", allowableValues = Array("get", "post", "admin")) permission: Option[Permission.Value] = None) { @Schema(
description = "user permission",
defaultValue = "get",
allowableValues = Array("get", "post", "admin")
) permission: Option[Permission.Value] = None
) {
def toUser: User = def toUser: User =
User(partyId, username, password, permission.getOrElse(Permission.get)) User(partyId, username, password, permission.getOrElse(Permission.get))
} }
object UserResponse { object UserResponse {
def fromUser(user: User): UserResponse = def fromUser(user: User): UserResponse =
UserResponse(user.partyId, user.username, "", Some(user.permission)) UserResponse(user.partyId, user.username, "", Some(user.permission))
} }

View File

@ -8,15 +8,22 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.Authorization import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
class BasePartyView(override val storage: ActorRef)(implicit timeout: Timeout) import scala.util.{Failure, Success}
extends Authorization {
class BasePartyView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(
implicit
timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization {
def route: Route = getIndex def route: Route = getIndex
@ -25,8 +32,10 @@ class BasePartyView(override val storage: ActorRef)(implicit timeout: Timeout)
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get { get {
complete { onComplete(getPartyDescription(partyId)) {
(StatusCodes.OK, RootView.toHtml(BasePartyView.template(partyId))) case Success(description) =>
complete(StatusCodes.OK, RootView.toHtml(BasePartyView.template(partyId, description.alias)))
case Failure(exception) => throw exception
} }
} }
} }
@ -37,27 +46,28 @@ class BasePartyView(override val storage: ActorRef)(implicit timeout: Timeout)
object BasePartyView { object BasePartyView {
import scalatags.Text import scalatags.Text
import scalatags.Text.all._ import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def root(partyId: String): Text.TypedTag[String] = def root(partyId: String): Text.TypedTag[String] =
a(href:=s"/party/$partyId", title:="root")("root") a(href := s"/party/$partyId", title := "root")("root")
def template(partyId: String): String = def template(partyId: String, alias: String): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" + "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en", html(
lang := "en",
head( head(
title:=s"Party $partyId", titleTag(s"Party $alias"),
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
), ),
body( body(
h2(s"Party $partyId"), h2(s"Party $alias"),
br, br,
h2(a(href:=s"/party/$partyId/players", title:="party")("party")), h2(a(href := s"/party/$partyId/players", title := "party")("party")),
h2(a(href:=s"/party/$partyId/bis", title:="bis management")("best in slot")), h2(a(href := s"/party/$partyId/bis", title := "bis management")("best in slot")),
h2(a(href:=s"/party/$partyId/loot", title:="loot management")("loot")), h2(a(href := s"/party/$partyId/loot", title := "loot management")("loot")),
h2(a(href:=s"/party/$partyId/suggest", title:="suggest loot")("suggest")), h2(a(href := s"/party/$partyId/suggest", title := "suggest loot")("suggest")),
hr, hr,
h2(a(href:=s"/party/$partyId/users", title:="user management")("users")) h2(a(href := s"/party/$partyId/users", title := "user management")("users"))
) )
) )
} }

View File

@ -8,19 +8,23 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper} import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId} import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.{Piece, PieceType, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try import scala.util.Try
class BiSView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) class BiSView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
extends BiSHelper(storage, ariyala) with Authorization { timeout: Timeout,
scheduler: Scheduler
) extends BiSHelper
with Authorization {
def route: Route = getBiS ~ modifyBiS def route: Route = getBiS ~ modifyBiS
@ -30,11 +34,13 @@ class BiSView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeou
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get { get {
complete { complete {
bis(partyId, None).map { players => bis(partyId, None)
BiSView.template(partyId, players, None) .map { players =>
}.map { text => BiSView.template(partyId, players, None)
(StatusCodes.OK, RootView.toHtml(text)) }
} .map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
} }
} }
} }
@ -46,39 +52,49 @@ class BiSView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeou
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post { post {
formFields("player".as[String], "piece".as[String].?, "is_tome".as[String].?, "link".as[String].?, "action".as[String]) { formFields(
(player, maybePiece, maybeIsTome, maybeLink, action) => "player".as[String],
onComplete(modifyBiSCall(partyId, player, maybePiece, maybeIsTome, maybeLink, action)) { "piece".as[String].?,
case _ => redirect(s"/party/$partyId/bis", StatusCodes.Found) "piece_type".as[String].?,
} "link".as[String].?,
"action".as[String]
) { (player, maybePiece, maybePieceType, maybeLink, action) =>
onComplete(modifyBiSCall(partyId, player, maybePiece, maybePieceType, maybeLink, action)) { _ =>
redirect(s"/party/$partyId/bis", StatusCodes.Found)
}
} }
} }
} }
} }
} }
private def modifyBiSCall(partyId: String, player: String, private def modifyBiSCall(
maybePiece: Option[String], maybeIsTome: Option[String], partyId: String,
maybeLink: Option[String], action: String) player: String,
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = { maybePiece: Option[String],
import me.arcanis.ffxivbis.utils.Implicits._ maybePieceType: Option[String],
maybeLink: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
def getPiece(playerId: PlayerId, piece: String, pieceType: String) =
Try(Piece(piece, PieceType.withName(pieceType), playerId.job)).toOption
def getPiece(playerId: PlayerId, piece: String) = def bisAction(playerId: PlayerId, piece: String, pieceType: String)(fn: Piece => Future[Unit]) =
Try(Piece(piece, maybeIsTome, playerId.job)).toOption getPiece(playerId, piece, pieceType) match {
case Some(item) => fn(item)
case _ => Future.failed(new Error(s"Could not construct piece from `$piece ($pieceType)`"))
}
PlayerId(partyId, player) match { PlayerId(partyId, player) match {
case Some(playerId) => (maybePiece, action, maybeLink) match { case Some(playerId) =>
case (Some(piece), "add", _) => getPiece(playerId, piece) match { (maybePiece, maybePieceType, action, maybeLink.map(_.trim).filter(_.nonEmpty)) match {
case Some(item) => addPieceBiS(playerId, item).map(_ => ()) case (Some(piece), Some(pieceType), "add", _) =>
case _ => Future.failed(new Error(s"Could not construct piece from `$piece`")) bisAction(playerId, piece, pieceType)(addPieceBiS(playerId, _))
case (Some(piece), Some(pieceType), "remove", _) =>
bisAction(playerId, piece, pieceType)(removePieceBiS(playerId, _))
case (_, _, "create", Some(link)) => putBiS(playerId, link)
case _ => Future.failed(new Error(s"Could not perform $action"))
} }
case (Some(piece), "remove", _) => getPiece(playerId, piece) match {
case Some(item) => removePieceBiS(playerId, item).map(_ => ())
case _ => Future.failed(new Error(s"Could not construct piece from `$piece`"))
}
case (_, "create", Some(link)) => putBiS(playerId, link).map(_ => ())
case _ => Future.failed(new Error(s"Could not perform $action"))
}
case _ => Future.failed(new Error(s"Could not construct player id from `$player`")) case _ => Future.failed(new Error(s"Could not construct player id from `$player`"))
} }
} }
@ -86,66 +102,72 @@ class BiSView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeou
object BiSView { object BiSView {
import scalatags.Text.all._ import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[Player], error: Option[String]): String = def template(partyId: String, party: Seq[Player], error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" + "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en", html(
lang := "en",
head( head(
title:="Best in slot", titleTag("Best in slot"),
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
), ),
body( body(
h2("Best in slot"), h2("Best in slot"),
ErrorView.template(error), ErrorView.template(error),
SearchLineView.template, SearchLineView.template,
form(action := s"/party/$partyId/bis", method := "post")(
form(action:=s"/party/$partyId/bis", method:="post")( select(name := "player", id := "player", title := "player")(
select(name:="player", id:="player", title:="player") for (player <- party) yield option(player.playerId.toString)
(for (player <- party) yield option(player.playerId.toString)), ),
select(name:="piece", id:="piece", title:="piece") select(name := "piece", id := "piece", title := "piece")(
(for (piece <- Piece.available) yield option(piece)), for (piece <- Piece.available) yield option(piece)
input(name:="is_tome", id:="is_tome", title:="is tome", `type`:="checkbox"), ),
label(`for`:="is_tome")("is tome gear"), select(name := "piece_type", id := "piece_type", title := "piece type")(
input(name:="action", id:="action", `type`:="hidden", value:="add"), for (pieceType <- PieceType.available) yield option(pieceType.toString)
input(name:="add", id:="add", `type`:="submit", value:="add") ),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
), ),
form(action := s"/party/$partyId/bis", method := "post")(
form(action:="/bis", method:="post")( select(name := "player", id := "player", title := "player")(
select(name:="player", id:="player", title:="player") for (player <- party) yield option(player.playerId.toString)
(for (player <- party) yield option(player.playerId.toString)), ),
input(name:="link", id:="link", placeholder:="player bis link", title:="link", `type`:="text"), input(name := "link", id := "link", placeholder := "player bis link", title := "link", `type` := "text"),
input(name:="action", id:="action", `type`:="hidden", value:="create"), input(name := "action", id := "action", `type` := "hidden", value := "create"),
input(name:="add", id:="add", `type`:="submit", value:="add") input(name := "add", id := "add", `type` := "submit", value := "add")
), ),
table(id := "result")(
table(id:="result")(
tr( tr(
th("player"), th("player"),
th("piece"), th("piece"),
th("is tome"), th("piece type"),
th("") th("")
), ),
for (player <- party; piece <- player.bis.pieces) yield tr( for (player <- party; piece <- player.bis.pieces)
td(`class`:="include_search")(player.playerId.toString), yield tr(
td(`class`:="include_search")(piece.piece), td(`class` := "include_search")(player.playerId.toString),
td(piece.isTomeToString), td(`class` := "include_search")(piece.piece),
td( td(piece.pieceType.toString),
form(action:=s"/party/$partyId/bis", method:="post")( td(
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString), form(action := s"/party/$partyId/bis", method := "post")(
input(name:="piece", id:="piece", `type`:="hidden", value:=piece.piece), input(name := "player", id := "player", `type` := "hidden", value := player.playerId.toString),
input(name:="is_tome", id:="is_tome", `type`:="hidden", value:=piece.isTomeToString), input(name := "piece", id := "piece", `type` := "hidden", value := piece.piece),
input(name:="action", id:="action", `type`:="hidden", value:="remove"), input(
input(name:="remove", id:="remove", `type`:="submit", value:="x") name := "piece_type",
id := "piece_type",
`type` := "hidden",
value := piece.pieceType.toString
),
input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
) )
) )
)
), ),
ExportToCSVView.template, ExportToCSVView.template,
BasePartyView.root(partyId), BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript") script(src := "/static/table_search.js", `type` := "text/javascript")
) )
) )
} }

View File

@ -12,8 +12,9 @@ import scalatags.Text
import scalatags.Text.all._ import scalatags.Text.all._
object ErrorView { object ErrorView {
def template(error: Option[String]): Text.TypedTag[String] = error match { def template(error: Option[String]): Text.TypedTag[String] = error match {
case Some(text) => p(id:="error", s"Error occurs: $text") case Some(text) => p(id := "error", s"Error occurs: $text")
case None => p("") case None => p("")
} }
} }

View File

@ -12,9 +12,10 @@ import scalatags.Text
import scalatags.Text.all._ import scalatags.Text.all._
object ExportToCSVView { object ExportToCSVView {
def template: Text.TypedTag[String] = def template: Text.TypedTag[String] =
div( div(
button(onclick:="exportTableToCsv('result.csv')")("Export to CSV"), button(onclick := "exportTableToCsv('result.csv')")("Export to CSV"),
script(src:="/static/table_export.js", `type`:="text/javascript") script(src := "/static/table_export.js", `type` := "text/javascript")
) )
} }

View File

@ -8,18 +8,23 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._ import akka.http.scaladsl.server._
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.UserHelper import me.arcanis.ffxivbis.http.{PlayerHelper, UserHelper}
import me.arcanis.ffxivbis.models.{Party, Permission, User} import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models.{PartyDescription, Permission, User}
import scala.concurrent.Future
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
class IndexView(storage: ActorRef)(implicit timeout: Timeout) class IndexView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
extends UserHelper(storage) { timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with UserHelper {
def route: Route = createParty ~ getIndex def route: Route = createParty ~ getIndex
@ -27,15 +32,20 @@ class IndexView(storage: ActorRef)(implicit timeout: Timeout)
path("party") { path("party") {
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
post { post {
formFields("username".as[String], "password".as[String]) { (username, password) => formFields("username".as[String], "password".as[String], "alias".as[String].?) {
onComplete(newPartyId) { (username, password, maybeAlias) =>
case Success(partyId) => onComplete {
val user = User(partyId, username, password, Permission.admin) newPartyId.flatMap { partyId =>
onComplete(addUser(user, isHashedPassword = false)) { val user = User(partyId, username, password, Permission.admin)
case _ => redirect(s"/party/$partyId", StatusCodes.Found) addUser(user, isHashedPassword = false).flatMap { _ =>
if (maybeAlias.getOrElse("").isEmpty) Future.successful(partyId)
else updateDescription(PartyDescription(partyId, maybeAlias)).map(_ => partyId)
}
} }
case Failure(exception) => throw exception } {
} case Success(partyId) => redirect(s"/party/$partyId", StatusCodes.Found)
case Failure(exception) => throw exception
}
} }
} }
} }
@ -54,31 +64,41 @@ class IndexView(storage: ActorRef)(implicit timeout: Timeout)
object IndexView { object IndexView {
import scalatags.Text.all._ import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template: String = def template: String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" + "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html( html(
head( head(
title:="FFXIV loot helper", titleTag("FFXIV loot helper"),
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
), ),
body( body(
form(action:=s"party", method:="post")( form(action := s"party", method := "post")(
label("create a new party"), label("create a new party"),
input(name:="username", id:="username", placeholder:="username", title:="username", `type`:="text"), input(name := "alias", id := "alias", placeholder := "party alias", title := "alias", `type` := "text"),
input(name:="password", id:="password", placeholder:="password", title:="password", `type`:="password"), input(
input(name:="add", id:="add", `type`:="submit", value:="add") name := "username",
id := "username",
placeholder := "username",
title := "username",
`type` := "text"
),
input(
name := "password",
id := "password",
placeholder := "password",
title := "password",
`type` := "password"
),
input(name := "add", id := "add", `type` := "submit", value := "add")
), ),
br, br,
form(action := "/", method := "get")(
form(action:="/", method:="get")(
label("already have party?"), label("already have party?"),
input(name:="partyId", id:="partyId", placeholder:="party id", title:="party id", `type`:="text"), input(name := "partyId", id := "partyId", placeholder := "party id", title := "party id", `type` := "text"),
input(name:="go", id:="go", `type`:="submit", value:="go") input(name := "go", id := "go", `type` := "submit", value := "go")
) )
) )
) )
} }

View File

@ -8,19 +8,21 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.Route
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, LootHelper} import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.models.{Piece, PlayerIdWithCounters} import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Job, Piece, PieceType, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try} import scala.util.{Failure, Success, Try}
class LootSuggestView(override val storage: ActorRef)(implicit timeout: Timeout) class LootSuggestView(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends LootHelper(storage) with Authorization { extends LootHelper
with Authorization {
def route: Route = getIndex ~ suggestLoot def route: Route = getIndex ~ suggestLoot
@ -30,7 +32,7 @@ class LootSuggestView(override val storage: ActorRef)(implicit timeout: Timeout)
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get { get {
complete { complete {
val text = LootSuggestView.template(partyId, Seq.empty, None, None) val text = LootSuggestView.template(partyId, Seq.empty, None, false, None)
(StatusCodes.OK, RootView.toHtml(text)) (StatusCodes.OK, RootView.toHtml(text))
} }
} }
@ -43,26 +45,30 @@ class LootSuggestView(override val storage: ActorRef)(implicit timeout: Timeout)
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
post { post {
formFields("piece".as[String], "is_tome".as[String].?) { (piece, maybeTome) => formFields("piece".as[String], "job".as[String], "piece_type".as[String], "free_loot".as[String].?) {
import me.arcanis.ffxivbis.utils.Implicits._ (piece, job, pieceType, maybeFreeLoot) =>
val maybePiece = Try(Piece(piece, maybeTome)).toOption import me.arcanis.ffxivbis.utils.Implicits._
onComplete(suggestLootCall(partyId, maybePiece)) { val maybePiece = Try(Piece(piece, PieceType.withName(pieceType), Job.withName(job))).toOption
case Success(players) =>
val text = LootSuggestView.template(partyId, players, maybePiece, None) onComplete(suggestLootCall(partyId, maybePiece)) {
complete(StatusCodes.OK, RootView.toHtml(text)) case Success(players) =>
case Failure(exception) => val text = LootSuggestView.template(partyId, players, maybePiece, maybeFreeLoot, None)
val text = LootSuggestView.template(partyId, Seq.empty, maybePiece, Some(exception.getMessage)) complete(StatusCodes.OK, RootView.toHtml(text))
complete(StatusCodes.OK, RootView.toHtml(text)) case Failure(exception) =>
} val text = LootSuggestView.template(partyId, Seq.empty, None, false, Some(exception.getMessage))
complete(StatusCodes.OK, RootView.toHtml(text))
}
} }
} }
} }
} }
} }
private def suggestLootCall(partyId: String, maybePiece: Option[Piece]) private def suggestLootCall(partyId: String, maybePiece: Option[Piece])(implicit
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[PlayerIdWithCounters]] = executionContext: ExecutionContext,
timeout: Timeout
): Future[Seq[PlayerIdWithCounters]] =
maybePiece match { maybePiece match {
case Some(piece) => suggestPiece(partyId, piece) case Some(piece) => suggestPiece(partyId, piece)
case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece`")) case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece`"))
@ -71,30 +77,42 @@ class LootSuggestView(override val storage: ActorRef)(implicit timeout: Timeout)
object LootSuggestView { object LootSuggestView {
import scalatags.Text.all._ import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[PlayerIdWithCounters], piece: Option[Piece], error: Option[String]): String = def template(
partyId: String,
party: Seq[PlayerIdWithCounters],
piece: Option[Piece],
isFreeLoot: Boolean,
error: Option[String]
): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" + "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en", html(
lang := "en",
head( head(
title:="Suggest loot", titleTag("Suggest loot"),
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
), ),
body( body(
h2("Suggest loot"), h2("Suggest loot"),
for (part <- piece) yield p(s"Piece ${part.piece} (${part.pieceType})"),
ErrorView.template(error), ErrorView.template(error),
SearchLineView.template, SearchLineView.template,
form(action := s"/party/$partyId/suggest", method := "post")(
form(action:=s"/party/$partyId/suggest", method:="post")( select(name := "piece", id := "piece", title := "piece")(
select(name:="piece", id:="piece", title:="piece") for (piece <- Piece.available) yield option(piece)
(for (piece <- Piece.available) yield option(piece)), ),
input(name:="is_tome", id:="is_tome", title:="is tome", `type`:="checkbox"), select(name := "job", id := "job", title := "job")(
label(`for`:="is_tome")("is tome gear"), for (job <- Job.availableWithAnyJob) yield option(job.toString)
input(name:="suggest", id:="suggest", `type`:="submit", value:="suggest") ),
select(name := "piece_type", id := "piece_type", title := "piece type")(
for (pieceType <- PieceType.available) yield option(pieceType.toString)
),
input(name := "free_loot", id := "free_loot", title := "is free loot", `type` := "checkbox"),
label(`for` := "free_loot")("is free loot"),
input(name := "suggest", id := "suggest", `type` := "submit", value := "suggest")
), ),
table(id := "result")(
table(id:="result")(
tr( tr(
th("player"), th("player"),
th("is required"), th("is required"),
@ -103,27 +121,43 @@ object LootSuggestView {
th("total pieces looted"), th("total pieces looted"),
th("") th("")
), ),
for (player <- party) yield tr( for (player <- party)
td(`class`:="include_search")(player.playerId.toString), yield tr(
td(player.isRequiredToString), td(`class` := "include_search")(player.playerId.toString),
td(player.lootCount), td(player.isRequiredToString),
td(player.lootCountBiS), td(player.lootCount),
td(player.lootCountTotal), td(player.lootCountBiS),
td( td(player.lootCountTotal),
form(action:=s"/party/$partyId/loot", method:="post")( td(
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString), form(action := s"/party/$partyId/loot", method := "post")(
input(name:="piece", id:="piece", `type`:="hidden", value:=piece.map(_.piece).getOrElse("")), input(name := "player", id := "player", `type` := "hidden", value := player.playerId.toString),
input(name:="is_tome", id:="is_tome", `type`:="hidden", value:=piece.map(_.isTomeToString).getOrElse("")), input(
input(name:="action", id:="action", `type`:="hidden", value:="add"), name := "piece",
input(name:="add", id:="add", `type`:="submit", value:="add") id := "piece",
`type` := "hidden",
value := piece.map(_.piece).getOrElse("")
),
input(
name := "piece_type",
id := "piece_type",
`type` := "hidden",
value := piece.map(_.pieceType.toString).getOrElse("")
),
input(
name := "free_loot",
id := "free_loot",
`type` := "hidden",
value := (if (isFreeLoot) "yes" else "no")
),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
)
) )
) )
)
), ),
ExportToCSVView.template, ExportToCSVView.template,
BasePartyView.root(partyId), BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript") script(src := "/static/table_search.js", `type` := "text/javascript")
) )
) )
} }

View File

@ -8,19 +8,21 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.Route
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, LootHelper} import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId} import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Piece, PieceType, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try import scala.util.Try
class LootView (override val storage: ActorRef)(implicit timeout: Timeout) class LootView(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends LootHelper(storage) with Authorization { extends LootHelper
with Authorization {
def route: Route = getLoot ~ modifyLoot def route: Route = getLoot ~ modifyLoot
@ -30,11 +32,13 @@ class LootView (override val storage: ActorRef)(implicit timeout: Timeout)
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get { get {
complete { complete {
loot(partyId, None).map { players => loot(partyId, None)
LootView.template(partyId, players, None) .map { players =>
}.map { text => LootView.template(partyId, players, None)
(StatusCodes.OK, RootView.toHtml(text)) }
} .map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
} }
} }
} }
@ -46,32 +50,42 @@ class LootView (override val storage: ActorRef)(implicit timeout: Timeout)
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post { post {
formFields("player".as[String], "piece".as[String], "is_tome".as[String].?, "action".as[String]) { formFields(
(player, maybePiece, maybeIsTome, action) => "player".as[String],
onComplete(modifyLootCall(partyId, player, maybePiece, maybeIsTome, action)) { "piece".as[String],
case _ => redirect(s"/party/$partyId/loot", StatusCodes.Found) "piece_type".as[String],
} "action".as[String],
"free_loot".as[String].?
) { (player, piece, pieceType, action, isFreeLoot) =>
onComplete(modifyLootCall(partyId, player, piece, pieceType, isFreeLoot, action)) { _ =>
redirect(s"/party/$partyId/loot", StatusCodes.Found)
}
} }
} }
} }
} }
} }
private def modifyLootCall(partyId: String, player: String, private def modifyLootCall(
maybePiece: String, maybeIsTome: Option[String], partyId: String,
action: String) player: String,
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = { maybePiece: String,
maybePieceType: String,
maybeFreeLoot: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
import me.arcanis.ffxivbis.utils.Implicits._ import me.arcanis.ffxivbis.utils.Implicits._
def getPiece(playerId: PlayerId) = def getPiece(playerId: PlayerId) =
Try(Piece(maybePiece, maybeIsTome, playerId.job)).toOption Try(Piece(maybePiece, PieceType.withName(maybePieceType), playerId.job)).toOption
PlayerId(partyId, player) match { PlayerId(partyId, player) match {
case Some(playerId) => (getPiece(playerId), action) match { case Some(playerId) =>
case (Some(piece), "add") => addPieceLoot(playerId, piece).map(_ => ()) (getPiece(playerId), action) match {
case (Some(piece), "remove") => removePieceLoot(playerId, piece).map(_ => ()) case (Some(piece), "add") => addPieceLoot(playerId, piece, maybeFreeLoot)
case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece`")) case (Some(piece), "remove") => removePieceLoot(playerId, piece)
} case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece ($maybePieceType)`"))
}
case _ => Future.failed(new Error(s"Could not construct player id from `$player`")) case _ => Future.failed(new Error(s"Could not construct player id from `$player`"))
} }
} }
@ -79,58 +93,71 @@ class LootView (override val storage: ActorRef)(implicit timeout: Timeout)
object LootView { object LootView {
import scalatags.Text.all._ import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[Player], error: Option[String]): String = def template(partyId: String, party: Seq[Player], error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" + "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en", html(
lang := "en",
head( head(
title:="Loot", titleTag("Loot"),
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
), ),
body( body(
h2("Loot"), h2("Loot"),
ErrorView.template(error), ErrorView.template(error),
SearchLineView.template, SearchLineView.template,
form(action := s"/party/$partyId/loot", method := "post")(
form(action:=s"/party/$partyId/loot", method:="post")( select(name := "player", id := "player", title := "player")(
select(name:="player", id:="player", title:="player") for (player <- party) yield option(player.playerId.toString)
(for (player <- party) yield option(player.playerId.toString)), ),
select(name:="piece", id:="piece", title:="piece") select(name := "piece", id := "piece", title := "piece")(
(for (piece <- Piece.available) yield option(piece)), for (piece <- Piece.available) yield option(piece)
input(name:="is_tome", id:="is_tome", title:="is tome", `type`:="checkbox"), ),
label(`for`:="is_tome")("is tome gear"), select(name := "piece_type", id := "piece_type", title := "piece type")(
input(name:="action", id:="action", `type`:="hidden", value:="add"), for (pieceType <- PieceType.available) yield option(pieceType.toString)
input(name:="add", id:="add", `type`:="submit", value:="add") ),
input(name := "free_loot", id := "free_loot", title := "is free loot", `type` := "checkbox"),
label(`for` := "free_loot")("is free loot"),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
), ),
table(id := "result")(
table(id:="result")(
tr( tr(
th("player"), th("player"),
th("piece"), th("piece"),
th("is tome"), th("piece type"),
th("is free loot"),
th("timestamp"),
th("") th("")
), ),
for (player <- party; piece <- player.bis.pieces) yield tr( for (player <- party; loot <- player.loot)
td(`class`:="include_search")(player.playerId.toString), yield tr(
td(`class`:="include_search")(piece.piece), td(`class` := "include_search")(player.playerId.toString),
td(piece.isTomeToString), td(`class` := "include_search")(loot.piece.piece),
td( td(loot.piece.pieceType.toString),
form(action:=s"/party/$partyId/loot", method:="post")( td(loot.isFreeLootToString),
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString), td(loot.timestamp.toString),
input(name:="piece", id:="piece", `type`:="hidden", value:=piece.piece), td(
input(name:="is_tome", id:="is_tome", `type`:="hidden", value:=piece.isTomeToString), form(action := s"/party/$partyId/loot", method := "post")(
input(name:="action", id:="action", `type`:="hidden", value:="remove"), input(name := "player", id := "player", `type` := "hidden", value := player.playerId.toString),
input(name:="remove", id:="remove", `type`:="submit", value:="x") input(name := "piece", id := "piece", `type` := "hidden", value := loot.piece.piece),
input(
name := "piece_type",
id := "piece_type",
`type` := "hidden",
value := loot.piece.pieceType.toString
),
input(name := "free_loot", id := "free_loot", `type` := "hidden", value := loot.isFreeLootToString),
input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
) )
) )
)
), ),
ExportToCSVView.template, ExportToCSVView.template,
BasePartyView.root(partyId), BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript") script(src := "/static/table_search.js", `type` := "text/javascript")
) )
) )

View File

@ -8,18 +8,22 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.Route
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper} import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId, PlayerIdWithCounters} import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
import me.arcanis.ffxivbis.models._
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class PlayerView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) class PlayerView(override val storage: ActorRef[Message], override val provider: ActorRef[BiSProviderMessage])(implicit
extends PlayerHelper(storage, ariyala) with Authorization { timeout: Timeout,
scheduler: Scheduler
) extends PlayerHelper
with Authorization {
def route: Route = getParty ~ modifyParty def route: Route = getParty ~ modifyParty
@ -29,11 +33,13 @@ class PlayerView(override val storage: ActorRef, ariyala: ActorRef)(implicit tim
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get { get {
complete { complete {
getPlayers(partyId, None).map { players => getPlayers(partyId, None)
PlayerView.template(partyId, players.map(_.withCounters(None)), None) .map { players =>
}.map { text => PlayerView.template(partyId, players.map(_.withCounters(None)), None)
(StatusCodes.OK, RootView.toHtml(text)) }
} .map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
} }
} }
} }
@ -45,28 +51,37 @@ class PlayerView(override val storage: ActorRef, ariyala: ActorRef)(implicit tim
extractExecutionContext { implicit executionContext => extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post { post {
formFields("nick".as[String], "job".as[String], "priority".as[Int].?, "link".as[String].?, "action".as[String]) { formFields(
(nick, job, maybePriority, maybeLink, action) => "nick".as[String],
onComplete(modifyPartyCall(partyId, nick, job, maybePriority, maybeLink, action)) { "job".as[String],
case _ => redirect(s"/party/$partyId/players", StatusCodes.Found) "priority".as[Int].?,
} "link".as[String].?,
"action".as[String]
) { (nick, job, maybePriority, maybeLink, action) =>
onComplete(modifyPartyCall(partyId, nick, job, maybePriority, maybeLink, action)) { _ =>
redirect(s"/party/$partyId/players", StatusCodes.Found)
}
} }
} }
} }
} }
} }
private def modifyPartyCall(partyId: String, nick: String, job: String, private def modifyPartyCall(
maybePriority: Option[Int], maybeLink: Option[String], partyId: String,
action: String) nick: String,
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = { job: String,
maybePriority: Option[Int],
maybeLink: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
def maybePlayerId = PlayerId(partyId, Some(nick), Some(job)) def maybePlayerId = PlayerId(partyId, Some(nick), Some(job))
def player(playerId: PlayerId) = def player(playerId: PlayerId) =
Player(partyId, playerId.job, playerId.nick, BiS(), Seq.empty, maybeLink, maybePriority.getOrElse(0)) Player(-1, partyId, playerId.job, playerId.nick, BiS.empty, Seq.empty, maybeLink, maybePriority.getOrElse(0))
(action, maybePlayerId) match { (action, maybePlayerId) match {
case ("add", Some(playerId)) => addPlayer(player(playerId)).map(_ => ()) case ("add", Some(playerId)) => addPlayer(player(playerId))
case ("remove", Some(playerId)) => removePlayer(playerId).map(_ => ()) case ("remove", Some(playerId)) => removePlayer(playerId)
case _ => Future.failed(new Error(s"Could not perform $action with $nick ($job)")) case _ => Future.failed(new Error(s"Could not perform $action with $nick ($job)"))
} }
} }
@ -74,32 +89,36 @@ class PlayerView(override val storage: ActorRef, ariyala: ActorRef)(implicit tim
object PlayerView { object PlayerView {
import scalatags.Text.all._ import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[PlayerIdWithCounters], error: Option[String]): String = def template(partyId: String, party: Seq[PlayerIdWithCounters], error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" + "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en", html(
lang := "en",
head( head(
title:="Party", titleTag("Party"),
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
), ),
body( body(
h2("Party"), h2("Party"),
ErrorView.template(error), ErrorView.template(error),
SearchLineView.template, SearchLineView.template,
form(action := s"/party/$partyId/players", method := "post")(
form(action:=s"/party/$partyId/players", method:="post")( input(name := "nick", id := "nick", placeholder := "nick", title := "nick", `type` := "nick"),
input(name:="nick", id:="nick", placeholder:="nick", title:="nick", `type`:="nick"), select(name := "job", id := "job", title := "job")(for (job <- Job.available) yield option(job.toString)),
select(name:="job", id:="job", title:="job") input(name := "link", id := "link", placeholder := "player bis link", title := "link", `type` := "text"),
(for (job <- Job.jobs) yield option(job.toString)), input(
input(name:="link", id:="link", placeholder:="player bis link", title:="link", `type`:="text"), name := "prioiry",
input(name:="prioiry", id:="priority", placeholder:="priority", title:="priority", `type`:="number", value:="0"), id := "priority",
input(name:="action", id:="action", `type`:="hidden", value:="add"), placeholder := "priority",
input(name:="add", id:="add", `type`:="submit", value:="add") title := "priority",
`type` := "number",
value := "0"
),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
), ),
table(id := "result")(
table(id:="result")(
tr( tr(
th("nick"), th("nick"),
th("job"), th("job"),
@ -108,26 +127,26 @@ object PlayerView {
th("priority"), th("priority"),
th("") th("")
), ),
for (player <- party) yield tr( for (player <- party)
td(`class`:="include_search")(player.nick), yield tr(
td(`class`:="include_search")(player.job.toString), td(`class` := "include_search")(player.nick),
td(player.lootCountBiS), td(`class` := "include_search")(player.job.toString),
td(player.lootCountTotal), td(player.lootCountBiS),
td(player.priority), td(player.lootCountTotal),
td( td(player.priority),
form(action:=s"/party/$partyId/players", method:="post")( td(
input(name:="nick", id:="nick", `type`:="hidden", value:=player.nick), form(action := s"/party/$partyId/players", method := "post")(
input(name:="job", id:="job", `type`:="hidden", value:=player.job.toString), input(name := "nick", id := "nick", `type` := "hidden", value := player.nick),
input(name:="action", id:="action", `type`:="hidden", value:="remove"), input(name := "job", id := "job", `type` := "hidden", value := player.job.toString),
input(name:="remove", id:="remove", `type`:="submit", value:="x") input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
) )
) )
)
), ),
ExportToCSVView.template, ExportToCSVView.template,
BasePartyView.root(partyId), BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript") script(src := "/static/table_search.js", `type` := "text/javascript")
) )
) )
} }

View File

@ -8,29 +8,34 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.{ContentTypes, HttpEntity} import akka.http.scaladsl.model.{ContentTypes, HttpEntity}
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.Route
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message}
class RootView(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) { class RootView(storage: ActorRef[Message], provider: ActorRef[BiSProviderMessage])(implicit
timeout: Timeout,
scheduler: Scheduler
) {
private val basePartyView = new BasePartyView(storage) private val basePartyView = new BasePartyView(storage, provider)
private val indexView = new IndexView(storage) private val indexView = new IndexView(storage, provider)
private val biSView = new BiSView(storage, ariyala) private val biSView = new BiSView(storage, provider)
private val lootView = new LootView(storage) private val lootView = new LootView(storage)
private val lootSuggestView = new LootSuggestView(storage) private val lootSuggestView = new LootSuggestView(storage)
private val playerView = new PlayerView(storage, ariyala) private val playerView = new PlayerView(storage, provider)
private val userView = new UserView(storage) private val userView = new UserView(storage)
def route: Route = def route: Route =
basePartyView.route ~ indexView.route ~ basePartyView.route ~ indexView.route ~
biSView.route ~ lootView.route ~ lootSuggestView.route ~ playerView.route ~ userView.route biSView.route ~ lootView.route ~ lootSuggestView.route ~ playerView.route ~ userView.route
} }
object RootView { object RootView {
def toHtml(template: String): HttpEntity.Strict = def toHtml(template: String): HttpEntity.Strict =
HttpEntity(ContentTypes.`text/html(UTF-8)`, template) HttpEntity(ContentTypes.`text/html(UTF-8)`, template)
} }

View File

@ -12,11 +12,15 @@ import scalatags.Text
import scalatags.Text.all._ import scalatags.Text.all._
object SearchLineView { object SearchLineView {
def template: Text.TypedTag[String] = def template: Text.TypedTag[String] =
div( div(
input( input(
`type`:="text", id:="search", onkeyup:="searchTable()", `type` := "text",
placeholder:="search for data", title:="search" id := "search",
onkeyup := "searchTable()",
placeholder := "search for data",
title := "search"
) )
) )
} }

View File

@ -8,19 +8,21 @@
*/ */
package me.arcanis.ffxivbis.http.view package me.arcanis.ffxivbis.http.view
import akka.actor.ActorRef import akka.actor.typed.{ActorRef, Scheduler}
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.Route
import akka.util.Timeout import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, UserHelper} import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
import me.arcanis.ffxivbis.messages.Message
import me.arcanis.ffxivbis.models.{Permission, User} import me.arcanis.ffxivbis.models.{Permission, User}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try import scala.util.Try
class UserView(override val storage: ActorRef)(implicit timeout: Timeout) class UserView(override val storage: ActorRef[Message])(implicit timeout: Timeout, scheduler: Scheduler)
extends UserHelper(storage) with Authorization { extends UserHelper
with Authorization {
def route: Route = getUsers ~ modifyUsers def route: Route = getUsers ~ modifyUsers
@ -30,11 +32,13 @@ class UserView(override val storage: ActorRef)(implicit timeout: Timeout)
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
get { get {
complete { complete {
users(partyId).map { users => users(partyId)
UserView.template(partyId, users, None) .map { users =>
}.map { text => UserView.template(partyId, users, None)
(StatusCodes.OK, RootView.toHtml(text)) }
} .map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
} }
} }
} }
@ -48,8 +52,8 @@ class UserView(override val storage: ActorRef)(implicit timeout: Timeout)
post { post {
formFields("username".as[String], "password".as[String].?, "permission".as[String].?, "action".as[String]) { formFields("username".as[String], "password".as[String].?, "permission".as[String].?, "action".as[String]) {
(username, maybePassword, maybePermission, action) => (username, maybePassword, maybePermission, action) =>
onComplete(modifyUsersCall(partyId, username, maybePassword, maybePermission, action)) { onComplete(modifyUsersCall(partyId, username, maybePassword, maybePermission, action)) { case _ =>
case _ => redirect(s"/party/$partyId/users", StatusCodes.Found) redirect(s"/party/$partyId/users", StatusCodes.Found)
} }
} }
} }
@ -57,19 +61,27 @@ class UserView(override val storage: ActorRef)(implicit timeout: Timeout)
} }
} }
private def modifyUsersCall(partyId: String, username: String, private def modifyUsersCall(
maybePassword: Option[String], maybePermission: Option[String], partyId: String,
action: String) username: String,
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = { maybePassword: Option[String],
maybePermission: Option[String],
action: String
)(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
def permission: Option[Permission.Value] = def permission: Option[Permission.Value] =
maybePermission.flatMap(p => Try(Permission.withName(p)).toOption) maybePermission.flatMap(p => Try(Permission.withName(p)).toOption)
action match { action match {
case "add" => (maybePassword, permission) match { case "add" =>
case (Some(password), Some(permission)) => addUser(User(partyId, username, password, permission), isHashedPassword = false).map(_ => ()) (maybePassword, permission) match {
case _ => Future.failed(new Error(s"Could not construct permission/password from `$maybePermission`/`$maybePassword`")) case (Some(password), Some(permission)) =>
} addUser(User(partyId, username, password, permission), isHashedPassword = false)
case "remove" => removeUser(partyId, username).map(_ => ()) case _ =>
Future.failed(
new Error(s"Could not construct permission/password from `$maybePermission`/`$maybePassword`")
)
}
case "remove" => removeUser(partyId, username)
case _ => Future.failed(new Error(s"Could not perform $action")) case _ => Future.failed(new Error(s"Could not perform $action"))
} }
} }
@ -77,51 +89,61 @@ class UserView(override val storage: ActorRef)(implicit timeout: Timeout)
object UserView { object UserView {
import scalatags.Text.all._ import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, users: Seq[User], error: Option[String]) = def template(partyId: String, users: Seq[User], error: Option[String]) =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" + "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en", html(
lang := "en",
head( head(
title:="Users", titleTag("Users"),
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") link(rel := "stylesheet", `type` := "text/css", href := "/static/styles.css")
), ),
body( body(
h2("Users"), h2("Users"),
ErrorView.template(error), ErrorView.template(error),
SearchLineView.template, SearchLineView.template,
form(action := s"/party/$partyId/users", method := "post")(
form(action:=s"/party/$partyId/users", method:="post")( input(
input(name:="username", id:="username", placeholder:="username", title:="username", `type`:="text"), name := "username",
input(name:="password", id:="password", placeholder:="password", title:="password", `type`:="password"), id := "username",
select(name:="permission", id:="permission", title:="permission")(option("get"), option("post")), placeholder := "username",
input(name:="action", id:="action", `type`:="hidden", value:="add"), title := "username",
input(name:="add", id:="add", `type`:="submit", value:="add") `type` := "text"
),
input(
name := "password",
id := "password",
placeholder := "password",
title := "password",
`type` := "password"
),
select(name := "permission", id := "permission", title := "permission")(option("get"), option("post")),
input(name := "action", id := "action", `type` := "hidden", value := "add"),
input(name := "add", id := "add", `type` := "submit", value := "add")
), ),
table(id := "result")(
table(id:="result")(
tr( tr(
th("username"), th("username"),
th("permission"), th("permission"),
th("") th("")
), ),
for (user <- users) yield tr( for (user <- users)
td(`class`:="include_search")(user.username), yield tr(
td(user.permission.toString), td(`class` := "include_search")(user.username),
td( td(user.permission.toString),
form(action:=s"/party/$partyId/users", method:="post")( td(
input(name:="username", id:="username", `type`:="hidden", value:=user.username.toString), form(action := s"/party/$partyId/users", method := "post")(
input(name:="action", id:="action", `type`:="hidden", value:="remove"), input(name := "username", id := "username", `type` := "hidden", value := user.username.toString),
input(name:="remove", id:="remove", `type`:="submit", value:="x") input(name := "action", id := "action", `type` := "hidden", value := "remove"),
input(name := "remove", id := "remove", `type` := "submit", value := "x")
)
) )
) )
)
), ),
ExportToCSVView.template, ExportToCSVView.template,
BasePartyView.root(partyId), BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript") script(src := "/static/table_search.js", `type` := "text/javascript")
) )
) )
} }

View File

@ -0,0 +1,11 @@
package me.arcanis.ffxivbis.messages
import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models.{BiS, Job}
sealed trait BiSProviderMessage
case class DownloadBiS(link: String, job: Job.Job, replyTo: ActorRef[BiS]) extends BiSProviderMessage {
require(link.nonEmpty && link.trim == link, "Link must be not empty and contain no spaces")
}

View File

@ -0,0 +1,10 @@
package me.arcanis.ffxivbis.messages
import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models.Party
case class ForgetParty(partyId: String) extends Message
case class GetNewPartyId(replyTo: ActorRef[String]) extends Message
case class StoreParty(partyId: String, party: Party) extends Message

View File

@ -0,0 +1,79 @@
package me.arcanis.ffxivbis.messages
import akka.actor.typed.{ActorRef, Behavior}
import me.arcanis.ffxivbis.models.{Party, PartyDescription, Piece, Player, PlayerId, User}
import me.arcanis.ffxivbis.service.LootSelector
sealed trait DatabaseMessage extends Message {
def partyId: String
}
object DatabaseMessage {
type Handler = PartialFunction[DatabaseMessage, Behavior[DatabaseMessage]]
}
// bis handler
case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) extends DatabaseMessage
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
// loot handler
case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) extends DatabaseMessage
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class SuggestLoot(partyId: String, piece: Piece, replyTo: ActorRef[LootSelector.LootSelectorResult])
extends DatabaseMessage
// party handler
case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = player.partyId
}
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends DatabaseMessage
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends DatabaseMessage
case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = playerId.partyId
}
case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = partyDescription.partyId
}
// user handler
case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends DatabaseMessage {
override def partyId: String = user.partyId
}
case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends DatabaseMessage
case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends DatabaseMessage
case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends DatabaseMessage
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends DatabaseMessage

View File

@ -0,0 +1,9 @@
package me.arcanis.ffxivbis.messages
import akka.actor.typed.Behavior
trait Message
object Message {
type Handler = PartialFunction[Message, Behavior[Message]]
}

View File

@ -8,21 +8,7 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
case class BiS(weapon: Option[Piece], case class BiS(pieces: Seq[Piece]) {
head: Option[Piece],
body: Option[Piece],
hands: Option[Piece],
waist: Option[Piece],
legs: Option[Piece],
feet: Option[Piece],
ears: Option[Piece],
neck: Option[Piece],
wrist: Option[Piece],
leftRing: Option[Piece],
rightRing: Option[Piece]) {
val pieces: Seq[Piece] =
Seq(weapon, head, body, hands, waist, legs, feet, ears, neck, wrist, leftRing, rightRing).flatten
def hasPiece(piece: Piece): Boolean = piece match { def hasPiece(piece: Piece): Boolean = piece match {
case upgrade: PieceUpgrade => upgrades.contains(upgrade) case upgrade: PieceUpgrade => upgrades.contains(upgrade)
@ -30,51 +16,31 @@ case class BiS(weapon: Option[Piece],
} }
def upgrades: Map[PieceUpgrade, Int] = def upgrades: Map[PieceUpgrade, Int] =
pieces.groupBy(_.upgrade).foldLeft(Map.empty[PieceUpgrade, Int]) { pieces
case (acc, (Some(k), v)) => acc + (k -> v.length) .groupBy(_.upgrade)
case (acc, _) => acc .foldLeft(Map.empty[PieceUpgrade, Int]) {
} withDefaultValue 0 case (acc, (Some(k), v)) => acc + (k -> v.size)
case (acc, _) => acc
}
.withDefaultValue(0)
def withPiece(piece: Piece): BiS = copyWithPiece(piece.piece, Some(piece)) def withPiece(piece: Piece): BiS = copy(pieces :+ piece)
def withoutPiece(piece: Piece): BiS = copyWithPiece(piece.piece, None) def withoutPiece(piece: Piece): BiS = copy(pieces.filterNot(_.strictEqual(piece)))
private def copyWithPiece(name: String, piece: Option[Piece]): BiS = { override def equals(obj: Any): Boolean = {
val params = Map( def comparePieces(left: Seq[Piece], right: Seq[Piece]): Boolean =
"weapon" -> weapon, left.groupBy(identity).view.mapValues(_.size).forall { case (key, count) =>
"head" -> head, right.count(_.strictEqual(key)) == count
"body" -> body, }
"hands" -> hands,
"waist" -> waist, obj match {
"legs" -> legs, case left: BiS => comparePieces(left.pieces, pieces)
"feet" -> feet, case _ => false
"ears" -> ears, }
"neck" -> neck,
"wrist" -> wrist,
"leftRing" -> leftRing,
"rightRing" -> rightRing
) + (name -> piece)
BiS(params)
} }
} }
object BiS { object BiS {
def apply(data: Map[String, Option[Piece]]): BiS =
BiS(
data.get("weapon").flatten,
data.get("head").flatten,
data.get("body").flatten,
data.get("hands").flatten,
data.get("waist").flatten,
data.get("legs").flatten,
data.get("feet").flatten,
data.get("ears").flatten,
data.get("neck").flatten,
data.get("wrist").flatten,
data.get("leftRing").flatten,
data.get("rightRing").flatten)
def apply(): BiS = BiS(Seq.empty) def empty: BiS = BiS(Seq.empty)
def apply(pieces: Seq[Piece]): BiS =
BiS(pieces.map(piece => piece.piece -> Some(piece)).toMap)
} }

View File

@ -9,6 +9,7 @@
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
object Job { object Job {
sealed trait RightSide sealed trait RightSide
object AccessoriesDex extends RightSide object AccessoriesDex extends RightSide
object AccessoriesInt extends RightSide object AccessoriesInt extends RightSide
@ -25,17 +26,19 @@ object Job {
object BodyTanks extends LeftSide object BodyTanks extends LeftSide
object BodyRanges extends LeftSide object BodyRanges extends LeftSide
sealed trait Job { sealed trait Job extends Equals {
def leftSide: LeftSide def leftSide: LeftSide
def rightSide: RightSide def rightSide: RightSide
// conversion to string to avoid recursion // conversion to string to avoid recursion
override def canEqual(that: Any): Boolean = that.isInstanceOf[Job]
override def equals(obj: Any): Boolean = { override def equals(obj: Any): Boolean = {
def canEqual(obj: Any): Boolean = obj.isInstanceOf[Job]
def equality(objRepr: String): Boolean = objRepr match { def equality(objRepr: String): Boolean = objRepr match {
case _ if objRepr == AnyJob.toString => true case _ if objRepr == AnyJob.toString => true
case _ if this.toString == AnyJob.toString => true case _ if this.toString == AnyJob.toString => true
case _ => this.toString == obj.toString case _ => this.toString == objRepr
} }
canEqual(obj) && equality(obj.toString) canEqual(obj) && equality(obj.toString)
@ -59,6 +62,10 @@ object Job {
val leftSide: LeftSide = BodyMnks val leftSide: LeftSide = BodyMnks
val rightSide: RightSide = AccessoriesStr val rightSide: RightSide = AccessoriesStr
} }
trait Drgs extends Job {
val leftSide: LeftSide = BodyDrgs
val rightSide: RightSide = AccessoriesStr
}
trait Tanks extends Job { trait Tanks extends Job {
val leftSide: LeftSide = BodyTanks val leftSide: LeftSide = BodyTanks
val rightSide: RightSide = AccessoriesVit val rightSide: RightSide = AccessoriesVit
@ -76,12 +83,11 @@ object Job {
case object WHM extends Healers case object WHM extends Healers
case object SCH extends Healers case object SCH extends Healers
case object AST extends Healers case object AST extends Healers
case object SGE extends Healers
case object MNK extends Mnks case object MNK extends Mnks
case object DRG extends Job { case object DRG extends Drgs
val leftSide: LeftSide = BodyDrgs case object RPR extends Drgs
val rightSide: RightSide = AccessoriesStr
}
case object NIN extends Job { case object NIN extends Job {
val leftSide: LeftSide = BodyNins val leftSide: LeftSide = BodyNins
val rightSide: RightSide = AccessoriesDex val rightSide: RightSide = AccessoriesDex
@ -96,8 +102,14 @@ object Job {
case object SMN extends Casters case object SMN extends Casters
case object RDM extends Casters case object RDM extends Casters
lazy val jobs: Seq[Job] = lazy val available: Seq[Job] =
Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, MNK, DRG, NIN, SAM, BRD, MCH, DNC, BLM, SMN, RDM) Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, SGE, MNK, DRG, RPR, NIN, SAM, BRD, MCH, DNC, BLM, SMN, RDM)
lazy val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob)
def withName(job: String): Job.Job = jobs.find(_.toString == job.toUpperCase).getOrElse(AnyJob) def withName(job: String): Job.Job =
availableWithAnyJob.find(_.toString.equalsIgnoreCase(job)) match {
case Some(value) => value
case None if job.isEmpty => AnyJob
case _ => throw new IllegalArgumentException(s"Invalid or unknown job $job")
}
} }

View File

@ -8,4 +8,9 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
case class Loot(playerId: Long, piece: Piece) import java.time.Instant
case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) {
def isFreeLootToString: String = if (isFreeLoot) "yes" else "no"
}

View File

@ -15,15 +15,15 @@ import me.arcanis.ffxivbis.service.LootSelector
import scala.jdk.CollectionConverters._ import scala.jdk.CollectionConverters._
import scala.util.Random import scala.util.Random
case class Party(partyId: String, rules: Seq[String], players: Map[PlayerId, Player]) case class Party(partyDescription: PartyDescription, rules: Seq[String], players: Map[PlayerId, Player])
extends StrictLogging { extends StrictLogging {
require(players.keys.forall(_.partyId == partyId), "party id must be same") require(players.keys.forall(_.partyId == partyDescription.partyId), "party id must be same")
def getPlayers: Seq[Player] = players.values.toSeq def getPlayers: Seq[Player] = players.values.toSeq
def player(playerId: PlayerId): Option[Player] = players.get(playerId) def player(playerId: PlayerId): Option[Player] = players.get(playerId)
def withPlayer(player: Player): Party = def withPlayer(player: Player): Party =
try { try {
require(player.partyId == partyId, "player must belong to this party") require(player.partyId == partyDescription.partyId, "player must belong to this party")
copy(players = players + (player.playerId -> player)) copy(players = players + (player.playerId -> player))
} catch { } catch {
case exception: Exception => case exception: Exception =>
@ -36,24 +36,26 @@ case class Party(partyId: String, rules: Seq[String], players: Map[PlayerId, Pla
} }
object Party { object Party {
private def getRules(config: Config): Seq[String] =
config.getStringList("me.arcanis.ffxivbis.settings.priority").asScala.toSeq
def apply(partyId: Option[String], config: Config): Party = def apply(
new Party(partyId.getOrElse(randomPartyId), getRules(config), Map.empty) party: PartyDescription,
config: Config,
def apply(partyId: String, config: Config, players: Map[Long, Player],
players: Map[Long, Player], bis: Seq[Loot], loot: Seq[Loot]): Party = { bis: Seq[Loot],
loot: Seq[Loot]
): Party = {
val bisByPlayer = bis.groupBy(_.playerId).view.mapValues(piece => BiS(piece.map(_.piece))) val bisByPlayer = bis.groupBy(_.playerId).view.mapValues(piece => BiS(piece.map(_.piece)))
val lootByPlayer = loot.groupBy(_.playerId).view.mapValues(_.map(_.piece)) val lootByPlayer = loot.groupBy(_.playerId).view
val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) { val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) { case (acc, (playerId, player)) =>
case (acc, (playerId, player)) => acc + (player.playerId -> player
acc + (player.playerId -> player .withBiS(bisByPlayer.get(playerId))
.withBiS(bisByPlayer.get(playerId)) .withLoot(lootByPlayer.getOrElse(playerId, Seq.empty)))
.withLoot(lootByPlayer.getOrElse(playerId, Seq.empty)))
} }
Party(partyId, getRules(config), playersWithItems) Party(party, getRules(config), playersWithItems)
} }
def getRules(config: Config): Seq[String] =
config.getStringList("me.arcanis.ffxivbis.settings.priority").asScala.toSeq
def randomPartyId: String = Random.alphanumeric.take(20).mkString def randomPartyId: String = Random.alphanumeric.take(20).mkString
} }

View File

@ -0,0 +1,19 @@
/*
* Copyright (c) 2020 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.models
case class PartyDescription(partyId: String, partyAlias: Option[String]) {
def alias: String = partyAlias.getOrElse(partyId)
}
object PartyDescription {
def empty(partyId: String): PartyDescription = PartyDescription(partyId, None)
}

View File

@ -8,79 +8,88 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
sealed trait Piece { sealed trait Piece extends Equals {
def isTome: Boolean
def pieceType: PieceType.PieceType
def job: Job.Job def job: Job.Job
def piece: String def piece: String
def withJob(other: Job.Job): Piece def withJob(other: Job.Job): Piece
def isTomeToString: String = if (isTome) "yes" else "no" def upgrade: Option[PieceUpgrade] = {
def upgrade: Option[PieceUpgrade] = this match { val isTome = pieceType == PieceType.Tome
case _ if !isTome => None Some(this).collect {
case _: Waist => Some(AccessoryUpgrade) case _: PieceAccessory if isTome => AccessoryUpgrade
case _: PieceAccessory => Some(AccessoryUpgrade) case _: PieceBody if isTome => BodyUpgrade
case _: PieceBody => Some(BodyUpgrade) case _: PieceWeapon if isTome => WeaponUpgrade
case _: PieceWeapon => Some(WeaponUpgrade) }
} }
// used for ring comparison
def strictEqual(obj: Any): Boolean = equals(obj)
} }
trait PieceAccessory extends Piece trait PieceAccessory extends Piece
trait PieceBody extends Piece trait PieceBody extends Piece
trait PieceUpgrade extends Piece { trait PieceUpgrade extends Piece {
val isTome: Boolean = true val pieceType: PieceType.PieceType = PieceType.Tome
val job: Job.Job = Job.AnyJob val job: Job.Job = Job.AnyJob
def withJob(other: Job.Job): Piece = this def withJob(other: Job.Job): Piece = this
} }
trait PieceWeapon extends Piece trait PieceWeapon extends Piece
case class Weapon(override val isTome: Boolean, override val job: Job.Job) extends PieceWeapon { case class Weapon(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceWeapon {
val piece: String = "weapon" val piece: String = "weapon"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Head(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { case class Head(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "head" val piece: String = "head"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Body(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { case class Body(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "body" val piece: String = "body"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Hands(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { case class Hands(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "hands" val piece: String = "hands"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Waist(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { case class Legs(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "waist"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Legs(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "legs" val piece: String = "legs"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Feet(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { case class Feet(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceBody {
val piece: String = "feet" val piece: String = "feet"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Ears(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory { case class Ears(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "ears" val piece: String = "ears"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Neck(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory { case class Neck(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "neck" val piece: String = "neck"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Wrist(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory { case class Wrist(override val pieceType: PieceType.PieceType, override val job: Job.Job) extends PieceAccessory {
val piece: String = "wrist" val piece: String = "wrist"
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
} }
case class Ring(override val isTome: Boolean, override val job: Job.Job, override val piece: String = "ring") case class Ring(
extends PieceAccessory { override val pieceType: PieceType.PieceType,
override val job: Job.Job,
override val piece: String = "ring"
) extends PieceAccessory {
def withJob(other: Job.Job): Piece = copy(job = other) def withJob(other: Job.Job): Piece = copy(job = other)
override def equals(obj: Any): Boolean = obj match { override def equals(obj: Any): Boolean = obj match {
case Ring(thatIsTome, thatJob, _) => (thatIsTome == isTome) && (thatJob == job) case Ring(thatPieceType, thatJob, _) => (thatPieceType == pieceType) && (thatJob == job)
case _ => false
}
override def strictEqual(obj: Any): Boolean = obj match {
case ring: Ring => equals(obj) && (ring.piece == this.piece)
case _ => false case _ => false
} }
} }
@ -96,28 +105,38 @@ case object WeaponUpgrade extends PieceUpgrade {
} }
object Piece { object Piece {
def apply(piece: String, isTome: Boolean, job: Job.Job = Job.AnyJob): Piece = def apply(piece: String, pieceType: PieceType.PieceType, job: Job.Job = Job.AnyJob): Piece =
piece.toLowerCase match { piece.toLowerCase match {
case "weapon" => Weapon(isTome, job) case "weapon" => Weapon(pieceType, job)
case "head" => Head(isTome, job) case "head" => Head(pieceType, job)
case "body" => Body(isTome, job) case "body" => Body(pieceType, job)
case "hands" => Hands(isTome, job) case "hands" => Hands(pieceType, job)
case "waist" => Waist(isTome, job) case "legs" => Legs(pieceType, job)
case "legs" => Legs(isTome, job) case "feet" => Feet(pieceType, job)
case "feet" => Feet(isTome, job) case "ears" => Ears(pieceType, job)
case "ears" => Ears(isTome, job) case "neck" => Neck(pieceType, job)
case "neck" => Neck(isTome, job) case "wrist" | "wrists" => Wrist(pieceType, job)
case "wrist" => Wrist(isTome, job) case ring @ ("ring" | "left ring" | "right ring") => Ring(pieceType, job, ring)
case "ring" => Ring(isTome, job)
case "leftring" => Ring(isTome, job).copy(piece = "leftRing")
case "rightring" => Ring(isTome, job).copy(piece = "rightRing")
case "accessory upgrade" => AccessoryUpgrade case "accessory upgrade" => AccessoryUpgrade
case "body upgrade" => BodyUpgrade case "body upgrade" => BodyUpgrade
case "weapon upgrade" => WeaponUpgrade case "weapon upgrade" => WeaponUpgrade
case other => throw new Error(s"Unknown item type $other") case other => throw new Error(s"Unknown item type $other")
} }
lazy val available: Seq[String] = Seq("weapon", lazy val available: Seq[String] = Seq(
"head", "body", "hands", "waist", "legs", "feet", "weapon",
"ears", "neck", "wrist", "leftRing", "rightRing") "head",
"body",
"hands",
"legs",
"feet",
"ears",
"neck",
"wrist",
"left ring",
"right ring",
"accessory upgrade",
"body upgrade",
"weapon upgrade"
)
} }

View File

@ -0,0 +1,20 @@
package me.arcanis.ffxivbis.models
object PieceType {
sealed trait PieceType
case object Crafted extends PieceType
case object Tome extends PieceType
case object Savage extends PieceType
case object Artifact extends PieceType
lazy val available: Seq[PieceType] =
Seq(Crafted, Tome, Savage, Artifact)
def withName(pieceType: String): PieceType =
available.find(_.toString.equalsIgnoreCase(pieceType)) match {
case Some(value) => value
case _ => throw new IllegalArgumentException(s"Invalid or unknown piece type $pieceType")
}
}

View File

@ -8,13 +8,16 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
case class Player(partyId: String, case class Player(
job: Job.Job, id: Long,
nick: String, partyId: String,
bis: BiS, job: Job.Job,
loot: Seq[Piece], nick: String,
link: Option[String] = None, bis: BiS,
priority: Int = 0) { loot: Seq[Loot],
link: Option[String] = None,
priority: Int = 0
) {
require(job ne Job.AnyJob, "AnyJob is not allowed") require(job ne Job.AnyJob, "AnyJob is not allowed")
val playerId: PlayerId = PlayerId(partyId, job, nick) val playerId: PlayerId = PlayerId(partyId, job, nick)
@ -24,30 +27,36 @@ case class Player(partyId: String,
} }
def withCounters(piece: Option[Piece]): PlayerIdWithCounters = def withCounters(piece: Option[Piece]): PlayerIdWithCounters =
PlayerIdWithCounters( PlayerIdWithCounters(
partyId, job, nick, isRequired(piece), priority, partyId,
bisCountTotal(piece), lootCount(piece), job,
lootCountBiS(piece), lootCountTotal(piece)) nick,
def withLoot(piece: Piece): Player = withLoot(Seq(piece)) isRequired(piece),
def withLoot(list: Seq[Piece]): Player = list match { priority,
case Nil => this bisCountTotal(piece),
case _ => copy(loot = list) lootCount(piece),
lootCountBiS(piece),
lootCountTotal(piece)
)
def withLoot(piece: Loot): Player = withLoot(Seq(piece))
def withLoot(list: Seq[Loot]): Player = {
require(loot.forall(_.playerId == id), "player id must be same")
copy(loot = loot ++ list)
} }
def isRequired(piece: Option[Piece]): Boolean = { def isRequired(piece: Option[Piece]): Boolean =
piece match { piece match {
case None => false case None => false
case Some(p) if !bis.hasPiece(p) => false case Some(p) if !bis.hasPiece(p) => false
case Some(p: PieceUpgrade) => bis.upgrades(p) > lootCount(piece) case Some(p: PieceUpgrade) => bis.upgrades(p) > lootCount(piece)
case Some(_) => lootCount(piece) == 0 case Some(_) => lootCount(piece) == 0
} }
}
def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(!_.isTome) def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(_.pieceType == PieceType.Savage)
def lootCount(piece: Option[Piece]): Int = piece match { def lootCount(piece: Option[Piece]): Int = piece match {
case Some(p) => loot.count(_ == p) case Some(p) => loot.count(item => !item.isFreeLoot && item.piece == p)
case None => lootCountTotal(piece) case None => lootCountTotal(piece)
} }
def lootCountBiS(piece: Option[Piece]): Int = loot.count(bis.hasPiece) def lootCountBiS(piece: Option[Piece]): Int = loot.map(_.piece).count(bis.hasPiece)
def lootCountTotal(piece: Option[Piece]): Int = loot.length def lootCountTotal(piece: Option[Piece]): Int = loot.count(!_.isFreeLoot)
def lootPriority(piece: Piece): Int = priority def lootPriority(piece: Piece): Int = priority
} }

View File

@ -12,6 +12,7 @@ import scala.util.Try
import scala.util.matching.Regex import scala.util.matching.Regex
trait PlayerIdBase { trait PlayerIdBase {
def job: Job.Job def job: Job.Job
def nick: String def nick: String
@ -21,6 +22,7 @@ trait PlayerIdBase {
case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase
object PlayerId { object PlayerId {
def apply(partyId: String, maybeNick: Option[String], maybeJob: Option[String]): Option[PlayerId] = def apply(partyId: String, maybeNick: Option[String], maybeJob: Option[String]): Option[PlayerId] =
(maybeNick, maybeJob) match { (maybeNick, maybeJob) match {
case (Some(nick), Some(job)) => Try(PlayerId(partyId, Job.withName(job), nick)).toOption case (Some(nick), Some(job)) => Try(PlayerId(partyId, Job.withName(job), nick)).toOption

View File

@ -8,16 +8,17 @@
*/ */
package me.arcanis.ffxivbis.models package me.arcanis.ffxivbis.models
case class PlayerIdWithCounters(partyId: String, case class PlayerIdWithCounters(
job: Job.Job, partyId: String,
nick: String, job: Job.Job,
isRequired: Boolean, nick: String,
priority: Int, isRequired: Boolean,
bisCountTotal: Int, priority: Int,
lootCount: Int, bisCountTotal: Int,
lootCountBiS: Int, lootCount: Int,
lootCountTotal: Int) lootCountBiS: Int,
extends PlayerIdBase { lootCountTotal: Int
) extends PlayerIdBase {
import PlayerIdWithCounters._ import PlayerIdWithCounters._
def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean = def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean =
@ -31,13 +32,15 @@ case class PlayerIdWithCounters(partyId: String,
"bisCountTotal" -> bisCountTotal, // the more pieces in bis the more priority "bisCountTotal" -> bisCountTotal, // the more pieces in bis the more priority
"lootCount" -> -lootCount, // the less loot got the more priority "lootCount" -> -lootCount, // the less loot got the more priority
"lootCountBiS" -> -lootCountBiS, // the less bis pieces looted the more priority "lootCountBiS" -> -lootCountBiS, // the less bis pieces looted the more priority
"lootCountTotal" -> -lootCountTotal) withDefaultValue 0 // the less pieces looted the more priority "lootCountTotal" -> -lootCountTotal
).withDefaultValue(0) // the less pieces looted the more priority
private def withCounters(orderBy: Seq[String]): PlayerCountersComparator = private def withCounters(orderBy: Seq[String]): PlayerCountersComparator =
PlayerCountersComparator(orderBy.map(counters): _*) PlayerCountersComparator(orderBy.map(counters): _*)
} }
object PlayerIdWithCounters { object PlayerIdWithCounters {
private case class PlayerCountersComparator(values: Int*) { private case class PlayerCountersComparator(values: Int*) {
def >(that: PlayerCountersComparator): Boolean = { def >(that: PlayerCountersComparator): Boolean = {
@scala.annotation.tailrec @scala.annotation.tailrec

View File

@ -14,10 +14,7 @@ object Permission extends Enumeration {
val get, post, admin = Value val get, post, admin = Value
} }
case class User(partyId: String, case class User(partyId: String, username: String, password: String, permission: Permission.Value) {
username: String,
password: String,
permission: Permission.Value) {
def hash: String = BCrypt.hashpw(password, BCrypt.gensalt) def hash: String = BCrypt.hashpw(password, BCrypt.gensalt)
def verify(plain: String): Boolean = BCrypt.checkpw(plain, password) def verify(plain: String): Boolean = BCrypt.checkpw(plain, password)

View File

@ -1,136 +0,0 @@
/*
* 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 java.nio.file.Paths
import akka.actor.{Actor, Props}
import akka.http.scaladsl.model._
import akka.http.scaladsl.Http
import akka.pattern.pipe
import akka.stream.ActorMaterializer
import akka.stream.scaladsl.{Keep, Sink}
import akka.util.ByteString
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.{BiS, Job, Piece}
import spray.json._
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class Ariyala extends Actor with StrictLogging {
import Ariyala._
private val settings = context.system.settings.config
private val ariyalaUrl = settings.getString("me.arcanis.ffxivbis.ariyala.ariyala-url")
private val xivapiUrl = settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-url")
private val xivapiKey = Try(settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-key")).toOption
private val http = Http()(context.system)
implicit private val materializer: ActorMaterializer = ActorMaterializer()
implicit private val executionContext: ExecutionContext = context.dispatcher
override def receive: Receive = {
case GetBiS(link, job) =>
val client = sender()
get(link, job).map(BiS(_)).pipeTo(client)
}
private def get(link: String, job: Job.Job): Future[Seq[Piece]] = {
val id = Paths.get(link).normalize.getFileName.toString
val uri = Uri(ariyalaUrl)
.withPath(Uri.Path / "store.app")
.withQuery(Uri.Query(Map("identifier" -> id)))
sendRequest(uri, Ariyala.parseAriyalaJsonToPieces(job, getIsTome))
}
private def getIsTome(itemIds: Seq[Long]): Future[Map[Long, Boolean]] = {
val uri = Uri(xivapiUrl)
.withPath(Uri.Path / "item")
.withQuery(Uri.Query(Map(
"columns" -> Seq("ID", "IsEquippable").mkString(","),
"ids" -> itemIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
)))
sendRequest(uri, Ariyala.parseXivapiJson)
}
private def sendRequest[T](uri: Uri, parser: JsObject => Future[T]): Future[T] =
http.singleRequest(HttpRequest(uri = uri)).map {
case HttpResponse(status, _, entity, _) if status.isSuccess() =>
entity.dataBytes
.fold(ByteString.empty)(_ ++ _)
.map(_.utf8String)
.map(result => parser(result.parseJson.asJsObject))
.toMat(Sink.head)(Keep.right)
.run().flatten
case other => Future.failed(new Error(s"Invalid response from server $other"))
}.flatten
}
object Ariyala {
def props: Props = Props(new Ariyala)
case class GetBiS(link: String, job: Job.Job)
private def parseAriyalaJson(job: Job.Job)(js: JsObject)
(implicit executionContext: ExecutionContext): Future[Map[String, Long]] =
Future {
val apiJob = js.fields.get("content") match {
case Some(JsString(value)) => value
case other => throw deserializationError(s"Invalid job name $other")
}
js.fields.get("datasets") match {
case Some(datasets: JsObject) =>
val fields = datasets.fields
fields.getOrElse(apiJob, fields(job.toString)).asJsObject
.fields("normal").asJsObject
.fields("items").asJsObject
.fields.foldLeft(Map.empty[String, Long]) {
case (acc, (key, JsNumber(id))) => remapKey(key).map(k => acc + (k -> id.toLong)).getOrElse(acc)
case (acc, _) => acc
}
case other => throw deserializationError(s"Invalid json $other")
}
}
private def parseAriyalaJsonToPieces(job: Job.Job, isTome: Seq[Long] => Future[Map[Long, Boolean]])(js: JsObject)
(implicit executionContext: ExecutionContext): Future[Seq[Piece]] =
parseAriyalaJson(job)(js).flatMap { pieces =>
isTome(pieces.values.toSeq).map { tomePieces =>
pieces.view.mapValues(tomePieces).map {
case (piece, isTomePiece) => Piece(piece, isTomePiece, job)
}.toSeq
}
}
private def parseXivapiJson(js: JsObject)
(implicit executionContext: ExecutionContext): Future[Map[Long, Boolean]] =
Future {
js.fields("Results") match {
case array: JsArray =>
array.elements.map(_.asJsObject.getFields("ID", "IsEquippable") match {
case Seq(JsNumber(id), JsNumber(isTome)) => id.toLong -> (isTome == 0)
case other => throw deserializationError(s"Could not parse $other")
}).toMap
case other => throw deserializationError(s"Could not parse $other")
}
}
private def remapKey(key: String): Option[String] = key match {
case "mainhand" => Some("weapon")
case "chest" => Some("body")
case "ringLeft" => Some("leftRing")
case "ringRight" => Some("rightRing")
case "head" | "hands" | "waist" | "legs" | "feet" | "ears" | "neck" | "wrist" => Some(key)
case _ => None
}
}

View File

@ -21,6 +21,7 @@ class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) {
} }
object LootSelector { object LootSelector {
def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult = def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult =
new LootSelector(players, piece, orderBy).suggest new LootSelector(players, piece, orderBy).suggest

View File

@ -8,56 +8,66 @@
*/ */
package me.arcanis.ffxivbis.service package me.arcanis.ffxivbis.service
import akka.actor.{Actor, ActorRef, Props} import akka.actor.typed.scaladsl.AskPattern.Askable
import akka.pattern.{ask, pipe} import akka.actor.typed.{ActorRef, Behavior, DispatcherSelector, Scheduler}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.scalalogging.StrictLogging import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages.{DatabaseMessage, Exists, ForgetParty, GetNewPartyId, GetParty, Message, StoreParty}
import me.arcanis.ffxivbis.models.Party import me.arcanis.ffxivbis.models.Party
import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class PartyService(storage: ActorRef) extends Actor with StrictLogging { class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMessage])
import PartyService._ extends AbstractBehavior[Message](context)
with StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._ import me.arcanis.ffxivbis.utils.Implicits._
private val cacheTimeout: FiniteDuration = private val cacheTimeout: FiniteDuration =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.cache-timeout") context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.cache-timeout")
implicit private val executionContext: ExecutionContext = context.dispatcher implicit private val executionContext: ExecutionContext = {
val selector = DispatcherSelector.fromConfig("me.arcanis.ffxivbis.default-dispatcher")
context.system.dispatchers.lookup(selector)
}
implicit private val timeout: Timeout = implicit private val timeout: Timeout =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.request-timeout")
implicit private val scheduler: Scheduler = context.system.scheduler
override def receive: Receive = handle(Map.empty) override def onMessage(msg: Message): Behavior[Message] = handle(Map.empty)(msg)
private def handle(cache: Map[String, Party]): Receive = { private def handle(cache: Map[String, Party]): Message.Handler = {
case ForgetParty(partyId) => case ForgetParty(partyId) =>
context become handle(cache - partyId) Behaviors.receiveMessage(handle(cache - partyId))
case GetNewPartyId => case GetNewPartyId(client) =>
val client = sender() getPartyId.foreach(client ! _)
getPartyId.pipeTo(client) Behaviors.same
case req @ impl.DatabasePartyHandler.GetParty(partyId) => case StoreParty(partyId, party) =>
val client = sender() Behaviors.receiveMessage(handle(cache.updated(partyId, party)))
case GetParty(partyId, client) =>
val party = cache.get(partyId) match { val party = cache.get(partyId) match {
case Some(party) => Future.successful(party) case Some(party) => Future.successful(party)
case None => case None =>
(storage ? req).mapTo[Party].map { party => storage.ask(ref => GetParty(partyId, ref)).map { party =>
context become handle(cache + (partyId -> party)) context.self ! StoreParty(partyId, party)
context.system.scheduler.scheduleOnce(cacheTimeout, self, ForgetParty(partyId)) context.system.scheduler.scheduleOnce(cacheTimeout, () => context.self ! ForgetParty(partyId))
party party
} }
} }
party.pipeTo(client) party.foreach(client ! _)
Behaviors.same
case req: Database.DatabaseRequest => case req: DatabaseMessage =>
self ! ForgetParty(req.partyId) storage ! req
storage.forward(req) Behaviors.receiveMessage(handle(cache - req.partyId))
} }
private def getPartyId: Future[String] = { private def getPartyId: Future[String] = {
val partyId = Party.randomPartyId val partyId = Party.randomPartyId
(storage ? impl.DatabaseUserHandler.Exists(partyId)).mapTo[Boolean].flatMap { storage.ask(ref => Exists(partyId, ref)).flatMap {
case true => getPartyId case true => getPartyId
case false => Future.successful(partyId) case false => Future.successful(partyId)
} }
@ -65,8 +75,7 @@ class PartyService(storage: ActorRef) extends Actor with StrictLogging {
} }
object PartyService { object PartyService {
def props(storage: ActorRef): Props = Props(new PartyService(storage))
case class ForgetParty(partyId: String) def apply(storage: ActorRef[DatabaseMessage]): Behavior[Message] =
case object GetNewPartyId Behaviors.setup[Message](context => new PartyService(context, storage))
} }

View File

@ -0,0 +1,91 @@
/*
* 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.bis
import java.nio.file.Paths
import akka.actor.ClassicActorSystemProvider
import akka.actor.typed.{Behavior, PostStop, Signal}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext, Behaviors}
import akka.http.scaladsl.model._
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages.{BiSProviderMessage, DownloadBiS}
import me.arcanis.ffxivbis.models.{BiS, Job, Piece, PieceType}
import me.arcanis.ffxivbis.service.bis.parser.Parser
import me.arcanis.ffxivbis.service.bis.parser.impl.{Ariyala, Etro}
import spray.json._
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
class BisProvider(context: ActorContext[BiSProviderMessage])
extends AbstractBehavior[BiSProviderMessage](context)
with XivApi
with StrictLogging {
override def system: ClassicActorSystemProvider = context.system
override def onMessage(msg: BiSProviderMessage): Behavior[BiSProviderMessage] =
msg match {
case DownloadBiS(link, job, client) =>
get(link, job).onComplete {
case Success(items) => client ! BiS(items)
case Failure(exception) =>
logger.error("received exception while getting items", exception)
}
Behaviors.same
}
override def onSignal: PartialFunction[Signal, Behavior[BiSProviderMessage]] = { case PostStop =>
shutdown()
Behaviors.same
}
private def get(link: String, job: Job.Job): Future[Seq[Piece]] =
try {
val url = Uri(link)
val id = Paths.get(link).normalize.getFileName.toString
val parser = if (url.authority.host.address().contains("etro")) Etro else Ariyala
val uri = parser.uri(url, id)
sendRequest(uri, BisProvider.parseBisJsonToPieces(job, parser, getPieceType))
} catch {
case exception: Exception => Future.failed(exception)
}
}
object BisProvider {
def apply(): Behavior[BiSProviderMessage] =
Behaviors.setup[BiSProviderMessage](context => new BisProvider(context))
private def parseBisJsonToPieces(
job: Job.Job,
idParser: Parser,
pieceTypes: Seq[Long] => Future[Map[Long, PieceType.PieceType]]
)(js: JsObject)(implicit executionContext: ExecutionContext): Future[Seq[Piece]] =
idParser.parse(job, js).flatMap { pieces =>
pieceTypes(pieces.values.toSeq).map { types =>
pieces.view
.mapValues(types)
.map { case (piece, pieceType) =>
Piece(piece, pieceType, job)
}
.toSeq
}
}
def remapKey(key: String): Option[String] = key match {
case "mainhand" => Some("weapon")
case "chest" => Some("body")
case "ringLeft" | "fingerL" => Some("left ring")
case "ringRight" | "fingerR" => Some("right ring")
case "weapon" | "head" | "body" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => Some(key)
case _ => None
}
}

View File

@ -0,0 +1,51 @@
/*
* 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.bis
import akka.actor.ClassicActorSystemProvider
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.headers.Location
import akka.http.scaladsl.model.{HttpRequest, HttpResponse, Uri}
import akka.stream.Materializer
import akka.stream.scaladsl.{Keep, Sink}
import akka.util.ByteString
import spray.json._
import scala.concurrent.{ExecutionContext, Future}
trait RequestExecutor {
def system: ClassicActorSystemProvider
private val http = Http()(system)
implicit val materializer: Materializer = Materializer.createMaterializer(system)
implicit val executionContext: ExecutionContext =
system.classicSystem.dispatchers.lookup("me.arcanis.ffxivbis.default-dispatcher")
def sendRequest[T](uri: Uri, parser: JsObject => Future[T]): Future[T] =
http
.singleRequest(HttpRequest(uri = uri))
.map {
case r: HttpResponse if r.status.isRedirection() =>
val location = r.header[Location].get.uri
sendRequest(uri.withPath(location.path), parser)
case HttpResponse(status, _, entity, _) if status.isSuccess() =>
entity.dataBytes
.fold(ByteString.empty)(_ ++ _)
.map(_.utf8String)
.map(result => parser(result.parseJson.asJsObject))
.toMat(Sink.head)(Keep.right)
.run()
.flatten
case other => Future.failed(new Error(s"Invalid response from server $other"))
}
.flatten
def shutdown(): Unit = http.shutdownAllConnectionPools()
}

View File

@ -0,0 +1,142 @@
/*
* 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.bis
import akka.http.scaladsl.model.Uri
import me.arcanis.ffxivbis.models.PieceType
import spray.json._
import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters._
import scala.util.Try
trait XivApi extends RequestExecutor {
private val config = system.classicSystem.settings.config
private val xivapiUrl = config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-url")
private val xivapiKey = Try(config.getString("me.arcanis.ffxivbis.bis-provider.xivapi-key")).toOption
private val preloadedItems: Map[Long, PieceType.PieceType] =
config
.getConfigList("me.arcanis.ffxivbis.bis-provider.cached-items")
.asScala
.map { item =>
item.getLong("id") -> PieceType.withName(item.getString("source"))
}
.toMap
def getPieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType.PieceType]] = {
val (local, remote) = itemIds.foldLeft((Map.empty[Long, PieceType.PieceType], Seq.empty[Long])) {
case ((l, r), id) =>
if (preloadedItems.contains(id)) (l.updated(id, preloadedItems(id)), r)
else (l, r :+ id)
}
if (remote.isEmpty) Future.successful(local)
else remotePieceType(remote).map(_ ++ local)
}
private def remotePieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType.PieceType]] = {
val uriForItems = Uri(xivapiUrl)
.withPath(Uri.Path / "item")
.withQuery(
Uri.Query(
Map(
"columns" -> Seq("ID", "GameContentLinks").mkString(","),
"ids" -> itemIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
)
)
)
sendRequest(uriForItems, XivApi.parseXivapiJsonToShop).flatMap { shops =>
val shopIds = shops.values.map(_._2).toSet
val columns = shops.values.map(pair => s"ItemCost${pair._1}").toSet
val uriForShops = Uri(xivapiUrl)
.withPath(Uri.Path / "specialshop")
.withQuery(
Uri.Query(
Map(
"columns" -> (columns + "ID").mkString(","),
"ids" -> shopIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
)
)
)
sendRequest(uriForShops, XivApi.parseXivapiJsonToType(shops))
}
}
}
object XivApi {
private def parseXivapiJsonToShop(
js: JsObject
)(implicit executionContext: ExecutionContext): Future[Map[Long, (String, Long)]] = {
def extractTraderId(js: JsObject) =
js.fields
.get("Recipe")
.map(_ => "crafted" -> -1L) // you can craft this item
.orElse { // lets try shop items
js.fields("SpecialShop").asJsObject.fields.collectFirst {
case (shopName, JsArray(array)) if shopName.startsWith("ItemReceive") =>
val shopId = array.head match {
case JsNumber(id) => id.toLong
case other => throw deserializationError(s"Could not parse $other")
}
shopName.replace("ItemReceive", "") -> shopId
}
}
.getOrElse(throw deserializationError(s"Could not parse $js"))
Future {
js.fields("Results") match {
case array: JsArray =>
array.elements
.map(_.asJsObject.getFields("ID", "GameContentLinks") match {
case Seq(JsNumber(id), shop: JsObject) => id.toLong -> extractTraderId(shop.asJsObject)
case other => throw deserializationError(s"Could not parse $other")
})
.toMap
case other => throw deserializationError(s"Could not parse $other")
}
}
}
private def parseXivapiJsonToType(
shops: Map[Long, (String, Long)]
)(js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[Long, PieceType.PieceType]] =
Future {
val shopMap = js.fields("Results") match {
case array: JsArray =>
array.elements.collect { case shop: JsObject =>
shop.fields("ID") match {
case JsNumber(id) => id.toLong -> shop
case other => throw deserializationError(s"Could not parse $other")
}
}.toMap
case other => throw deserializationError(s"Could not parse $other")
}
shops.map { case (itemId, (index, shopId)) =>
val pieceType =
if (index == "crafted" && shopId == -1L) PieceType.Crafted
else
Try(shopMap(shopId).fields(s"ItemCost$index").asJsObject)
.getOrElse(throw new Exception(s"${shopMap(shopId).fields(s"ItemCost$index")}, $index"))
.getFields("IsUnique", "StackSize") match {
case Seq(JsNumber(isUnique), JsNumber(stackSize)) =>
if (isUnique == 1 || stackSize.toLong != 999) PieceType.Tome // either upgraded gear or tomes found
else PieceType.Savage
case other => throw deserializationError(s"Could not parse $other")
}
itemId -> pieceType
}
}
}

View File

@ -0,0 +1,15 @@
package me.arcanis.ffxivbis.service.bis.parser
import akka.http.scaladsl.model.Uri
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.Job
import spray.json.JsObject
import scala.concurrent.{ExecutionContext, Future}
trait Parser extends StrictLogging {
def parse(job: Job.Job, js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[String, Long]]
def uri(root: Uri, id: String): Uri
}

View File

@ -0,0 +1,45 @@
package me.arcanis.ffxivbis.service.bis.parser.impl
import akka.http.scaladsl.model.Uri
import me.arcanis.ffxivbis.models.Job
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.bis.parser.Parser
import spray.json.{deserializationError, JsNumber, JsObject, JsString}
import scala.concurrent.{ExecutionContext, Future}
object Ariyala extends Parser {
override def parse(job: Job.Job, js: JsObject)(implicit
executionContext: ExecutionContext
): Future[Map[String, Long]] =
Future {
val apiJob = js.fields.get("content") match {
case Some(JsString(value)) => value
case other => throw deserializationError(s"Invalid job name $other")
}
js.fields.get("datasets") match {
case Some(datasets: JsObject) =>
val fields = datasets.fields
fields
.getOrElse(apiJob, fields(job.toString))
.asJsObject
.fields("normal")
.asJsObject
.fields("items")
.asJsObject
.fields
.foldLeft(Map.empty[String, Long]) {
case (acc, (key, JsNumber(id))) =>
BisProvider.remapKey(key).map(k => acc + (k -> id.toLong)).getOrElse(acc)
case (acc, _) => acc
}
case other => throw deserializationError(s"Invalid json $other")
}
}
override def uri(root: Uri, id: String): Uri =
root
.withPath(Uri.Path / "store.app")
.withQuery(Uri.Query(Map("identifier" -> id)))
}

View File

@ -0,0 +1,25 @@
package me.arcanis.ffxivbis.service.bis.parser.impl
import akka.http.scaladsl.model.Uri
import me.arcanis.ffxivbis.models.Job
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.bis.parser.Parser
import spray.json.{JsNumber, JsObject}
import scala.concurrent.{ExecutionContext, Future}
object Etro extends Parser {
override def parse(job: Job.Job, js: JsObject)(implicit
executionContext: ExecutionContext
): Future[Map[String, Long]] =
Future {
js.fields.foldLeft(Map.empty[String, Long]) {
case (acc, (key, JsNumber(id))) => BisProvider.remapKey(key).map(k => acc + (k -> id.toLong)).getOrElse(acc)
case (acc, _) => acc
}
}
override def uri(root: Uri, id: String): Uri =
root.withPath(Uri.Path / "api" / "gearsets" / id)
}

View File

@ -6,40 +6,42 @@
* *
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/ */
package me.arcanis.ffxivbis.service package me.arcanis.ffxivbis.service.database
import akka.actor.Actor import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors
import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages.DatabaseMessage
import me.arcanis.ffxivbis.models.{Party, Player, PlayerId} import me.arcanis.ffxivbis.models.{Party, Player, PlayerId}
import me.arcanis.ffxivbis.service.database.impl.DatabaseImpl
import me.arcanis.ffxivbis.storage.DatabaseProfile import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
trait Database extends Actor with StrictLogging { trait Database extends StrictLogging {
implicit def executionContext: ExecutionContext implicit def executionContext: ExecutionContext
def config: Config
def profile: DatabaseProfile def profile: DatabaseProfile
override def postStop(): Unit = {
profile.db.close()
super.postStop()
}
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] = def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
(party, maybePlayerId) match { maybePlayerId match {
case (_, Some(playerId)) => party.player(playerId).map(Seq(_)).getOrElse(Seq.empty) case Some(playerId) => party.player(playerId).map(Seq(_)).getOrElse(Seq.empty)
case (_, _) => party.getPlayers case _ => party.getPlayers
} }
def getParty(partyId: String, withBiS: Boolean, withLoot: Boolean): Future[Party] = def getParty(partyId: String, withBiS: Boolean, withLoot: Boolean): Future[Party] =
for { for {
partyDescription <- profile.getPartyDescription(partyId)
players <- profile.getParty(partyId) players <- profile.getParty(partyId)
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty) bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty)
loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty) loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty)
} yield Party(partyId, context.system.settings.config, players, bis, loot) } yield Party(partyDescription, config, players, bis, loot)
} }
object Database { object Database {
trait DatabaseRequest {
def partyId: String def apply(): Behavior[DatabaseMessage] =
} Behaviors.setup[DatabaseMessage](context => new DatabaseImpl(context))
} }

View File

@ -0,0 +1,36 @@
/*
* 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.database.impl
import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages.{AddPieceToBis, DatabaseMessage, GetBiS, RemovePieceFromBiS, RemovePiecesFromBiS}
import me.arcanis.ffxivbis.service.database.Database
trait DatabaseBiSHandler { this: Database =>
def bisHandler: DatabaseMessage.Handler = {
case AddPieceToBis(playerId, piece, client) =>
profile.insertPieceBiS(playerId, piece).foreach(_ => client ! ())
Behaviors.same
case GetBiS(partyId, maybePlayerId, client) =>
getParty(partyId, withBiS = true, withLoot = false)
.map(filterParty(_, maybePlayerId))
.foreach(client ! _)
Behaviors.same
case RemovePieceFromBiS(playerId, piece, client) =>
profile.deletePieceBiS(playerId, piece).foreach(_ => client ! ())
Behaviors.same
case RemovePiecesFromBiS(playerId, client) =>
profile.deletePiecesBiS(playerId).foreach(_ => client ! ())
Behaviors.same
}
}

View File

@ -0,0 +1,39 @@
/*
* 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.database.impl
import akka.actor.typed.{Behavior, DispatcherSelector}
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext}
import com.typesafe.config.Config
import me.arcanis.ffxivbis.messages.DatabaseMessage
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.ExecutionContext
class DatabaseImpl(context: ActorContext[DatabaseMessage])
extends AbstractBehavior[DatabaseMessage](context)
with Database
with DatabaseBiSHandler
with DatabaseLootHandler
with DatabasePartyHandler
with DatabaseUserHandler {
implicit override val executionContext: ExecutionContext = {
val selector = DispatcherSelector.fromConfig("me.arcanis.ffxivbis.default-dispatcher")
context.system.dispatchers.lookup(selector)
}
override val config: Config = context.system.settings.config
override val profile: DatabaseProfile = new DatabaseProfile(executionContext, config)
override def onMessage(msg: DatabaseMessage): Behavior[DatabaseMessage] = handle(msg)
private def handle: DatabaseMessage.Handler =
bisHandler.orElse(lootHandler).orElse(partyHandler).orElse(userHandler)
}

View File

@ -0,0 +1,41 @@
/*
* 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.database.impl
import java.time.Instant
import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages.{AddPieceTo, DatabaseMessage, GetLoot, RemovePieceFrom, SuggestLoot}
import me.arcanis.ffxivbis.models.Loot
import me.arcanis.ffxivbis.service.database.Database
trait DatabaseLootHandler { this: Database =>
def lootHandler: DatabaseMessage.Handler = {
case AddPieceTo(playerId, piece, isFreeLoot, client) =>
val loot = Loot(-1, piece, Instant.now, isFreeLoot)
profile.insertPiece(playerId, loot).foreach(_ => client ! ())
Behaviors.same
case GetLoot(partyId, maybePlayerId, client) =>
getParty(partyId, withBiS = false, withLoot = true)
.map(filterParty(_, maybePlayerId))
.foreach(client ! _)
Behaviors.same
case RemovePieceFrom(playerId, piece, client) =>
profile.deletePiece(playerId, piece).foreach(_ => client ! ())
Behaviors.same
case SuggestLoot(partyId, piece, client) =>
getParty(partyId, withBiS = true, withLoot = true)
.map(_.suggestLoot(piece))
.foreach(client ! _)
Behaviors.same
}
}

View File

@ -0,0 +1,65 @@
/*
* 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.database.impl
import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages.{AddPlayer, DatabaseMessage, GetParty, GetPartyDescription, GetPlayer, RemovePlayer, UpdateParty}
import me.arcanis.ffxivbis.models.{BiS, Player}
import me.arcanis.ffxivbis.service.database.Database
import scala.concurrent.Future
trait DatabasePartyHandler { this: Database =>
def partyHandler: DatabaseMessage.Handler = {
case AddPlayer(player, client) =>
profile.insertPlayer(player).foreach(_ => client ! ())
Behaviors.same
case GetParty(partyId, client) =>
getParty(partyId, withBiS = true, withLoot = true).foreach(client ! _)
Behaviors.same
case GetPartyDescription(partyId, client) =>
profile.getPartyDescription(partyId).foreach(client ! _)
Behaviors.same
case GetPlayer(playerId, client) =>
val player = profile
.getPlayerFull(playerId)
.flatMap { maybePlayerData =>
Future.traverse(maybePlayerData.toSeq) { playerData =>
for {
bis <- profile.getPiecesBiS(playerId)
loot <- profile.getPieces(playerId)
} yield Player(
playerData.id,
playerId.partyId,
playerId.job,
playerId.nick,
BiS(bis.map(_.piece)),
loot,
playerData.link,
playerData.priority
)
}
}
.map(_.headOption)
player.foreach(client ! _)
Behaviors.same
case RemovePlayer(playerId, client) =>
profile.deletePlayer(playerId).foreach(_ => client ! ())
Behaviors.same
case UpdateParty(description, client) =>
profile.insertPartyDescription(description).foreach(_ => client ! ())
Behaviors.same
}
}

Some files were not shown because too many files have changed in this diff Show More