18 Commits
0.1.1 ... 0.9.0

Author SHA1 Message Date
0bf1edfff8 add missing license headers 2019-11-02 14:52:01 +03:00
50acecd97e party cache impl, fix reject handling, correct party creation api 2019-11-02 14:41:36 +03:00
e03f8987b0 simple http log 2019-11-01 01:23:24 +03:00
2ad3600da5 rejection handling 2019-10-31 22:23:03 +03:00
a84b947862 improve api 2019-10-29 01:59:35 +03:00
f84b9cbaba moar tests 2019-10-27 23:58:43 +03:00
9f12647fed fix travis build 2019-10-27 05:48:51 +03:00
2a1eb9430e even more tests 2019-10-27 04:23:06 +03:00
4cdcd80d51 more tests 2019-10-26 19:44:51 +03:00
b228595a1b some tests 2019-10-24 08:20:03 +03:00
d1001ffb8e license headers 2019-10-17 22:58:26 +03:00
09c7efec62 full view impl 2019-10-17 21:03:30 +03:00
9668a0edd1 base views 2019-10-17 20:08:28 +03:00
d5233361e5 add some views 2019-10-17 02:01:04 +03:00
eea2f1b04b add bisview 2019-10-17 01:21:55 +03:00
49fd33fffc Scala (#3)
* initial migration to scala
2019-10-16 03:06:58 +03:00
2d84459c4d replace requests by aiohttp 2019-09-15 03:27:29 +03:00
28dabcb44e rename service to ffxivbis, add notes about venv 2019-09-15 02:57:53 +03:00
191 changed files with 5438 additions and 4988 deletions

162
.gitignore vendored
View File

@ -1,96 +1,88 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
#### joe made this: http://goel.io/joe
# C extensions
*.so
#### jetbrains ####
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
parts/
sdist/
*.egg-info/
.installed.cfg
*.egg
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# User-specific stuff:
.idea
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
## File-based project format:
*.iws
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
## Plugin-specific files:
# IntelliJ
/out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
#### gradle ####
.gradle
/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Cache of project
.gradletasknamecache
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
# gradle/wrapper/gradle-wrapper.properties
#### java ####
*.class
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.ear
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
#### scala ####
*.class
*.log
# sbt specific
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log*
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.history
.lib/
dist/*
target/
lib_managed/
src_managed/
project/boot/
project/plugins/project/
# IPython Notebook
.ipynb_checkpoints
# Scala-IDE specific
.scala_dependencies
.worksheet
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
*.deb
.idea/
.mypy_cache/
/cache
# ENSIME specific
.ensime_cache/
.ensime
*.db
*.sc

9
.travis.yml Normal file
View File

@ -0,0 +1,9 @@
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

@ -4,79 +4,20 @@ Service which allows to manage savage loot distribution easy.
## Installation and usage
This service requires python >= 3.7. For other dependencies see `setup.py`.
In general installation process looks like:
```bash
python setup.py build
python setup.py test # if you want to run tests
sbt assembly
```
Service can be run from `src` directory by using command:
Service can be run by using command:
```bash
python -m service.application.application
java -cp ./target/scala-2.13/ffxivbis-scala-assembly-0.1.jar me.arcanis.ffxivbis.ffxivbis
```
To see all available options type `--help`.
## Web service
REST API documentation is available at `http://0.0.0.0:8000/api-docs`. 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.
## Configuration
* `settings` section
General project settings.
* `include`: path to include configuration directory, string, optional.
* `logging`: path to logging configuration, see `logging.ini` for reference, string, optional.
* `database`: database provide name, string, required. Allowed values: `sqlite`, `postgres`.
* `priority`: methods of `Player` class which will be called to sort players for loot priority, space separated list of strings, required.
* `ariyala` section
Settings related to ariyala parser.
* `ariyala_url`: ariyala base url, string, required.
* `request_timeout`: xivapi request timeout, float, optional, default 30.
* `xivapi_key`: xivapi developer key, string, optional.
* `xivapi_url`: xivapi base url, string, required.
* `auth` section
Authentication settings.
* `enabled`: whether authentication enabled or not, boolean, required.
* `root_username`: username of administrator, string, required.
* `root_password`: md5 hashed password of administrator, string, required.
* `postgres` section
Database settings for `postgres` provider.
* `database`: database name, string, required.
* `host`: database host, string, required.
* `password`: database password, string, required.
* `port`: database port, int, required.
* `username`: database username, string, required.
* `migrations_path`: path to database migrations, string, required.
* `sqlite` section
Database settings for `sqlite` provider.
* `database_path`: path to sqlite database, string, required.
* `migrations_path`: path to database migrations, string, required.
* `web` section
Web server related settings.
* `host`: address to bind, string, required.
* `port`: port to bind, int, required.
* `templates`: path to directory with jinja templates, string, required.

View File

@ -1,3 +1,3 @@
* [ ] items improvements
* [ ] multiple parties support
* [x] items improvements
* [x] multiple parties support
* [ ] pretty UI

5
build.sbt Normal file
View File

@ -0,0 +1,5 @@
name := "ffxivbis"
scalaVersion := "2.13.1"
scalacOptions ++= Seq("-deprecation", "-feature")

19
libraries.sbt Normal file
View File

@ -0,0 +1,19 @@
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.3"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.1.10"
libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.10"
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.lihaoyi" %% "scalatags" % "0.7.0"
libraryDependencies += "com.typesafe.slick" %% "slick" % "3.3.2"
libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % "3.3.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"

View File

@ -1,38 +0,0 @@
'''
init tables
'''
from yoyo import step
__depends__ = {}
steps = [
step('''create table players (
player_id integer primary key,
created integer not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1
)'''),
step('''create unique index players_nick_job_idx on players(nick, job)'''),
step('''create table loot (
loot_id integer primary key,
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players(player_id) on delete cascade
)'''),
step('''create index loot_owner_idx on loot(player_id)'''),
step('''create table bis (
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players(player_id) on delete cascade
)'''),
step('''create unique index bis_piece_player_id_idx on bis(player_id, piece)''')
]

View File

@ -1,17 +0,0 @@
'''
users table
'''
from yoyo import step
__depends__ = {}
steps = [
step('''create table users (
user_id integer primary key,
username text not null,
password text not null,
permission text not null
)'''),
step('''create unique index users_username_idx on users(username)''')
]

View File

@ -1,10 +0,0 @@
[settings]
include = ffxivbis.ini.d
logging = ffxivbis.ini.d/logging.ini
database = sqlite
priority = is_required loot_count_bis loot_priority loot_count loot_count_total
[web]
host = 0.0.0.0
port = 8000
templates = templates

View File

@ -1,4 +0,0 @@
[ariyala]
ariyala_url = https://ffxiv.ariyala.com
request_timeout = 1
xivapi_url = https://xivapi.com

View File

@ -1,4 +0,0 @@
[auth]
enabled = yes
root_username = admin
root_password = $1$R3j4sym6$HtvrKOJ66f7w3.9Zc3U6h1

View File

@ -1,44 +0,0 @@
[loggers]
keys = root,application,database,http
[handlers]
keys = file_handler
[formatters]
keys = generic_format
[handler_console_handler]
class = StreamHandler
level = INFO
formatter = generic_format
args = (sys.stdout,)
[handler_file_handler]
class = logging.handlers.RotatingFileHandler
level = INFO
formatter = generic_format
args = ('ffxivbis.log', 'a', 20971520, 20)
[formatter_generic_format]
format = [%(levelname)s] [%(asctime)s] [%(threadName)s] [%(name)s] [%(funcName)s]: %(message)s
datefmt =
[logger_root]
level = INFO
handlers = file_handler
qualname = root
[logger_application]
level = INFO
handlers = file_handler
qualname = application
[logger_database]
level = INFO
handlers = file_handler
qualname = database
[logger_http]
level = INFO
handlers = file_handler
qualname = http

View File

@ -1,3 +0,0 @@
[sqlite]
database_path = /home/arcanis/Documents/github/ffxivbis/ffxivbis.db
migrations_path = /home/arcanis/Documents/github/ffxivbis/migrations

1
project/assembly.sbt Normal file
View File

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

1
project/build.properties Normal file
View File

@ -0,0 +1 @@
sbt.version = 1.3.3

View File

@ -1,5 +0,0 @@
[aliases]
test=pytest
[tool:pytest]
addopts = --verbose --pyargs .

View File

@ -1,51 +0,0 @@
from distutils.util import convert_path
from setuptools import setup, find_packages
from os import path
here = path.abspath(path.dirname(__file__))
metadata = dict()
with open(convert_path('src/service/core/version.py')) as metadata_file:
exec(metadata_file.read(), metadata)
setup(
name='ffxivbis',
version=metadata['__version__'],
zip_safe=False,
description='Helper to handle loot drop',
author='Evgeniy Alekseev',
author_email='i@arcanis.me',
license='BSD',
packages=find_packages(exclude=['contrib', 'docs', 'tests']),
install_requires=[
'aiohttp',
'aiohttp_jinja2',
'aiohttp_security',
'apispec',
'Jinja2',
'passlib',
'requests',
'yoyo_migrations'
],
setup_requires=[
'pytest-runner'
],
tests_require=[
'pytest', 'pytest-aiohttp', 'pytest-asyncio'
],
include_package_data=True,
extras_require={
'Postgresql': ['aiopg'],
'SQLite': ['aiosqlite'],
'test': ['coverage', 'pytest'],
},
)

View File

@ -0,0 +1,36 @@
create table players (
party_id text not null,
player_id bigserial,
created bigint not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1);
create unique index players_nick_job_idx on players(party_id, nick, job);
create table loot (
loot_id bigserial,
player_id bigint not null,
created bigint not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create index loot_owner_idx on loot(player_id);
create table bis (
player_id bigint not null,
created bigint not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create unique index bis_piece_player_id_idx on bis(player_id, piece);
create table users (
party_id text not null,
user_id bigserial,
username text not null,
password text not null,
permission text not null);
create unique index users_username_idx on users(party_id, username);

View File

@ -0,0 +1,36 @@
create table players (
party_id text not null,
player_id integer primary key autoincrement,
created integer not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1);
create unique index players_nick_job_idx on players(party_id, nick, job);
create table loot (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create index loot_owner_idx on loot(player_id);
create table bis (
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create unique index bis_piece_player_id_idx on bis(player_id, piece);
create table users (
party_id text not null,
user_id integer primary key autoincrement,
username text not null,
password text not null,
permission text not null);
create unique index users_username_idx on users(party_id, username);

View File

@ -0,0 +1,27 @@
<included>
<appender name="application-base" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>[%-5level %d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%logger{50}]: %msg%n</pattern>
</encoder>
<file>application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<minIndex>1</minIndex>
<maxIndex>20</maxIndex>
<fileNamePattern>application.log.%i.gz</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>100MB</maxFileSize>
</triggeringPolicy>
</appender>
<appender name="application" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="application-base"/>
<queueSize>50000</queueSize>
<neverBlock>true</neverBlock>
</appender>
</included>

View File

@ -0,0 +1,27 @@
<included>
<appender name="http-base" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>%msg%n</pattern>
</encoder>
<file>http.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<minIndex>1</minIndex>
<maxIndex>20</maxIndex>
<fileNamePattern>http.log.%i.gz</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>100MB</maxFileSize>
</triggeringPolicy>
</appender>
<appender name="http" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="http-base"/>
<queueSize>50000</queueSize>
<neverBlock>true</neverBlock>
</appender>
</included>

View File

@ -0,0 +1,16 @@
<configuration>
<include resource="logback-application.xml" />
<include resource="logback-http.xml" />
<root level="debug">
<appender-ref ref="application" />
</root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" level="DEBUG">
<appender-ref ref="http" />
</logger>
<logger name="slick" level="INFO" />
</configuration>

View File

@ -0,0 +1,55 @@
me.arcanis.ffxivbis {
ariyala {
# ariyala base url, string, required
ariyala-url = "https://ffxiv.ariyala.com"
# xivapi base url, string, required
xivapi-url = "https://xivapi.com"
# xivapi developer key, string, optional
# xivapi-key = "abcdef"
}
database {
# database section. Section must be declared inside
# for more detailed section descriptions refer to slick documentation
mode = "sqlite"
sqlite {
profile = "slick.jdbc.SQLiteProfile$"
db {
url = "jdbc:sqlite:ffxivbis.db"
user = "user"
password = "password"
}
numThreads = 10
}
postgresql {
profile = "slick.jdbc.PostgresProfile$"
db {
url = "jdbc:postgresql://localhost/ffxivbis"
user = "user"
password = "password"
}
numThreads = 10
}
}
settings {
# counters of Player class which will be called to sort players for loot priority
# list of strings, required
priority = [
"isRequired", "lootCountBiS", "priority", "lootCount", "lootCountTotal"
]
# general request timeout, duratin, required
request-timeout = 10s
# party in-memory storage lifetime
cache-timeout = 1m
}
web {
# address to bind, string, required
host = "0.0.0.0"
# port to bind, int, required
port = 8000
}
}

View File

@ -0,0 +1,277 @@
/* in-text images */
figure.img {
float: right;
border: 0px solid #333;
padding: 0px;
margin: 5px 0px 5px 10px;
}
figure.img img {
max-width: 100%;
height: auto;
}
figure.img figcaption {
margin: 0px;
font-size: 90%;
font-style: italic;
text-align: center;
}
h1 .octicon-link, h2 .octicon-link, h3 .octicon-link, h4 .octicon-link, h5 .octicon-link, h6 .octicon-link {
display: none;
color: #222222;
vertical-align: middle;
}
h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor{
padding-left: 8px;
margin-left: -24px;
text-decoration: none;
}
h1:hover .anchor .octicon-link, h2:hover .anchor .octicon-link, h3:hover .anchor .octicon-link, h4:hover .anchor .octicon-link, h5:hover .anchor .octicon-link, h6:hover .anchor .octicon-link {
display: inline-block;
}
body {
padding: 50px;
font: 14px/1.5 "Liberation Sans", Helvetica, Arial, sans-serif;
color: #555555;
background: #eaeaea
}
h1, h2, h3, h4, h5, h6 {
color: #222222;
margin: 0 0 20px;
}
p, ul, ol, table, pre, dl {
margin: 0 0 20px;
text-align: justify;
}
h1, h2, h3 {
line-height: 1.1;
}
h1 {
font-size: 28px;
}
h2 {
color: #393939;
}
h3, h4, h5, h6 {
color: #494949;
}
a {
color: #3399cc;
font-weight: 350;
text-decoration: none;
}
a small {
font-size: 11px;
color: #777777;
margin-top: -0.6em;
display: block;
}
.wrapper {
width: 80%;
margin: 0 auto;
}
blockquote {
border-left: 1px solid #ffffff;
margin: 0;
padding: 0 0 0 20px;
font-style: italic;
}
code, pre {
font-family: "Liberation Mono", Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal;
color: #222222;
font-size: 12px;
}
pre {
padding: 8px 15px;
border-radius: 5px;
border: 1px solid #e5e5e5;
overflow-x: auto;
overflow-y: auto;
}
input, select{
box-sizing: border-box;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 5px 10px;
border-bottom: 1px solid #ffffff;
}
td {
text-align: justify;
}
dt {
color: #444444;
font-weight: 700;
}
th {
text-align: left;
color: #444444;
}
img {
max-width: 100%;
}
header {
width: 20%;
float: left;
position: fixed;
}
header ul {
list-style: none;
height: 40px;
padding: 0;
background: #eeeeee;
border-radius: 5px;
border: 1px solid #d2d2d2;
box-shadow: inset #fff 0 1px 0, inset rgba(0,0,0,0.03) 0 -1px 0;
width: 15%;
}
header li {
width: 8%;
float: left;
border-right: 1px solid #d2d2d2;
height: 40px;
}
header ul a {
line-height: 1;
font-size: 11px;
color: #999999;
display: block;
text-align: center;
padding-top: 6px;
height: 40px;
}
strong {
color: #222222;
font-weight: 700;
}
header ul li + li {
width: 8%;
border-left: 1px solid #ffffff;
}
header ul li + li + li {
width: 8%;
border-right: none;
}
header ul a strong {
font-size: 14px;
display: block;
color: #222222;
}
section {
width: 70%;
float: right;
padding-bottom: 50px;
}
small {
font-size: 11px;
}
hr {
border: 0;
background: #ffffff;
height: 1px;
margin: 0 0 20px;
}
footer {
width: 20%;
float: left;
position: fixed;
bottom: 50px;
}
@media print, screen and (max-width: 960px) {
div.wrapper {
width: auto;
margin: 0;
}
header, section, footer {
float: none;
position: static;
width: auto;
}
header {
padding-right: 320px;
}
section {
border: 1px solid #e5e5e5;
border-width: 1px 0;
padding: 20px 0;
margin: 0 0 20px;
}
header a small {
display: inline;
}
header ul {
position: absolute;
right: 50px;
top: 52px;
}
}
@media print, screen and (max-width: 720px) {
body {
word-wrap: break-word;
}
header {
padding: 0;
}
header ul, header p.view {
position: static;
}
pre, code {
word-wrap: normal;
}
}
@media print, screen and (max-width: 480px) {
body {
padding: 15px;
}
header ul {
display: none;
}
}
@media print {
body {
padding: 0.4in;
font-size: 12pt;
color: #444444;
}
}

View File

@ -17,7 +17,7 @@ function exportTableToCsv(filename) {
var csv = [];
for (var i = 0; i < rows.length; i++) {
if (rows[i].style.display === "none")
continue
continue;
var cols = rows[i].querySelectorAll("td, th");
var row = [];
@ -28,4 +28,4 @@ function exportTableToCsv(filename) {
}
downloadCsv(csv.join("\n"), filename);
}
}

View File

@ -18,4 +18,4 @@ function searchTable() {
}
tr[i].style.display = display;
}
}
}

View File

@ -1,6 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<title>ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
@ -11,14 +11,14 @@
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='/api-docs/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>
</head>
<body>
<redoc spec-url='/api-docs/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -0,0 +1,52 @@
/*
* 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
import akka.actor.{Actor, Props}
import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.{Ariyala, PartyService}
import me.arcanis.ffxivbis.service.impl.DatabaseImpl
import me.arcanis.ffxivbis.storage.Migration
import scala.concurrent.{Await, ExecutionContext}
import scala.concurrent.duration.Duration
import scala.util.{Failure, Success}
class Application extends Actor with StrictLogging {
implicit private val executionContext: ExecutionContext = context.system.dispatcher
implicit private val materializer: ActorMaterializer = ActorMaterializer()
private val config = context.system.settings.config
private val host = config.getString("me.arcanis.ffxivbis.web.host")
private val port = config.getInt("me.arcanis.ffxivbis.web.port")
override def receive: Receive = Actor.emptyBehavior
Migration(config).onComplete {
case Success(_) =>
val ariyala = context.system.actorOf(Ariyala.props, "ariyala")
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")
val bind = Http()(context.system).bindAndHandle(http.route, host, port)
Await.result(context.system.whenTerminated, Duration.Inf)
bind.foreach(_.unbind())
case Failure(exception) => throw exception
}
}
object Application {
def props: Props = Props(new Application)
}

View File

@ -0,0 +1,20 @@
/*
* 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
import akka.actor.ActorSystem
import com.typesafe.config.ConfigFactory
object ffxivbis {
def main(args: Array[String]): Unit = {
val config = ConfigFactory.load()
val actorSystem = ActorSystem("ffxivbis", config)
actorSystem.actorOf(Application.props, "ffxivbis")
}
}

View File

@ -0,0 +1,24 @@
/*
* 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

@ -0,0 +1,61 @@
/*
* 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.http.scaladsl.model.headers._
import akka.http.scaladsl.server.AuthenticationFailedRejection._
import akka.http.scaladsl.server._
import akka.http.scaladsl.server.Directives._
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.{Permission, User}
import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler
import scala.concurrent.{ExecutionContext, Future}
// idea comes from https://synkre.com/bcrypt-for-akka-http-password-encryption/
trait Authorization {
def storage: ActorRef
def authenticateBasicBCrypt[T](realm: String,
authenticate: (String, String) => Future[Option[T]]): Directive1[T] = {
def challenge = HttpChallenges.basic(realm)
extractCredentials.flatMap {
case Some(BasicHttpCredentials(username, password)) =>
onSuccess(authenticate(username, password)).flatMap {
case Some(client) => provide(client)
case None => reject(AuthenticationFailedRejection(CredentialsRejected, challenge))
}
case _ => reject(AuthenticationFailedRejection(CredentialsMissing, challenge))
}
}
def authenticator(scope: Permission.Value)(partyId: String)
(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
(storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]].map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(username)
case _ => None
}
def authAdmin(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
authenticator(Permission.admin)(partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
authenticator(Permission.get)(partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
authenticator(Permission.post)(partyId)(username, password)
}

View File

@ -0,0 +1,47 @@
/*
* 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.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import me.arcanis.ffxivbis.service.impl.DatabaseBiSHandler
import scala.concurrent.{ExecutionContext, Future}
class BiSHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) {
def addPieceBiS(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseBiSHandler.AddPieceToBis(playerId, piece.withJob(playerId.job))).mapTo[Int]
def bis(partyId: String, playerId: Option[PlayerId])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] =
(storage ? DatabaseBiSHandler.GetBiS(partyId, playerId)).mapTo[Seq[Player]]
def doModifyBiS(action: ApiAction.Value, playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
action match {
case ApiAction.add => addPieceBiS(playerId, piece)
case ApiAction.remove => removePieceBiS(playerId, piece)
}
def putBiS(playerId: PlayerId, link: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] =
downloadBiS(link, playerId.job).flatMap { bis =>
Future.traverse(bis.pieces)(addPieceBiS(playerId, _))
}.map(_ => ())
def removePieceBiS(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseBiSHandler.RemovePieceFromBiS(playerId, piece)).mapTo[Int]
}

View File

@ -0,0 +1,45 @@
/*
* 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.http.api.v1.json.ApiAction
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}
class LootHelper(storage: ActorRef) {
def addPieceLoot(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseLootHandler.AddPieceTo(playerId, piece)).mapTo[Int]
def doModifyLoot(action: ApiAction.Value, playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
action match {
case ApiAction.add => addPieceLoot(playerId, piece)
case ApiAction.remove => removePieceLoot(playerId, piece)
}
def loot(partyId: String, playerId: Option[PlayerId])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] =
(storage ? DatabaseLootHandler.GetLoot(partyId, playerId)).mapTo[Seq[Player]]
def removePieceLoot(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseLootHandler.RemovePieceFrom(playerId, piece)).mapTo[Int]
def suggestPiece(partyId: String, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[PlayerIdWithCounters]] =
(storage ? DatabaseLootHandler.SuggestLoot(partyId, piece)).mapTo[LootSelectorResult].map(_.result)
}

View File

@ -0,0 +1,53 @@
/*
* 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.http.api.v1.json.ApiAction
import me.arcanis.ffxivbis.models.{Party, Player, PlayerId}
import me.arcanis.ffxivbis.service.impl.{DatabaseBiSHandler, DatabasePartyHandler}
import scala.concurrent.{ExecutionContext, Future}
class PlayerHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) {
def addPlayer(player: Player)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabasePartyHandler.AddPlayer(player)).mapTo[Int].map { res =>
player.link match {
case Some(link) =>
downloadBiS(link, player.job).map { bis =>
bis.pieces.map(storage ? DatabaseBiSHandler.AddPieceToBis(player.playerId, _))
}.map(_ => res)
case None => Future.successful(res)
}
}.flatten
def doModifyPlayer(action: ApiAction.Value, player: Player)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
action match {
case ApiAction.add => addPlayer(player)
case ApiAction.remove => removePlayer(player.playerId)
}
def getPlayers(partyId: String, maybePlayerId: Option[PlayerId])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] =
maybePlayerId match {
case Some(playerId) =>
(storage ? DatabasePartyHandler.GetPlayer(playerId)).mapTo[Option[Player]].map(_.toSeq)
case None =>
(storage ? DatabasePartyHandler.GetParty(partyId)).mapTo[Party].map(_.players.values.toSeq)
}
def removePlayer(playerId: PlayerId)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabasePartyHandler.RemovePlayer(playerId)).mapTo[Int]
}

View File

@ -0,0 +1,70 @@
/*
* 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 java.time.Instant
import akka.actor.{ActorRef, ActorSystem}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import com.typesafe.scalalogging.{Logger, StrictLogging}
import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint
import me.arcanis.ffxivbis.http.view.RootView
class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef)
extends StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._
private val config = system.settings.config
implicit val timeout: Timeout =
config.getDuration("me.arcanis.ffxivbis.settings.request-timeout")
private val rootApiV1Endpoint: RootApiV1Endpoint = new RootApiV1Endpoint(storage, ariyala)
private val rootView: RootView = new RootView(storage, ariyala)
private val httpLogger = Logger("http")
private val withHttpLog: Directive0 =
extractRequestContext.flatMap { context =>
val start = Instant.now.toEpochMilli
mapResponse { response =>
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""")
response
}
}
def route: Route =
withHttpLog {
apiRoute ~ htmlRoute ~ Swagger.routes ~ swaggerUIRoute
}
private def apiRoute: Route =
ignoreTrailingSlash {
pathPrefix("api") {
pathPrefix(Segment) {
case "v1" => rootApiV1Endpoint.route
case _ => reject
}
}
}
private def htmlRoute: Route =
ignoreTrailingSlash {
pathPrefix("static") {
getFromResourceDirectory("static")
} ~ rootView.route
}
private def swaggerUIRoute: Route =
path("swagger") {
getFromResource("swagger/index.html")
} ~ getFromResourceDirectory("swagger")
}

View File

@ -0,0 +1,32 @@
/*
* 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 com.github.swagger.akka.SwaggerHttpService
import com.github.swagger.akka.model.Info
import io.swagger.v3.oas.models.security.SecurityScheme
object Swagger extends SwaggerHttpService {
override val apiClasses: Set[Class[_]] = Set(
classOf[api.v1.BiSEndpoint], classOf[api.v1.LootEndpoint],
classOf[api.v1.PlayerEndpoint], classOf[api.v1.UserEndpoint]
)
override val info: Info = Info()
private val basicAuth = new SecurityScheme()
.description("basic http auth")
.`type`(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER)
.scheme("bearer")
override def securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth)
override val unwantedDefinitions: Seq[String] =
Seq("Function1", "Function1RequestContextFutureRouteResult")
}

View File

@ -0,0 +1,40 @@
/*
* 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.User
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler
import scala.concurrent.{ExecutionContext, Future}
class UserHelper(storage: ActorRef) {
def addUser(user: User, isHashedPassword: Boolean)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseUserHandler.AddUser(user, isHashedPassword)).mapTo[Int]
def newPartyId(implicit executionContext: ExecutionContext, timeout: Timeout): Future[String] =
(storage ? PartyService.GetNewPartyId).mapTo[String]
def user(partyId: String, username: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[User]] =
(storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]]
def users(partyId: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[User]] =
(storage ? DatabaseUserHandler.GetUsers(partyId)).mapTo[Seq[User]]
def removeUser(partyId: String, username: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseUserHandler.DeleteUser(partyId, username)).mapTo[Int]
}

View File

@ -0,0 +1,147 @@
/*
* 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.actor.ActorRef
import akka.http.scaladsl.model.{HttpEntity, StatusCodes}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import com.typesafe.scalalogging.StrictLogging
import io.swagger.v3.oas.annotations.enums.ParameterIn
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.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success}
@Path("api/v1")
class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout)
extends BiSHelper(storage, ariyala) with Authorization with JsonSupport {
def route: Route = createBiS ~ getBiS ~ modifyBiS
@PUT
@Path("party/{partyId}/bis")
@Consumes(value = Array("application/json"))
@Operation(summary = "create best in slot", description = "Create the best in slot set",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "player best in slot description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkResponse])))),
responses = Array(
new ApiResponse(responseCode = "201", description = "Best in slot set has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"),
)
def createBiS: Route =
path("party" / Segment / "bis") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
put {
entity(as[PlayerBiSLinkResponse]) { bisLink =>
val playerId = bisLink.playerId.withPartyId(partyId)
onComplete(putBiS(playerId, bisLink.link)) {
case Success(_) => complete(StatusCodes.Created, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}
@GET
@Path("party/{partyId}/bis")
@Produces(value = Array("application/json"))
@Operation(summary = "get best in slot", description = "Return the best in slot items",
parameters = Array(
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 = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Best in slot",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("best in slot"),
)
def getBiS: Route =
path("party" / Segment / "bis") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
onComplete(bis(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
}
}
}
}
}
}
@POST
@Path("party/{partyId}/bis")
@Consumes(value = Array("application/json"))
@Operation(summary = "modify best in slot", description = "Add or remove an item from the best in slot",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "action and piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))),
responses = Array(
new ApiResponse(responseCode = "202", description = "Best in slot set has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"),
)
def modifyBiS: Route =
path("party" / Segment / "bis") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { action =>
val playerId = action.playerIdResponse.withPartyId(partyId)
onComplete(doModifyBiS(action.action, playerId, action.piece.toPiece)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}
}

View File

@ -0,0 +1,34 @@
/*
* 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.model._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.api.v1.json._
import spray.json._
trait HttpHandler extends StrictLogging { this: JsonSupport =>
implicit def exceptionHandler: ExceptionHandler = ExceptionHandler {
case other: Exception =>
logger.error("exception during request completion", other)
complete(StatusCodes.InternalServerError, ErrorResponse("unknown server error"))
}
implicit def rejectionHandler: RejectionHandler =
RejectionHandler.default
.mapRejectionResponse {
case response @ HttpResponse(_, _, entity: HttpEntity.Strict, _) =>
val message = ErrorResponse(entity.data.utf8String).toJson
response.copy(entity = HttpEntity(ContentTypes.`application/json`, message.compactPrint))
case other => other
}
}

View File

@ -0,0 +1,147 @@
/*
* 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.actor.ActorRef
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.{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.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success}
@Path("api/v1")
class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
extends LootHelper(storage) with Authorization with JsonSupport with HttpHandler {
def route: Route = getLoot ~ modifyLoot
@GET
@Path("party/{partyId}/loot")
@Produces(value = Array("application/json"))
@Operation(summary = "get loot list", description = "Return the looted items",
parameters = Array(
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 = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Loot list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"),
)
def getLoot: Route =
path("party" / Segment / "loot") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
onComplete(loot(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
}
}
}
}
}
}
@POST
@Consumes(value = Array("application/json"))
@Path("party/{partyId}/loot")
@Operation(summary = "modify loot list", description = "Add or remove an item from the loot list",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "action and piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))),
responses = Array(
new ApiResponse(responseCode = "202", description = "Loot list has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("loot"),
)
def modifyLoot: Route =
path("party" / Segment / "loot") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { action =>
val playerId = action.playerIdResponse.withPartyId(partyId)
onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}
@PUT
@Path("party/{partyId}/loot")
@Consumes(value = Array("application/json"))
@Produces(value = Array("application/json"))
@Operation(summary = "suggest loot", description = "Suggest loot piece to party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceResponse])))),
responses = Array(
new ApiResponse(responseCode = "200", description = "Players with counters ordered by priority to get this item",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersResponse])),
))),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"),
)
def suggestLoot: Route =
path("party" / Segment / "loot") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
put {
entity(as[PieceResponse]) { piece =>
onComplete(suggestPiece(partyId, piece.toPiece)) {
case Success(response) => complete(response.map(PlayerIdWithCountersResponse.fromPlayerId))
case Failure(exception) => throw exception
}
}
}
}
}
}
}

View File

@ -0,0 +1,106 @@
/*
* 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.actor.ActorRef
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.{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.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.PlayerId
import scala.util.{Failure, Success}
@Path("api/v1")
class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout)
extends PlayerHelper(storage, ariyala) with Authorization with JsonSupport with HttpHandler {
def route: Route = getParty ~ modifyParty
@GET
@Path("party/{partyId}")
@Produces(value = Array("application/json"))
@Operation(summary = "get party", description = "Return the players who belong to the party",
parameters = Array(
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 = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Players list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])),
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("party"),
)
def getParty: Route =
path("party" / Segment) { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
onComplete(getPlayers(partyId, playerId)) {
case Success(response) => complete(response.map(PlayerResponse.fromPlayer))
case Failure(exception) => throw exception
}
}
}
}
}
}
@POST
@Path("party/{partyId}")
@Consumes(value = Array("application/json"))
@Operation(summary = "modify party", description = "Add or remove a player from party list",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "player description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionResponse])))),
responses = Array(
new ApiResponse(responseCode = "202", description = "Party has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("party"),
)
def modifyParty: Route =
path("party" / Segment) { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
entity(as[PlayerActionResponse]) { action =>
val player = action.playerIdResponse.toPlayer.copy(partyId = partyId)
onComplete(doModifyPlayer(action.action, player)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}

View File

@ -0,0 +1,32 @@
/*
* 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.actor.ActorRef
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.api.v1.json.JsonSupport
class RootApiV1Endpoint(storage: ActorRef, ariyala: ActorRef)
(implicit timeout: Timeout)
extends JsonSupport with HttpHandler {
private val biSEndpoint = new BiSEndpoint(storage, ariyala)
private val lootEndpoint = new LootEndpoint(storage)
private val playerEndpoint = new PlayerEndpoint(storage, ariyala)
private val userEndpoint = new UserEndpoint(storage)
def route: Route =
handleExceptions(exceptionHandler) {
handleRejections(rejectionHandler) {
biSEndpoint.route ~ lootEndpoint.route ~ playerEndpoint.route ~ userEndpoint.route
}
}
}

View File

@ -0,0 +1,166 @@
/*
* 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.actor.ActorRef
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.{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.security.SecurityRequirement
import javax.ws.rs.{Consumes, DELETE, GET, POST, PUT, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.Permission
import scala.util.{Failure, Success}
@Path("api/v1")
class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
extends UserHelper(storage) with Authorization with JsonSupport {
def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers
@PUT
@Path("party")
@Consumes(value = Array("application/json"))
@Operation(summary = "create new party", 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(
new ApiResponse(responseCode = "200", description = "Party has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "406", description = "Party with the specified ID already exists"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
tags = Array("party"),
)
def createParty: Route =
path("party") {
extractExecutionContext { implicit executionContext =>
put {
entity(as[UserResponse]) { user =>
onComplete(newPartyId) {
case Success(partyId) =>
val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin)
onComplete(addUser(admin, isHashedPassword = false)) {
case Success(_) => complete(PartyIdResponse(partyId))
case Failure(exception) => throw exception
}
case Failure(exception) => throw exception
}
}
}
}
}
@POST
@Path("party/{partyId}/users")
@Consumes(value = Array("application/json"))
@Operation(summary = "create new user", description = "Add an user to the specified party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "user description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))),
responses = Array(
new ApiResponse(responseCode = "201", description = "User has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def createUser: Route =
path("party" / Segment / "users") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
post {
entity(as[UserResponse]) { user =>
val withPartyId = user.toUser.copy(partyId = partyId)
onComplete(addUser(withPartyId, isHashedPassword = false)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
}
@DELETE
@Path("party/{partyId}/users/{username}")
@Operation(summary = "remove user", description = "Remove an user from the specified party",
parameters = Array(
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"),
),
responses = Array(
new ApiResponse(responseCode = "202", description = "User has been removed"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def deleteUser: Route =
path("party" / Segment / "users" / Segment) { (partyId, username) =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
delete {
onComplete(removeUser(partyId, username)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}
}
}
}
}
@GET
@Path("party/{partyId}/users")
@Produces(value = Array("application/json"))
@Operation(summary = "get users", description = "Return the list of users belong to party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Users list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[UserResponse])),
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def getUsers: Route =
path("party" / Segment / "users") { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
get {
onComplete(users(partyId)) {
case Success(response) => complete(response.map(UserResponse.fromUser))
case Failure(exception) => throw exception
}
}
}
}
}
}

View File

@ -0,0 +1,13 @@
/*
* 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.json
object ApiAction extends Enumeration {
val add, remove = Value
}

View File

@ -0,0 +1,14 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
case class ErrorResponse(
@Schema(description = "error message", required = true) message: String)

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.http.api.v1.json
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import me.arcanis.ffxivbis.models.Permission
import spray.json._
trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
private def enumFormat[E <: Enumeration](enum: E): RootJsonFormat[E#Value] =
new RootJsonFormat[E#Value] {
override def write(obj: E#Value): JsValue = obj.toString.toJson
override def read(json: JsValue): E#Value = json match {
case JsNumber(value) => enum(value.toInt)
case JsString(name) => enum.withName(name)
case other => deserializationError(s"String or number expected, got $other")
}
}
implicit val actionFormat: RootJsonFormat[ApiAction.Value] = enumFormat(ApiAction)
implicit val permissionFormat: RootJsonFormat[Permission.Value] = enumFormat(Permission)
implicit val errorFormat: RootJsonFormat[ErrorResponse] = jsonFormat1(ErrorResponse.apply)
implicit val partyIdFormat: RootJsonFormat[PartyIdResponse] = jsonFormat1(PartyIdResponse.apply)
implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply)
implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply)
implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply)
implicit val playerIdFormat: RootJsonFormat[PlayerIdResponse] = jsonFormat3(PlayerIdResponse.apply)
implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat3(PieceActionResponse.apply)
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkResponse] = jsonFormat2(PlayerBiSLinkResponse.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersResponse] =
jsonFormat9(PlayerIdWithCountersResponse.apply)
implicit val userFormat: RootJsonFormat[UserResponse] = jsonFormat4(UserResponse.apply)
}

View File

@ -0,0 +1,14 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
case class PartyIdResponse(
@Schema(description = "party id", required = true) partyId: String)

View File

@ -0,0 +1,16 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
case class PieceActionResponse(
@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 = "player description", required = true) playerIdResponse: PlayerIdResponse)

View File

@ -0,0 +1,24 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, Piece}
case class PieceResponse(
@Schema(description = "is piece tome gear", required = true) isTome: Boolean,
@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) {
def toPiece: Piece = Piece(piece, isTome, Job.withName(job))
}
object PieceResponse {
def fromPiece(piece: Piece): PieceResponse =
PieceResponse(piece.isTome, piece.job.toString, piece.piece)
}

View File

@ -0,0 +1,15 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerActionResponse(
@Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove"), example = "add") action: ApiAction.Value,
@Schema(description = "player description", required = true) playerIdResponse: PlayerResponse)

View File

@ -0,0 +1,15 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerBiSLinkResponse(
@Schema(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

@ -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.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, PlayerId}
case class PlayerIdResponse(
@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 = "player nick name", required = true, example = "Siuan Sanche") nick: String) {
def withPartyId(partyId: String): PlayerId =
PlayerId(partyId, Job.withName(job), nick)
}
object PlayerIdResponse {
def fromPlayerId(playerId: PlayerId): PlayerIdResponse =
PlayerIdResponse(Some(playerId.partyId), playerId.job.toString, playerId.nick)
}

View File

@ -0,0 +1,37 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PlayerIdWithCounters
case class PlayerIdWithCountersResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: 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 = "is piece required by player or not", required = true) isRequired: Boolean,
@Schema(description = "player loot priority", required = true) priority: 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 which are parts of best in slot", required = true) lootCountBiS: Int,
@Schema(description = "total count of looted pieces", required = true) lootCountTotal: Int)
object PlayerIdWithCountersResponse {
def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersResponse =
PlayerIdWithCountersResponse(
playerIdWithCounters.partyId,
playerIdWithCounters.job.toString,
playerIdWithCounters.nick,
playerIdWithCounters.isRequired,
playerIdWithCounters.priority,
playerIdWithCounters.bisCountTotal,
playerIdWithCounters.lootCount,
playerIdWithCounters.lootCountBiS,
playerIdWithCounters.lootCountTotal)
}

View File

@ -0,0 +1,33 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{BiS, Job, Player}
case class PlayerResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: 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 = "pieces in best in slot") bis: Option[Seq[PieceResponse]],
@Schema(description = "looted pieces") loot: Option[Seq[PieceResponse]],
@Schema(description = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String],
@Schema(description = "player loot priority") priority: Option[Int]) {
def toPlayer: Player =
Player(partyId, Job.withName(job), nick,
BiS(bis.getOrElse(Seq.empty).map(_.toPiece)), loot.getOrElse(Seq.empty).map(_.toPiece),
link, priority.getOrElse(0))
}
object PlayerResponse {
def fromPlayer(player: Player): PlayerResponse =
PlayerResponse(player.partyId, player.job.toString, player.nick,
Some(player.bis.pieces.map(PieceResponse.fromPiece)), Some(player.loot.map(PieceResponse.fromPiece)),
player.link, Some(player.priority))
}

View File

@ -0,0 +1,26 @@
/*
* 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.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Permission, User}
case class UserResponse(
@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 = "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) {
def toUser: User =
User(partyId, username, password, permission.getOrElse(Permission.get))
}
object UserResponse {
def fromUser(user: User): UserResponse =
UserResponse(user.partyId, user.username, "", Some(user.permission))
}

View File

@ -0,0 +1,63 @@
/*
* 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.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import me.arcanis.ffxivbis.http.Authorization
class BasePartyView(override val storage: ActorRef)(implicit timeout: Timeout)
extends Authorization {
def route: Route = getIndex
def getIndex: Route =
path("party" / Segment) { partyId =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
(StatusCodes.OK, RootView.toHtml(BasePartyView.template(partyId)))
}
}
}
}
}
}
object BasePartyView {
import scalatags.Text
import scalatags.Text.all._
def root(partyId: String): Text.TypedTag[String] =
a(href:=s"/party/$partyId", title:="root")("root")
def template(partyId: String): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en",
head(
title:=s"Party $partyId",
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css")
),
body(
h2(s"Party $partyId"),
br,
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/loot", title:="loot management")("loot")),
h2(a(href:=s"/party/$partyId/suggest", title:="suggest loot")("suggest")),
hr,
h2(a(href:=s"/party/$partyId/users", title:="user management")("users"))
)
)
}

View File

@ -0,0 +1,151 @@
/*
* 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.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class BiSView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout)
extends BiSHelper(storage, ariyala) with Authorization {
def route: Route = getBiS ~ modifyBiS
def getBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
bis(partyId, None).map { players =>
BiSView.template(partyId, players, None)
}.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
formFields("player".as[String], "piece".as[String].?, "is_tome".as[String].?, "link".as[String].?, "action".as[String]) {
(player, maybePiece, maybeIsTome, maybeLink, action) =>
onComplete(modifyBiSCall(partyId, player, maybePiece, maybeIsTome, maybeLink, action)) {
case _ => redirect(s"/party/$partyId/bis", StatusCodes.Found)
}
}
}
}
}
}
private def modifyBiSCall(partyId: String, player: String,
maybePiece: Option[String], maybeIsTome: Option[String],
maybeLink: Option[String], action: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
import me.arcanis.ffxivbis.utils.Implicits._
def getPiece(playerId: PlayerId, piece: String) =
Try(Piece(piece, maybeIsTome, playerId.job)).toOption
PlayerId(partyId, player) match {
case Some(playerId) => (maybePiece, action, maybeLink) match {
case (Some(piece), "add", _) => getPiece(playerId, piece) match {
case Some(item) => addPieceBiS(playerId, item).map(_ => ())
case _ => Future.failed(new Error(s"Could not construct piece from `$piece`"))
}
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`"))
}
}
}
object BiSView {
import scalatags.Text.all._
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\">" +
html(lang:="en",
head(
title:="Best in slot",
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css")
),
body(
h2("Best in slot"),
ErrorView.template(error),
SearchLineView.template,
form(action:=s"/party/$partyId/bis", method:="post")(
select(name:="player", id:="player", title:="player")
(for (player <- party) yield option(player.playerId.toString)),
select(name:="piece", id:="piece", title:="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"),
input(name:="action", id:="action", `type`:="hidden", value:="add"),
input(name:="add", id:="add", `type`:="submit", value:="add")
),
form(action:="/bis", method:="post")(
select(name:="player", id:="player", title:="player")
(for (player <- party) yield option(player.playerId.toString)),
input(name:="link", id:="link", placeholder:="player bis link", title:="link", `type`:="text"),
input(name:="action", id:="action", `type`:="hidden", value:="create"),
input(name:="add", id:="add", `type`:="submit", value:="add")
),
table(id:="result")(
tr(
th("player"),
th("piece"),
th("is tome"),
th("")
),
for (player <- party; piece <- player.bis.pieces) yield tr(
td(`class`:="include_search")(player.playerId.toString),
td(`class`:="include_search")(piece.piece),
td(piece.isTomeToString),
td(
form(action:=s"/party/$partyId/bis", method:="post")(
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString),
input(name:="piece", id:="piece", `type`:="hidden", value:=piece.piece),
input(name:="is_tome", id:="is_tome", `type`:="hidden", value:=piece.isTomeToString),
input(name:="action", id:="action", `type`:="hidden", value:="remove"),
input(name:="remove", id:="remove", `type`:="submit", value:="x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript")
)
)
}

View File

@ -0,0 +1,19 @@
/*
* 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.view
import scalatags.Text
import scalatags.Text.all._
object ErrorView {
def template(error: Option[String]): Text.TypedTag[String] = error match {
case Some(text) => p(id:="error", s"Error occurs: $text")
case None => p("")
}
}

View File

@ -0,0 +1,20 @@
/*
* 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.view
import scalatags.Text
import scalatags.Text.all._
object ExportToCSVView {
def template: Text.TypedTag[String] =
div(
button(onclick:="exportTableToCsv('result.csv')")("Export to CSV"),
script(src:="/static/table_export.js", `type`:="text/javascript")
)
}

View File

@ -0,0 +1,84 @@
/*
* 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.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import me.arcanis.ffxivbis.http.UserHelper
import me.arcanis.ffxivbis.models.{Party, Permission, User}
import scala.util.{Failure, Success}
class IndexView(storage: ActorRef)(implicit timeout: Timeout)
extends UserHelper(storage) {
def route: Route = createParty ~ getIndex
def createParty: Route =
path("party") {
extractExecutionContext { implicit executionContext =>
post {
formFields("username".as[String], "password".as[String]) { (username, password) =>
onComplete(newPartyId) {
case Success(partyId) =>
val user = User(partyId, username, password, Permission.admin)
onComplete(addUser(user, isHashedPassword = false)) {
case _ => redirect(s"/party/$partyId", StatusCodes.Found)
}
case Failure(exception) => throw exception
}
}
}
}
}
def getIndex: Route =
pathEndOrSingleSlash {
get {
parameters("partyId".as[String].?) {
case Some(partyId) => redirect(s"/party/$partyId", StatusCodes.Found)
case _ => complete(StatusCodes.OK, RootView.toHtml(IndexView.template))
}
}
}
}
object IndexView {
import scalatags.Text.all._
def template: String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(
head(
title:="FFXIV loot helper",
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css")
),
body(
form(action:=s"party", method:="post")(
label("create a new party"),
input(name:="username", id:="username", placeholder:="username", title:="username", `type`:="text"),
input(name:="password", id:="password", placeholder:="password", title:="password", `type`:="password"),
input(name:="add", id:="add", `type`:="submit", value:="add")
),
br,
form(action:="/", method:="get")(
label("already have party?"),
input(name:="partyId", id:="partyId", placeholder:="party id", title:="party id", `type`:="text"),
input(name:="go", id:="go", `type`:="submit", value:="go")
)
)
)
}

View File

@ -0,0 +1,129 @@
/*
* 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.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.models.{Piece, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}
class LootSuggestView(override val storage: ActorRef)(implicit timeout: Timeout)
extends LootHelper(storage) with Authorization {
def route: Route = getIndex ~ suggestLoot
def getIndex: Route =
path("party" / Segment / "suggest") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
val text = LootSuggestView.template(partyId, Seq.empty, None, None)
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
def suggestLoot: Route =
path("party" / Segment / "suggest") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
post {
formFields("piece".as[String], "is_tome".as[String].?) { (piece, maybeTome) =>
import me.arcanis.ffxivbis.utils.Implicits._
val maybePiece = Try(Piece(piece, maybeTome)).toOption
onComplete(suggestLootCall(partyId, maybePiece)) {
case Success(players) =>
val text = LootSuggestView.template(partyId, players, maybePiece, None)
complete(StatusCodes.OK, RootView.toHtml(text))
case Failure(exception) =>
val text = LootSuggestView.template(partyId, Seq.empty, maybePiece, Some(exception.getMessage))
complete(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
private def suggestLootCall(partyId: String, maybePiece: Option[Piece])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[PlayerIdWithCounters]] =
maybePiece match {
case Some(piece) => suggestPiece(partyId, piece)
case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece`"))
}
}
object LootSuggestView {
import scalatags.Text.all._
def template(partyId: String, party: Seq[PlayerIdWithCounters], piece: Option[Piece], error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en",
head(
title:="Suggest loot",
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css")
),
body(
h2("Suggest loot"),
ErrorView.template(error),
SearchLineView.template,
form(action:=s"/party/$partyId/suggest", method:="post")(
select(name:="piece", id:="piece", title:="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"),
input(name:="suggest", id:="suggest", `type`:="submit", value:="suggest")
),
table(id:="result")(
tr(
th("player"),
th("is required"),
th("these pieces looted"),
th("total bis pieces looted"),
th("total pieces looted"),
th("")
),
for (player <- party) yield tr(
td(`class`:="include_search")(player.playerId.toString),
td(player.isRequiredToString),
td(player.lootCount),
td(player.lootCountBiS),
td(player.lootCountTotal),
td(
form(action:=s"/party/$partyId/loot", method:="post")(
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString),
input(name:="piece", id:="piece", `type`:="hidden", value:=piece.map(_.piece).getOrElse("")),
input(name:="is_tome", id:="is_tome", `type`:="hidden", value:=piece.map(_.isTomeToString).getOrElse("")),
input(name:="action", id:="action", `type`:="hidden", value:="add"),
input(name:="add", id:="add", `type`:="submit", value:="add")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript")
)
)
}

View File

@ -0,0 +1,137 @@
/*
* 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.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class LootView (override val storage: ActorRef)(implicit timeout: Timeout)
extends LootHelper(storage) with Authorization {
def route: Route = getLoot ~ modifyLoot
def getLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
loot(partyId, None).map { players =>
LootView.template(partyId, players, None)
}.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
formFields("player".as[String], "piece".as[String], "is_tome".as[String].?, "action".as[String]) {
(player, maybePiece, maybeIsTome, action) =>
onComplete(modifyLootCall(partyId, player, maybePiece, maybeIsTome, action)) {
case _ => redirect(s"/party/$partyId/loot", StatusCodes.Found)
}
}
}
}
}
}
private def modifyLootCall(partyId: String, player: String,
maybePiece: String, maybeIsTome: Option[String],
action: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
import me.arcanis.ffxivbis.utils.Implicits._
def getPiece(playerId: PlayerId) =
Try(Piece(maybePiece, maybeIsTome, playerId.job)).toOption
PlayerId(partyId, player) match {
case Some(playerId) => (getPiece(playerId), action) match {
case (Some(piece), "add") => addPieceLoot(playerId, piece).map(_ => ())
case (Some(piece), "remove") => removePieceLoot(playerId, piece).map(_ => ())
case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece`"))
}
case _ => Future.failed(new Error(s"Could not construct player id from `$player`"))
}
}
}
object LootView {
import scalatags.Text.all._
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\">" +
html(lang:="en",
head(
title:="Loot",
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css")
),
body(
h2("Loot"),
ErrorView.template(error),
SearchLineView.template,
form(action:=s"/party/$partyId/loot", method:="post")(
select(name:="player", id:="player", title:="player")
(for (player <- party) yield option(player.playerId.toString)),
select(name:="piece", id:="piece", title:="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"),
input(name:="action", id:="action", `type`:="hidden", value:="add"),
input(name:="add", id:="add", `type`:="submit", value:="add")
),
table(id:="result")(
tr(
th("player"),
th("piece"),
th("is tome"),
th("")
),
for (player <- party; piece <- player.bis.pieces) yield tr(
td(`class`:="include_search")(player.playerId.toString),
td(`class`:="include_search")(piece.piece),
td(piece.isTomeToString),
td(
form(action:=s"/party/$partyId/loot", method:="post")(
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString),
input(name:="piece", id:="piece", `type`:="hidden", value:=piece.piece),
input(name:="is_tome", id:="is_tome", `type`:="hidden", value:=piece.isTomeToString),
input(name:="action", id:="action", `type`:="hidden", value:="remove"),
input(name:="remove", id:="remove", `type`:="submit", value:="x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript")
)
)
}

View File

@ -0,0 +1,133 @@
/*
* 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.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId, PlayerIdWithCounters}
import scala.concurrent.{ExecutionContext, Future}
class PlayerView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout)
extends PlayerHelper(storage, ariyala) with Authorization {
def route: Route = getParty ~ modifyParty
def getParty: Route =
path("party" / Segment / "players") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
getPlayers(partyId, None).map { players =>
PlayerView.template(partyId, players.map(_.withCounters(None)), None)
}.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyParty: Route =
path("party" / Segment / "players") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
formFields("nick".as[String], "job".as[String], "priority".as[Int].?, "link".as[String].?, "action".as[String]) {
(nick, job, maybePriority, maybeLink, action) =>
onComplete(modifyPartyCall(partyId, nick, job, maybePriority, maybeLink, action)) {
case _ => redirect(s"/party/$partyId/players", StatusCodes.Found)
}
}
}
}
}
}
private def modifyPartyCall(partyId: String, nick: String, 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 player(playerId: PlayerId) =
Player(partyId, playerId.job, playerId.nick, BiS(), Seq.empty, maybeLink, maybePriority.getOrElse(0))
(action, maybePlayerId) match {
case ("add", Some(playerId)) => addPlayer(player(playerId)).map(_ => ())
case ("remove", Some(playerId)) => removePlayer(playerId).map(_ => ())
case _ => Future.failed(new Error(s"Could not perform $action with $nick ($job)"))
}
}
}
object PlayerView {
import scalatags.Text.all._
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\">" +
html(lang:="en",
head(
title:="Party",
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css")
),
body(
h2("Party"),
ErrorView.template(error),
SearchLineView.template,
form(action:=s"/party/$partyId/players", method:="post")(
input(name:="nick", id:="nick", placeholder:="nick", title:="nick", `type`:="nick"),
select(name:="job", id:="job", title:="job")
(for (job <- Job.jobs) yield option(job.toString)),
input(name:="link", id:="link", placeholder:="player bis link", title:="link", `type`:="text"),
input(name:="prioiry", id:="priority", placeholder:="priority", 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")(
tr(
th("nick"),
th("job"),
th("total bis pieces looted"),
th("total pieces looted"),
th("priority"),
th("")
),
for (player <- party) yield tr(
td(`class`:="include_search")(player.nick),
td(`class`:="include_search")(player.job.toString),
td(player.lootCountBiS),
td(player.lootCountTotal),
td(player.priority),
td(
form(action:=s"/party/$partyId/players", method:="post")(
input(name:="nick", id:="nick", `type`:="hidden", value:=player.nick),
input(name:="job", id:="job", `type`:="hidden", value:=player.job.toString),
input(name:="action", id:="action", `type`:="hidden", value:="remove"),
input(name:="remove", id:="remove", `type`:="submit", value:="x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript")
)
)
}

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.http.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.{ContentTypes, HttpEntity}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
class RootView(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) {
private val basePartyView = new BasePartyView(storage)
private val indexView = new IndexView(storage)
private val biSView = new BiSView(storage, ariyala)
private val lootView = new LootView(storage)
private val lootSuggestView = new LootSuggestView(storage)
private val playerView = new PlayerView(storage, ariyala)
private val userView = new UserView(storage)
def route: Route =
basePartyView.route ~ indexView.route ~
biSView.route ~ lootView.route ~ lootSuggestView.route ~ playerView.route ~ userView.route
}
object RootView {
def toHtml(template: String): HttpEntity.Strict =
HttpEntity(ContentTypes.`text/html(UTF-8)`, template)
}

View File

@ -0,0 +1,22 @@
/*
* 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.view
import scalatags.Text
import scalatags.Text.all._
object SearchLineView {
def template: Text.TypedTag[String] =
div(
input(
`type`:="text", id:="search", onkeyup:="searchTable()",
placeholder:="search for data", title:="search"
)
)
}

View File

@ -0,0 +1,127 @@
/*
* 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.view
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
import me.arcanis.ffxivbis.models.{Permission, User}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class UserView(override val storage: ActorRef)(implicit timeout: Timeout)
extends UserHelper(storage) with Authorization {
def route: Route = getUsers ~ modifyUsers
def getUsers: Route =
path("party" / Segment / "users") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
get {
complete {
users(partyId).map { users =>
UserView.template(partyId, users, None)
}.map { text =>
(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
}
}
def modifyUsers: Route =
path("party" / Segment / "users") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
post {
formFields("username".as[String], "password".as[String].?, "permission".as[String].?, "action".as[String]) {
(username, maybePassword, maybePermission, action) =>
onComplete(modifyUsersCall(partyId, username, maybePassword, maybePermission, action)) {
case _ => redirect(s"/party/$partyId/users", StatusCodes.Found)
}
}
}
}
}
}
private def modifyUsersCall(partyId: String, username: String,
maybePassword: Option[String], maybePermission: Option[String],
action: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
def permission: Option[Permission.Value] =
maybePermission.flatMap(p => Try(Permission.withName(p)).toOption)
action match {
case "add" => (maybePassword, permission) match {
case (Some(password), Some(permission)) => addUser(User(partyId, username, password, permission), isHashedPassword = false).map(_ => ())
case _ => Future.failed(new Error(s"Could not construct permission/password from `$maybePermission`/`$maybePassword`"))
}
case "remove" => removeUser(partyId, username).map(_ => ())
case _ => Future.failed(new Error(s"Could not perform $action"))
}
}
}
object UserView {
import scalatags.Text.all._
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\">" +
html(lang:="en",
head(
title:="Users",
link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css")
),
body(
h2("Users"),
ErrorView.template(error),
SearchLineView.template,
form(action:=s"/party/$partyId/users", method:="post")(
input(name:="username", id:="username", placeholder:="username", title:="username", `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")(
tr(
th("username"),
th("permission"),
th("")
),
for (user <- users) yield tr(
td(`class`:="include_search")(user.username),
td(user.permission.toString),
td(
form(action:=s"/party/$partyId/users", method:="post")(
input(name:="username", id:="username", `type`:="hidden", value:=user.username.toString),
input(name:="action", id:="action", `type`:="hidden", value:="remove"),
input(name:="remove", id:="remove", `type`:="submit", value:="x")
)
)
)
),
ExportToCSVView.template,
BasePartyView.root(partyId),
script(src:="/static/table_search.js", `type`:="text/javascript")
)
)
}

View File

@ -0,0 +1,80 @@
/*
* 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.models
case class BiS(weapon: Option[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 {
case upgrade: PieceUpgrade => upgrades.contains(upgrade)
case _ => pieces.contains(piece)
}
def upgrades: Map[PieceUpgrade, Int] =
pieces.groupBy(_.upgrade).foldLeft(Map.empty[PieceUpgrade, Int]) {
case (acc, (Some(k), v)) => acc + (k -> v.length)
case (acc, _) => acc
} withDefaultValue 0
def withPiece(piece: Piece): BiS = copyWithPiece(piece.piece, Some(piece))
def withoutPiece(piece: Piece): BiS = copyWithPiece(piece.piece, None)
private def copyWithPiece(name: String, piece: Option[Piece]): BiS = {
val params = Map(
"weapon" -> weapon,
"head" -> head,
"body" -> body,
"hands" -> hands,
"waist" -> waist,
"legs" -> legs,
"feet" -> feet,
"ears" -> ears,
"neck" -> neck,
"wrist" -> wrist,
"leftRing" -> leftRing,
"rightRing" -> rightRing
) + (name -> piece)
BiS(params)
}
}
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 apply(pieces: Seq[Piece]): BiS =
BiS(pieces.map(piece => piece.piece -> Some(piece)).toMap)
}

View File

@ -0,0 +1,103 @@
/*
* 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.models
object Job {
sealed trait RightSide
object AccessoriesDex extends RightSide
object AccessoriesInt extends RightSide
object AccessoriesMnd extends RightSide
object AccessoriesStr extends RightSide
object AccessoriesVit extends RightSide
sealed trait LeftSide
object BodyCasters extends LeftSide
object BodyDrgs extends LeftSide
object BodyHealers extends LeftSide
object BodyMnks extends LeftSide
object BodyNins extends LeftSide
object BodyTanks extends LeftSide
object BodyRanges extends LeftSide
sealed trait Job {
def leftSide: LeftSide
def rightSide: RightSide
// conversion to string to avoid recursion
override def equals(obj: Any): Boolean = {
def canEqual(obj: Any): Boolean = obj.isInstanceOf[Job]
def equality(objRepr: String): Boolean = objRepr match {
case _ if objRepr == AnyJob.toString => true
case _ if this.toString == AnyJob.toString => true
case _ => this.toString == obj.toString
}
canEqual(obj) && equality(obj.toString)
}
}
case object AnyJob extends Job {
val leftSide: LeftSide = null
val rightSide: RightSide = null
}
trait Casters extends Job {
val leftSide: LeftSide = BodyCasters
val rightSide: RightSide = AccessoriesInt
}
trait Healers extends Job {
val leftSide: LeftSide = BodyHealers
val rightSide: RightSide = AccessoriesMnd
}
trait Mnks extends Job {
val leftSide: LeftSide = BodyMnks
val rightSide: RightSide = AccessoriesStr
}
trait Tanks extends Job {
val leftSide: LeftSide = BodyTanks
val rightSide: RightSide = AccessoriesVit
}
trait Ranges extends Job {
val leftSide: LeftSide = BodyRanges
val rightSide: RightSide = AccessoriesDex
}
case object PLD extends Tanks
case object WAR extends Tanks
case object DRK extends Tanks
case object GNB extends Tanks
case object WHM extends Healers
case object SCH extends Healers
case object AST extends Healers
case object MNK extends Mnks
case object DRG extends Job {
val leftSide: LeftSide = BodyDrgs
val rightSide: RightSide = AccessoriesStr
}
case object NIN extends Job {
val leftSide: LeftSide = BodyNins
val rightSide: RightSide = AccessoriesDex
}
case object SAM extends Mnks
case object BRD extends Ranges
case object MCH extends Ranges
case object DNC extends Ranges
case object BLM extends Casters
case object SMN extends Casters
case object RDM extends Casters
lazy val jobs: Seq[Job] =
Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, MNK, DRG, NIN, SAM, BRD, MCH, DNC, BLM, SMN, RDM)
def withName(job: String): Job.Job = jobs.find(_.toString == job.toUpperCase).getOrElse(AnyJob)
}

View File

@ -0,0 +1,11 @@
/*
* 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.models
case class Loot(playerId: Long, piece: Piece)

View File

@ -0,0 +1,59 @@
/*
* 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.models
import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.service.LootSelector
import scala.jdk.CollectionConverters._
import scala.util.Random
case class Party(partyId: String, rules: Seq[String], players: Map[PlayerId, Player])
extends StrictLogging {
require(players.keys.forall(_.partyId == partyId), "party id must be same")
def getPlayers: Seq[Player] = players.values.toSeq
def player(playerId: PlayerId): Option[Player] = players.get(playerId)
def withPlayer(player: Player): Party =
try {
require(player.partyId == partyId, "player must belong to this party")
copy(players = players + (player.playerId -> player))
} catch {
case exception: Exception =>
logger.error("cannot add player", exception)
this
}
def suggestLoot(piece: Piece): LootSelector.LootSelectorResult =
LootSelector(getPlayers, piece, rules)
}
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 =
new Party(partyId.getOrElse(randomPartyId), getRules(config), Map.empty)
def apply(partyId: String, config: Config,
players: Map[Long, Player], bis: Seq[Loot], loot: Seq[Loot]): Party = {
val bisByPlayer = bis.groupBy(_.playerId).view.mapValues(piece => BiS(piece.map(_.piece)))
val lootByPlayer = loot.groupBy(_.playerId).view.mapValues(_.map(_.piece))
val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) {
case (acc, (playerId, player)) =>
acc + (player.playerId -> player
.withBiS(bisByPlayer.get(playerId))
.withLoot(lootByPlayer.getOrElse(playerId, Seq.empty)))
}
Party(partyId, getRules(config), playersWithItems)
}
def randomPartyId: String = Random.alphanumeric.take(20).mkString
}

View File

@ -0,0 +1,123 @@
/*
* 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.models
sealed trait Piece {
def isTome: Boolean
def job: Job.Job
def piece: String
def withJob(other: Job.Job): Piece
def isTomeToString: String = if (isTome) "yes" else "no"
def upgrade: Option[PieceUpgrade] = this match {
case _ if !isTome => None
case _: Waist => Some(AccessoryUpgrade)
case _: PieceAccessory => Some(AccessoryUpgrade)
case _: PieceBody => Some(BodyUpgrade)
case _: PieceWeapon => Some(WeaponUpgrade)
}
}
trait PieceAccessory extends Piece
trait PieceBody extends Piece
trait PieceUpgrade extends Piece {
val isTome: Boolean = true
val job: Job.Job = Job.AnyJob
def withJob(other: Job.Job): Piece = this
}
trait PieceWeapon extends Piece
case class Weapon(override val isTome: Boolean, override val job: Job.Job) extends PieceWeapon {
val piece: String = "weapon"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Head(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "head"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Body(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "body"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Hands(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "hands"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Waist(override val isTome: Boolean, 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"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Feet(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "feet"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Ears(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
val piece: String = "ears"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Neck(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
val piece: String = "neck"
def withJob(other: Job.Job): Piece = copy(job = other)
}
case class Wrist(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
val piece: String = "wrist"
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")
extends PieceAccessory {
def withJob(other: Job.Job): Piece = copy(job = other)
override def equals(obj: Any): Boolean = obj match {
case Ring(thatIsTome, thatJob, _) => (thatIsTome == isTome) && (thatJob == job)
case _ => false
}
}
case object AccessoryUpgrade extends PieceUpgrade {
val piece: String = "accessory upgrade"
}
case object BodyUpgrade extends PieceUpgrade {
val piece: String = "body upgrade"
}
case object WeaponUpgrade extends PieceUpgrade {
val piece: String = "weapon upgrade"
}
object Piece {
def apply(piece: String, isTome: Boolean, job: Job.Job = Job.AnyJob): Piece =
piece.toLowerCase match {
case "weapon" => Weapon(isTome, job)
case "head" => Head(isTome, job)
case "body" => Body(isTome, job)
case "hands" => Hands(isTome, job)
case "waist" => Waist(isTome, job)
case "legs" => Legs(isTome, job)
case "feet" => Feet(isTome, job)
case "ears" => Ears(isTome, job)
case "neck" => Neck(isTome, job)
case "wrist" => Wrist(isTome, job)
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 "body upgrade" => BodyUpgrade
case "weapon upgrade" => WeaponUpgrade
case other => throw new Error(s"Unknown item type $other")
}
lazy val available: Seq[String] = Seq("weapon",
"head", "body", "hands", "waist", "legs", "feet",
"ears", "neck", "wrist", "leftRing", "rightRing")
}

View File

@ -0,0 +1,53 @@
/*
* 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.models
case class Player(partyId: String,
job: Job.Job,
nick: String,
bis: BiS,
loot: Seq[Piece],
link: Option[String] = None,
priority: Int = 0) {
require(job ne Job.AnyJob, "AnyJob is not allowed")
val playerId: PlayerId = PlayerId(partyId, job, nick)
def withBiS(set: Option[BiS]): Player = set match {
case Some(value) => copy(bis = value)
case None => this
}
def withCounters(piece: Option[Piece]): PlayerIdWithCounters =
PlayerIdWithCounters(
partyId, job, nick, isRequired(piece), priority,
bisCountTotal(piece), lootCount(piece),
lootCountBiS(piece), lootCountTotal(piece))
def withLoot(piece: Piece): Player = withLoot(Seq(piece))
def withLoot(list: Seq[Piece]): Player = list match {
case Nil => this
case _ => copy(loot = list)
}
def isRequired(piece: Option[Piece]): Boolean = {
piece match {
case None => false
case Some(p) if !bis.hasPiece(p) => false
case Some(p: PieceUpgrade) => bis.upgrades(p) > lootCount(piece)
case Some(_) => lootCount(piece) == 0
}
}
def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(!_.isTome)
def lootCount(piece: Option[Piece]): Int = piece match {
case Some(p) => loot.count(_ == p)
case None => lootCountTotal(piece)
}
def lootCountBiS(piece: Option[Piece]): Int = loot.count(bis.hasPiece)
def lootCountTotal(piece: Option[Piece]): Int = loot.length
def lootPriority(piece: Piece): Int = priority
}

View File

@ -0,0 +1,35 @@
/*
* 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.models
import scala.util.Try
import scala.util.matching.Regex
trait PlayerIdBase {
def job: Job.Job
def nick: String
override def toString: String = s"$nick ($job)"
}
case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase
object PlayerId {
def apply(partyId: String, maybeNick: Option[String], maybeJob: Option[String]): Option[PlayerId] =
(maybeNick, maybeJob) match {
case (Some(nick), Some(job)) => Try(PlayerId(partyId, Job.withName(job), nick)).toOption
case _ => None
}
private val prettyPlayerIdRegex: Regex = "^(.*) \\(([A-Z]{3})\\)$".r
def apply(partyId: String, player: String): Option[PlayerId] = player match {
case s"${prettyPlayerIdRegex(nick, job)}" => Try(PlayerId(partyId, Job.withName(job), nick)).toOption
case _ => None
}
}

View File

@ -0,0 +1,53 @@
/*
* 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.models
case class PlayerIdWithCounters(partyId: String,
job: Job.Job,
nick: String,
isRequired: Boolean,
priority: Int,
bisCountTotal: Int,
lootCount: Int,
lootCountBiS: Int,
lootCountTotal: Int)
extends PlayerIdBase {
import PlayerIdWithCounters._
def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean =
withCounters(orderBy) > that.withCounters(orderBy)
def isRequiredToString: String = if (isRequired) "yes" else "no"
def playerId: PlayerId = PlayerId(partyId, job, nick)
private val counters: Map[String, Int] = Map(
"isRequired" -> (if (isRequired) 1 else 0), // true has more priority
"priority" -> -priority, // the less value the more priority
"bisCountTotal" -> bisCountTotal, // the more pieces in bis the more priority
"lootCount" -> -lootCount, // the less loot got the more priority
"lootCountBiS" -> -lootCountBiS, // the less bis pieces looted the more priority
"lootCountTotal" -> -lootCountTotal) withDefaultValue 0 // the less pieces looted the more priority
private def withCounters(orderBy: Seq[String]): PlayerCountersComparator =
PlayerCountersComparator(orderBy.map(counters): _*)
}
object PlayerIdWithCounters {
private case class PlayerCountersComparator(values: Int*) {
def >(that: PlayerCountersComparator): Boolean = {
@scala.annotation.tailrec
def compareLists(left: List[Int], right: List[Int]): Boolean =
(left, right) match {
case (hl :: tl, hr :: tr) => if (hl == hr) compareLists(tl, tr) else hl > hr
case (_ :: _, Nil) => true
case (_, _) => false
}
compareLists(values.toList, that.values.toList)
}
}
}

View File

@ -0,0 +1,26 @@
/*
* 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.models
import org.mindrot.jbcrypt.BCrypt
object Permission extends Enumeration {
val get, post, admin = Value
}
case class User(partyId: String,
username: String,
password: String,
permission: Permission.Value) {
def hash: String = BCrypt.hashpw(password, BCrypt.gensalt)
def verify(plain: String): Boolean = BCrypt.checkpw(plain, password)
def verityScope(scope: Permission.Value): Boolean = permission >= scope
def withHashedPassword: User = copy(password = hash)
}

View File

@ -0,0 +1,136 @@
/*
* 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

@ -0,0 +1,45 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.service
import akka.actor.Actor
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.{Party, Player, PlayerId}
import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.{ExecutionContext, Future}
trait Database extends Actor with StrictLogging {
implicit def executionContext: ExecutionContext
def profile: DatabaseProfile
override def postStop(): Unit = {
profile.db.close()
super.postStop()
}
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
(party, maybePlayerId) match {
case (_, Some(playerId)) => party.player(playerId).map(Seq(_)).getOrElse(Seq.empty)
case (_, _) => party.getPlayers
}
def getParty(partyId: String, withBiS: Boolean, withLoot: Boolean): Future[Party] =
for {
players <- profile.getParty(partyId)
bis <- if (withBiS) profile.getPiecesBiS(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)
}
object Database {
trait DatabaseRequest {
def partyId: String
}
}

View File

@ -0,0 +1,28 @@
/*
* 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 me.arcanis.ffxivbis.models.{Piece, Player, PlayerIdWithCounters}
class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) {
val counters: Seq[PlayerIdWithCounters] = players.map(_.withCounters(Some(piece)))
def suggest: LootSelector.LootSelectorResult =
LootSelector.LootSelectorResult {
counters.sortWith { case (left, right) => left.gt(right, orderBy) }
}
}
object LootSelector {
def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult =
new LootSelector(players, piece, orderBy).suggest
case class LootSelectorResult(result: Seq[PlayerIdWithCounters])
}

View File

@ -0,0 +1,72 @@
/*
* Copyright (c) 2019 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.service
import akka.actor.{Actor, ActorRef, Props}
import akka.pattern.{ask, pipe}
import akka.util.Timeout
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.Party
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future}
class PartyService(storage: ActorRef) extends Actor with StrictLogging {
import PartyService._
import me.arcanis.ffxivbis.utils.Implicits._
private val cacheTimeout: FiniteDuration =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.cache-timeout")
implicit private val executionContext: ExecutionContext = context.dispatcher
implicit private val timeout: Timeout =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.request-timeout")
override def receive: Receive = handle(Map.empty)
private def handle(cache: Map[String, Party]): Receive = {
case ForgetParty(partyId) =>
context become handle(cache - partyId)
case GetNewPartyId =>
val client = sender()
getPartyId.pipeTo(client)
case req @ impl.DatabasePartyHandler.GetParty(partyId) =>
val client = sender()
val party = cache.get(partyId) match {
case Some(party) => Future.successful(party)
case None =>
(storage ? req).mapTo[Party].map { party =>
context become handle(cache + (partyId -> party))
context.system.scheduler.scheduleOnce(cacheTimeout, self, ForgetParty(partyId))
party
}
}
party.pipeTo(client)
case req: Database.DatabaseRequest =>
self ! ForgetParty(req.partyId)
storage.forward(req)
}
private def getPartyId: Future[String] = {
val partyId = Party.randomPartyId
(storage ? impl.DatabaseUserHandler.Exists(partyId)).mapTo[Boolean].flatMap {
case true => getPartyId
case false => Future.successful(partyId)
}
}
}
object PartyService {
def props(storage: ActorRef): Props = Props(new PartyService(storage))
case class ForgetParty(partyId: String)
case object GetNewPartyId
}

View File

@ -0,0 +1,43 @@
/*
* 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.impl
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.{Piece, PlayerId}
import me.arcanis.ffxivbis.service.Database
trait DatabaseBiSHandler { this: Database =>
import DatabaseBiSHandler._
def bisHandler: Receive = {
case AddPieceToBis(playerId, piece) =>
val client = sender()
profile.insertPieceBiS(playerId, piece).pipeTo(client)
case GetBiS(partyId, maybePlayerId) =>
val client = sender()
getParty(partyId, withBiS = true, withLoot = false)
.map(filterParty(_, maybePlayerId))
.pipeTo(client)
case RemovePieceFromBiS(playerId, piece) =>
val client = sender()
profile.deletePieceBiS(playerId, piece).pipeTo(client)
}
}
object DatabaseBiSHandler {
case class AddPieceToBis(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest {
override def partyId: String = playerId.partyId
}
case class GetBiS(partyId: String, playerId: Option[PlayerId]) extends Database.DatabaseRequest
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest {
override def partyId: String = playerId.partyId
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.impl
import akka.actor.Props
import me.arcanis.ffxivbis.service.Database
import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.ExecutionContext
class DatabaseImpl extends Database
with DatabaseBiSHandler with DatabaseLootHandler
with DatabasePartyHandler with DatabaseUserHandler {
implicit val executionContext: ExecutionContext = context.dispatcher
val profile = new DatabaseProfile(executionContext, context.system.settings.config)
override def receive: Receive =
bisHandler orElse lootHandler orElse partyHandler orElse userHandler
}
object DatabaseImpl {
def props: Props = Props(new DatabaseImpl)
}

View File

@ -0,0 +1,48 @@
/*
* 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.impl
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.{Piece, PlayerId}
import me.arcanis.ffxivbis.service.Database
trait DatabaseLootHandler { this: Database =>
import DatabaseLootHandler._
def lootHandler: Receive = {
case AddPieceTo(playerId, piece) =>
val client = sender()
profile.insertPiece(playerId, piece).pipeTo(client)
case GetLoot(partyId, maybePlayerId) =>
val client = sender()
getParty(partyId, withBiS = false, withLoot = true)
.map(filterParty(_, maybePlayerId))
.pipeTo(client)
case RemovePieceFrom(playerId, piece) =>
val client = sender()
profile.deletePiece(playerId, piece).pipeTo(client)
case SuggestLoot(partyId, piece) =>
val client = sender()
getParty(partyId, withBiS = true, withLoot = true).map(_.suggestLoot(piece)).pipeTo(client)
}
}
object DatabaseLootHandler {
case class AddPieceTo(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest {
override def partyId: String = playerId.partyId
}
case class GetLoot(partyId: String, playerId: Option[PlayerId]) extends Database.DatabaseRequest
case class RemovePieceFrom(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest {
override def partyId: String = playerId.partyId
}
case class SuggestLoot(partyId: String, piece: Piece) extends Database.DatabaseRequest
}

View File

@ -0,0 +1,60 @@
/*
* 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.impl
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.{BiS, Player, PlayerId}
import me.arcanis.ffxivbis.service.Database
import scala.concurrent.Future
trait DatabasePartyHandler { this: Database =>
import DatabasePartyHandler._
def partyHandler: Receive = {
case AddPlayer(player) =>
val client = sender()
profile.insertPlayer(player).pipeTo(client)
case GetParty(partyId) =>
val client = sender()
getParty(partyId, withBiS = true, withLoot = true).pipeTo(client)
case GetPlayer(playerId) =>
val client = sender()
val player = profile.getPlayerFull(playerId).flatMap { maybePlayerData =>
Future.traverse(maybePlayerData.toSeq) { playerData =>
for {
bis <- profile.getPiecesBiS(playerId)
loot <- profile.getPieces(playerId)
} yield Player(playerId.partyId, playerId.job, playerId.nick,
BiS(bis.map(_.piece)), loot.map(_.piece),
playerData.link, playerData.priority)
}
}.map(_.headOption)
player.pipeTo(client)
case RemovePlayer(playerId) =>
val client = sender()
profile.deletePlayer(playerId).pipeTo(client)
}
}
object DatabasePartyHandler {
case class AddPlayer(player: Player) extends Database.DatabaseRequest {
override def partyId: String = player.partyId
}
case class GetParty(partyId: String) extends Database.DatabaseRequest
case class GetPlayer(playerId: PlayerId) extends Database.DatabaseRequest {
override def partyId: String = playerId.partyId
}
case class RemovePlayer(playerId: PlayerId) extends Database.DatabaseRequest {
override def partyId: String = playerId.partyId
}
}

View File

@ -0,0 +1,50 @@
/*
* 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.impl
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.User
import me.arcanis.ffxivbis.service.Database
trait DatabaseUserHandler { this: Database =>
import DatabaseUserHandler._
def userHandler: Receive = {
case AddUser(user, isHashedPassword) =>
val client = sender()
val toInsert = if (isHashedPassword) user else user.withHashedPassword
profile.insertUser(toInsert).pipeTo(client)
case DeleteUser(partyId, username) =>
val client = sender()
profile.deleteUser(partyId, username).pipeTo(client)
case Exists(partyId) =>
val client = sender()
profile.exists(partyId).pipeTo(client)
case GetUser(partyId, username) =>
val client = sender()
profile.getUser(partyId, username).pipeTo(client)
case GetUsers(partyId) =>
val client = sender()
profile.getUsers(partyId).pipeTo(client)
}
}
object DatabaseUserHandler {
case class AddUser(user: User, isHashedPassword: Boolean) extends Database.DatabaseRequest {
override def partyId: String = user.partyId
}
case class DeleteUser(partyId: String, username: String) extends Database.DatabaseRequest
case class Exists(partyId: String) extends Database.DatabaseRequest
case class GetUser(partyId: String, username: String) extends Database.DatabaseRequest
case class GetUsers(partyId: String) extends Database.DatabaseRequest
}

View File

@ -0,0 +1,54 @@
/*
* 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.storage
import me.arcanis.ffxivbis.models.{Job, Loot, Piece}
import slick.lifted.{ForeignKeyQuery, Index, PrimaryKey}
import scala.concurrent.Future
trait BiSProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class BiSRep(playerId: Long, created: Long, piece: String, isTome: Int, job: String) {
def toLoot: Loot = Loot(playerId, Piece(piece, isTome == 1, Job.withName(job)))
}
object BiSRep {
def fromPiece(playerId: Long, piece: Piece) =
BiSRep(playerId, DatabaseProfile.now, piece.piece, if (piece.isTome) 1 else 0,
piece.job.toString)
}
class BiSPieces(tag: Tag) extends Table[BiSRep](tag, "bis") {
def playerId: Rep[Long] = column[Long]("player_id", O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece", O.PrimaryKey)
def isTome: Rep[Int] = column[Int]("is_tome")
def job: Rep[String] = column[String]("job")
def * =
(playerId, created, piece, isTome, job) <> ((BiSRep.apply _).tupled, BiSRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
}
def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceBiS(BiSRep.fromPiece(playerId, piece)).delete)
def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId))
def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesBiS(playerIds).result).map(_.map(_.toLoot))
def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
db.run(bisTable.insertOrUpdate(BiSRep.fromPiece(playerId, piece)))
private def pieceBiS(piece: BiSRep) =
piecesBiS(Seq(piece.playerId)).filter(_.piece === piece.piece)
private def piecesBiS(playerIds: Seq[Long]) =
bisTable.filter(_.playerId.inSet(playerIds.toSet))
}

View File

@ -0,0 +1,70 @@
/*
* 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.storage
import java.time.Instant
import com.typesafe.config.Config
import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId}
import slick.basic.DatabaseConfig
import slick.jdbc.JdbcProfile
import scala.concurrent.{ExecutionContext, Future}
class DatabaseProfile(context: ExecutionContext, config: Config)
extends BiSProfile with LootProfile with PlayersProfile with UsersProfile {
implicit val executionContext: ExecutionContext = context
val dbConfig: DatabaseConfig[JdbcProfile] =
DatabaseConfig.forConfig[JdbcProfile]("", DatabaseProfile.getSection(config))
import dbConfig.profile.api._
val db = dbConfig.db
val bisTable: TableQuery[BiSPieces] = TableQuery[BiSPieces]
val lootTable: TableQuery[LootPieces] = TableQuery[LootPieces]
val playersTable: TableQuery[Players] = TableQuery[Players]
val usersTable: TableQuery[Users] = TableQuery[Users]
// generic bis api
def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, deletePieceBiSById(piece))
def getPiecesBiS(playerId: PlayerId): Future[Seq[Loot]] =
byPlayerId(playerId, getPiecesBiSById)
def getPiecesBiS(partyId: String): Future[Seq[Loot]] =
byPartyId(partyId, getPiecesBiSById)
def insertPieceBiS(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, insertPieceBiSById(piece))
// generic loot api
def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, deletePieceById(piece))
def getPieces(playerId: PlayerId): Future[Seq[Loot]] =
byPlayerId(playerId, getPiecesById)
def getPieces(partyId: String): Future[Seq[Loot]] =
byPartyId(partyId, getPiecesById)
def insertPiece(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, insertPieceById(piece))
private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] =
getPlayers(partyId).map(callback).flatten
private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] =
getPlayer(playerId).map {
case Some(id) => callback(id)
case None => Future.failed(new Error(s"Could not find player $playerId"))
}.flatten
}
object DatabaseProfile {
def now: Long = Instant.now.toEpochMilli
def getSection(config: Config): Config = {
val section = config.getString("me.arcanis.ffxivbis.database.mode")
config.getConfig("me.arcanis.ffxivbis.database").getConfig(section)
}
}

View File

@ -0,0 +1,61 @@
/*
* 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.storage
import me.arcanis.ffxivbis.models.{Job, Loot, Piece}
import slick.lifted.{ForeignKeyQuery, Index}
import scala.concurrent.Future
trait LootProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class LootRep(lootId: Option[Long], playerId: Long, created: Long, piece: String,
isTome: Int, job: String) {
def toLoot: Loot = Loot(playerId, Piece(piece, isTome == 1, Job.withName(job)))
}
object LootRep {
def fromPiece(playerId: Long, piece: Piece) =
LootRep(None, playerId, DatabaseProfile.now, piece.piece, if (piece.isTome) 1 else 0,
piece.job.toString)
}
class LootPieces(tag: Tag) extends Table[LootRep](tag, "loot") {
def lootId: Rep[Long] = column[Long]("loot_id", O.AutoInc, O.PrimaryKey)
def playerId: Rep[Long] = column[Long]("player_id")
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece")
def isTome: Rep[Int] = column[Int]("is_tome")
def job: Rep[String] = column[String]("job")
def * =
(lootId.?, playerId, created, piece, isTome, job) <> ((LootRep.apply _).tupled, LootRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
def lootOwnerIdx: Index =
index("loot_owner_idx", (playerId), unique = false)
}
def deletePieceById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceLoot(LootRep.fromPiece(playerId, piece)).map(_.lootId).max.result).flatMap {
case Some(id) => db.run(lootTable.filter(_.lootId === id).delete)
case _ => throw new IllegalArgumentException(s"Could not find piece $piece belong to $playerId")
}
def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId))
def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesLoot(playerIds).result).map(_.map(_.toLoot))
def insertPieceById(piece: Piece)(playerId: Long): Future[Int] =
db.run(lootTable.insertOrUpdate(LootRep.fromPiece(playerId, piece)))
private def pieceLoot(piece: LootRep) =
piecesLoot(Seq(piece.playerId)).filter(_.piece === piece.piece)
private def piecesLoot(playerIds: Seq[Long]) =
lootTable.filter(_.playerId.inSet(playerIds.toSet))
}

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.storage
import com.typesafe.config.Config
import org.flywaydb.core.Flyway
import org.flywaydb.core.api.configuration.ClassicConfiguration
import scala.concurrent.Future
class Migration(config: Config) {
def performMigration(): Future[Int] = {
val section = DatabaseProfile.getSection(config)
val url = section.getString("db.url")
val username = section.getString("db.user")
val password = section.getString("db.password")
val provider = url match {
case s"jdbc:$p:$_" => p
case other => throw new NotImplementedError(s"unknown could not parse jdbc url from $other")
}
val flywayConfiguration = new ClassicConfiguration
flywayConfiguration.setLocationsAsStrings(s"db/migration/$provider")
flywayConfiguration.setDataSource(url, username, password)
val flyway = new Flyway(flywayConfiguration)
Future.successful(flyway.migrate())
}
}
object Migration {
def apply(config: Config): Future[Int] = new Migration(config).performMigration()
}

View File

@ -0,0 +1,69 @@
/*
* 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.storage
import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId}
import slick.lifted.{Index, PrimaryKey}
import scala.concurrent.Future
trait PlayersProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class PlayerRep(partyId: String, playerId: Option[Long], created: Long, nick: String,
job: String, link: Option[String], priority: Int) {
def toPlayer: Player =
Player(partyId, Job.withName(job), nick, BiS(Seq.empty), List.empty, link, priority)
}
object PlayerRep {
def fromPlayer(player: Player, id: Option[Long]): PlayerRep =
PlayerRep(player.partyId, id, DatabaseProfile.now, player.nick,
player.job.toString, player.link, player.priority)
}
class Players(tag: Tag) extends Table[PlayerRep](tag, "players") {
def partyId: Rep[String] = column[String]("party_id")
def playerId: Rep[Long] = column[Long]("player_id", O.AutoInc, O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def nick: Rep[String] = column[String]("nick")
def job: Rep[String] = column[String]("job")
def bisLink: Rep[Option[String]] = column[Option[String]]("bis_link")
def priority: Rep[Int] = column[Int]("priority", O.Default(1))
def * =
(partyId, playerId.?, created, nick, job, bisLink, priority) <> ((PlayerRep.apply _).tupled, PlayerRep.unapply)
}
def deletePlayer(playerId: PlayerId): Future[Int] = db.run(player(playerId).delete)
def getParty(partyId: String): Future[Map[Long, Player]] =
db.run(players(partyId).result).map(_.foldLeft(Map.empty[Long, Player]) {
case (acc, p @ PlayerRep(_, Some(id), _, _, _, _, _)) => acc + (id -> p.toPlayer)
case (acc, _) => acc
})
def getPlayer(playerId: PlayerId): Future[Option[Long]] =
db.run(player(playerId).map(_.playerId).result.headOption)
def getPlayerFull(playerId: PlayerId): Future[Option[Player]] =
db.run(player(playerId).result.headOption.map(_.map(_.toPlayer)))
def getPlayers(partyId: String): Future[Seq[Long]] =
db.run(players(partyId).map(_.playerId).result)
def insertPlayer(playerObj: Player): Future[Int] =
getPlayer(playerObj.playerId).map {
case Some(id) => db.run(playersTable.update(PlayerRep.fromPlayer(playerObj, Some(id))))
case _ => db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(playerObj, None)))
}.flatten
private def player(playerId: PlayerId) =
playersTable
.filter(_.partyId === playerId.partyId)
.filter(_.job === playerId.job.toString)
.filter(_.nick === playerId.nick)
private def players(partyId: String) =
playersTable.filter(_.partyId === partyId)
}

View File

@ -0,0 +1,61 @@
/*
* 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.storage
import me.arcanis.ffxivbis.models.{Permission, User}
import slick.lifted.{Index, PrimaryKey}
import scala.concurrent.Future
trait UsersProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class UserRep(partyId: String, userId: Option[Long], username: String, password: String,
permission: String) {
def toUser: User = User(partyId, username, password, Permission.withName(permission))
}
object UserRep {
def fromUser(user: User, id: Option[Long]): UserRep =
UserRep(user.partyId, id, user.username, user.password, user.permission.toString)
}
class Users(tag: Tag) extends Table[UserRep](tag, "users") {
def partyId: Rep[String] = column[String]("party_id")
def userId: Rep[Long] = column[Long]("user_id", O.AutoInc, O.PrimaryKey)
def username: Rep[String] = column[String]("username")
def password: Rep[String] = column[String]("password")
def permission: Rep[String] = column[String]("permission")
def * =
(partyId, userId.?, username, password, permission) <> ((UserRep.apply _).tupled, UserRep.unapply)
def pk: PrimaryKey = primaryKey("users_username_idx", (partyId, username))
def usersUsernameIdx: Index =
index("users_username_idx", (partyId, username), unique = true)
}
def deleteUser(partyId: String, username: String): Future[Int] =
db.run(user(partyId, Some(username)).delete)
def exists(partyId: String): Future[Boolean] =
db.run(user(partyId, None).exists.result)
def getUser(partyId: String, username: String): Future[Option[User]] =
db.run(user(partyId, Some(username)).result.headOption).map(_.map(_.toUser))
def getUsers(partyId: String): Future[Seq[User]] =
db.run(user(partyId, None).result).map(_.map(_.toUser))
def insertUser(userObj: User): Future[Int] =
db.run(user(userObj.partyId, Some(userObj.username)).map(_.userId).result.headOption).map {
case Some(id) => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, Some(id))))
case _ => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, None)))
}.flatten
private def user(partyId: String, username: Option[String]) =
usersTable
.filter(_.partyId === partyId)
.filterIf(username.isDefined)(_.username === username.orNull)
}

View File

@ -0,0 +1,30 @@
/*
* 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.utils
import java.time.Duration
import java.util.concurrent.TimeUnit
import akka.util.Timeout
import scala.concurrent.duration.FiniteDuration
import scala.language.implicitConversions
object Implicits {
implicit def getBooleanFromOptionString(maybeYes: Option[String]): Boolean = maybeYes.map(_.toLowerCase) match {
case Some("yes" | "on") => true
case _ => false
}
implicit def getFiniteDuration(duration: Duration): FiniteDuration =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
implicit def getTimeout(duration: Duration): Timeout =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
}

View File

@ -1,53 +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
#
from aiohttp.web import middleware, Request, Response
from aiohttp_security import AbstractAuthorizationPolicy, check_permission
from typing import Callable, Optional
from service.core.database import Database
class AuthorizationPolicy(AbstractAuthorizationPolicy):
def __init__(self, database: Database) -> None:
self.database = database
async def authorized_userid(self, identity: str) -> Optional[str]:
user = await self.database.get_user(identity)
return identity if user is not None else None
async def permits(self, identity: str, permission: str, context: str = None) -> bool:
user = await self.database.get_user(identity)
if user is None:
return False
if user.username != identity:
return False
if user.permission == 'admin':
return True
return permission == 'get' or user.permission == permission
def authorize_factory() -> Callable:
allowed_paths = {'/', '/favicon.ico', '/api/v1/login', '/api/v1/logout'}
allowed_paths_groups = {'/api-docs', '/static'}
@middleware
async def authorize(request: Request, handler: Callable) -> Response:
if request.path.startswith('/admin'):
permission = 'admin'
else:
permission = 'get' if request.method in ('GET', 'HEAD') else 'post'
if request.path not in allowed_paths \
and not any(request.path.startswith(path) for path in allowed_paths_groups):
await check_permission(request, permission)
return await handler(request)
return authorize

View File

@ -1,34 +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
#
from enum import Enum
from json import JSONEncoder
from typing import Any
class HttpEncoder(JSONEncoder):
def default(self, obj: Any) -> Any:
if isinstance(obj, dict):
data = {}
for key, value in obj.items():
data[key] = self.default(value)
return data
elif isinstance(obj, Enum):
return obj.name
elif hasattr(obj, '_ast'):
return self.default(obj._ast())
elif hasattr(obj, '__iter__') and not isinstance(obj, str):
return [self.default(value) for value in obj]
elif hasattr(obj, '__dict__'):
data = {
key: self.default(value)
for key, value in obj.__dict__.items()
if not callable(value) and not key.startswith('_')}
return data
else:
return obj

View File

@ -1,66 +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
#
from aiohttp.web import Application
from .views.api.bis import BiSView
from .views.api.login import LoginView
from .views.api.logout import LogoutView
from .views.api.loot import LootView
from .views.api.player import PlayerView
from .views.html.api import ApiDocVIew, ApiHtmlView
from .views.html.bis import BiSHtmlView
from .views.html.index import IndexHtmlView
from .views.html.loot import LootHtmlView
from .views.html.loot_suggest import LootSuggestHtmlView
from .views.html.player import PlayerHtmlView
from .views.html.static import StaticHtmlView
from .views.html.users import UsersHtmlView
def setup_routes(app: Application) -> None:
# api routes
app.router.add_delete('/admin/api/v1/login/{username}', LoginView)
app.router.add_post('/api/v1/login', LoginView)
app.router.add_post('/api/v1/logout', LogoutView)
app.router.add_put('/admin/api/v1/login', LoginView)
app.router.add_get('/api/v1/party', PlayerView)
app.router.add_post('/api/v1/party', PlayerView)
app.router.add_get('/api/v1/party/bis', BiSView)
app.router.add_post('/api/v1/party/bis', BiSView)
app.router.add_put('/api/v1/party/bis', BiSView)
app.router.add_get('/api/v1/party/loot', LootView)
app.router.add_post('/api/v1/party/loot', LootView)
app.router.add_put('/api/v1/party/loot', LootView)
# html routes
app.router.add_get('/', IndexHtmlView)
app.router.add_get('/static/{resource_id}', StaticHtmlView)
app.router.add_get('/api-docs', ApiHtmlView)
app.router.add_get('/api-docs/swagger.json', ApiDocVIew)
app.router.add_get('/party', PlayerHtmlView)
app.router.add_post('/party', PlayerHtmlView)
app.router.add_get('/bis', BiSHtmlView)
app.router.add_post('/bis', BiSHtmlView)
app.router.add_get('/loot', LootHtmlView)
app.router.add_post('/loot', LootHtmlView)
app.router.add_get('/suggest', LootSuggestHtmlView)
app.router.add_post('/suggest', LootSuggestHtmlView)
app.router.add_get('/admin/users', UsersHtmlView)
app.router.add_post('/admin/users', UsersHtmlView)

View File

@ -1,78 +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
#
from aiohttp.web import Application
from apispec import APISpec
from service.core.version import __version__
from service.models.action import Action
from service.models.bis import BiS, BiSLink
from service.models.error import Error
from service.models.job import Job
from service.models.loot import Loot
from service.models.piece import Piece
from service.models.player import Player, PlayerId, PlayerIdWithCounters
from service.models.player_edit import PlayerEdit
from service.models.upgrade import Upgrade
from service.models.user import User
def get_spec(app: Application) -> APISpec:
spec = APISpec(
title='FFXIV loot helper',
version=__version__,
openapi_version='3.0.2',
info=dict(description='Loot manager for FFXIV statics'),
)
# routes
for route in app.router.routes():
path = route.get_info().get('path') or route.get_info().get('formatter')
method = route.method.lower()
spec_method = f'endpoint_{method}_spec'
if not hasattr(route.handler, spec_method):
continue
operations = getattr(route.handler, spec_method)()
if not operations:
continue
spec.path(path, operations={method: operations})
# components
spec.components.schema(Action.model_name(), Action.model_spec())
spec.components.schema(BiS.model_name(), BiS.model_spec())
spec.components.schema(BiSLink.model_name(), BiSLink.model_spec())
spec.components.schema(Error.model_name(), Error.model_spec())
spec.components.schema(Job.model_name(), Job.model_spec())
spec.components.schema(Loot.model_name(), Loot.model_spec())
spec.components.schema(Piece.model_name(), Piece.model_spec())
spec.components.schema(Player.model_name(), Player.model_spec())
spec.components.schema(PlayerEdit.model_name(), PlayerEdit.model_spec())
spec.components.schema(PlayerId.model_name(), PlayerId.model_spec())
spec.components.schema(PlayerIdWithCounters.model_name(), PlayerIdWithCounters.model_spec())
spec.components.schema(Upgrade.model_name(), Upgrade.model_spec())
spec.components.schema(User.model_name(), User.model_spec())
# default responses
spec.components.response('BadRequest', dict(
description='Bad parameters applied or bad request was formed',
content={'application/json': {'schema': {'$ref': Error.model_ref('Error')}}}
))
spec.components.response('Forbidden', dict(
description='User permissions do not allow this action'
))
spec.components.response('ServerError', dict(
description='Server was unable to process request',
content={'application/json': {'schema': {'$ref': Error.model_ref('Error')}}}
))
spec.components.response('Unauthorized', dict(
description='User was not authorized'
))
return spec

View File

@ -1,42 +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
#
import json
from aiohttp.web import HTTPException, Response
from typing import Any, Mapping, List
from .json import HttpEncoder
def make_json(response: Any) -> str:
return json.dumps(response, cls=HttpEncoder, sort_keys=True)
def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response:
if isinstance(exception, HTTPException):
raise exception # reraise return
return wrap_json({
'message': repr(exception),
'arguments': dict(args)
}, code)
def wrap_invalid_param(params: List[str], args: Mapping[str, Any], code: int = 400) -> Response:
return wrap_json({
'message': f'invalid or missing parameters: `{params}`',
'arguments': dict(args)
}, code)
def wrap_json(response: Any, code: int = 200) -> Response:
return Response(
text=make_json(response),
status=code,
content_type='application/json'
)

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