Compare commits
34 Commits
Author | SHA1 | Date | |
---|---|---|---|
964ab26007 | |||
6b07ec7d07 | |||
a64caa4081 | |||
80345c3a6f | |||
55c68fee2c | |||
2f95fab825 | |||
81840b3d34 | |||
0adbf7c5c0 | |||
b214786415 | |||
d9a891c6b5 | |||
50184da1f6 | |||
0bc94e4b7d | |||
a1107b7100 | |||
9949c537a0 | |||
d277b5c5cd | |||
9334c3a47d | |||
9410cbf6a5 | |||
e44aa9ddfc | |||
5413fba309 | |||
780f0be433 | |||
1a1806ffc3 | |||
377a260a2f | |||
e0cc81c97e | |||
6ac4b03a0a | |||
2f69c86645 | |||
a53eeffb0f | |||
0980d01640 | |||
8cb76d6ff4 | |||
4074ad0c97 | |||
08c8dcb07b | |||
c87c35021e | |||
fb5c78ac4f | |||
e482e7764d | |||
e831a925f5 |
@ -1,15 +0,0 @@
|
||||
# NOTE: For maximum performance, build using a nightly compiler
|
||||
# If you are using rust stable, remove the "-Zshare-generics=y" below.
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "/usr/bin/clang"
|
||||
rustflags = ["-Clink-arg=-fuse-ld=lld", "-Zshare-generics=y"]
|
||||
|
||||
[target.x86_64-apple-darwin]
|
||||
rustflags = ["-Zshare-generics=y"]
|
||||
|
||||
# NOTE: you must manually install lld on windows. you can easily do this with the "scoop" package manager:
|
||||
# `scoop install llvm`
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "lld-link.exe"
|
||||
rustflags = ["-Clinker=lld", "-Zshare-generics=y"]
|
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
target
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,6 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.env
|
||||
.env.docker
|
||||
Session.vim
|
||||
src/db/refinery.toml
|
||||
tags
|
||||
|
802
Cargo.lock
generated
802
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@ -8,25 +8,31 @@ edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
bincode = "1.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenv = "0.15"
|
||||
http-api-problem = { version = "0.17", features = ["with-warp"] }
|
||||
hyper = "0.13"
|
||||
lazy_static = "1.4"
|
||||
listenfd = "0.3"
|
||||
mime = "0.3"
|
||||
openssl-probe = "0.1"
|
||||
tokio = { version = "0.2", features = ["macros", "rt-threaded", "sync"] }
|
||||
sqlx = { version = "0.4.0-beta.1", default-features = false, features = [ "runtime-tokio", "macros", "postgres", "chrono", "uuid", "ipnetwork", "json" ] }
|
||||
warp = { version = "0.2", features = ["compression"] }
|
||||
refinery = { version = "0.3.0", features = [ "tokio-postgres", "tokio" ] }
|
||||
barrel = { version = "0.6.5", features = [ "pg" ] }
|
||||
clap = "3.0.0-beta.1"
|
||||
sqlx = { version = "0.4.1", default-features = false, features = [ "runtime-tokio-rustls", "macros", "postgres", "chrono", "uuid", "ipnetwork", "json", "migrate", "offline" ] }
|
||||
warp = { version = "0.2", features = ["compression", "tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "0.8", features = ["serde", "v4"] }
|
||||
ipnetwork = "0.17"
|
||||
url = "2.1"
|
||||
async-trait = "0.1"
|
||||
seahash = "4.0"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.1"
|
||||
tracing-subscriber = "0.2"
|
||||
tracing-futures = "0.2"
|
||||
lru = "0.5"
|
||||
http = "0.2"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@ -0,0 +1,24 @@
|
||||
# -*- mode: dockerfile -*-
|
||||
|
||||
# You can override this `--build-arg BASE_IMAGE=...` to use different
|
||||
# version of Rust or OpenSSL.
|
||||
ARG BASE_IMAGE=ekidd/rust-musl-builder:nightly-2020-10-08
|
||||
|
||||
# Our first FROM statement declares the build environment.
|
||||
FROM ${BASE_IMAGE} AS builder
|
||||
|
||||
# Add our source code.
|
||||
ADD --chown=rust:rust . ./
|
||||
|
||||
ENV SQLX_OFFLINE true
|
||||
# Build our application.
|
||||
RUN cargo build --release
|
||||
|
||||
# Now, we need to build our _real_ Docker container, copying in `using-sqlx`.
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates
|
||||
COPY --from=builder \
|
||||
/home/rust/src/target/x86_64-unknown-linux-musl/release/bazaar_realm_api \
|
||||
/usr/local/bin/
|
||||
|
||||
CMD /usr/local/bin/bazaar_realm_api
|
175
README.md
175
README.md
@ -1,45 +1,111 @@
|
||||
# BazaarRealmAPI
|
||||
|
||||
The API for the Bazaar Realm Skyrim mod which is responsible for storing and
|
||||
serving data related to the mod to all users.
|
||||
|
||||
Right now, the types of data the API stores and the endpoints to access them
|
||||
are (all prefixed under `/v1`, the API version):
|
||||
|
||||
* `/owners`: Every player character that has registered with this API server.
|
||||
Contains their unique api key. Owners own shops.
|
||||
* `/shops`: Metadata about each shop including name, description, and who owns
|
||||
it.
|
||||
* `/interior_ref_lists`: Lists of in-game ObjectReferences that are in the
|
||||
interior of individual shops. When a user visits a shop, these references
|
||||
are loaded into the cell.
|
||||
* `/merchandise_lists`: Lists of in-game Forms that are in the merchant chest
|
||||
of individual shops. When a user visits a shop, these forms are loaded
|
||||
onto the shop's shelves and are purchasable.
|
||||
- `/owners`: Every player character that has registered with this API server.
|
||||
Contains their unique api key. Owners own shops.
|
||||
- `/shops`: Metadata about each shop including name, description, and who owns
|
||||
it.
|
||||
- `/interior_ref_lists`: Lists of in-game ObjectReferences that are in the
|
||||
interior of individual shops. When a user visits a shop, these references
|
||||
are loaded into the cell.
|
||||
- `/merchandise_lists`: Lists of in-game Forms that are in the merchant chest
|
||||
of individual shops. When a user visits a shop, these forms are loaded
|
||||
onto the shop's shelves and are purchasable.
|
||||
- `/transactions`: Allows posting a new buy or sell between an owner and a
|
||||
shop's merchandise.
|
||||
|
||||
Bazaar Realm was designed to allow users to change the API they are using the
|
||||
mod under, if they wish. The API can run on a small server with minimal
|
||||
resources, which should be suitable for a small group of friends to share
|
||||
shops with each other.
|
||||
resources, which should be suitable for a group of friends to share shops
|
||||
with each other.
|
||||
|
||||
It uses the [`warp`](https://crates.io/crates/warp) web server framework and
|
||||
[`sqlx`](https://crates.io/crates/sqlx) for database queries to a [PostgreSQL
|
||||
database](https://www.postgresql.org).
|
||||
|
||||
The API was designed with performance as a high priority. When it serves a
|
||||
response, it also caches that response so future queries for the same data
|
||||
can be returned in less than 1ms. To reduce data sent over the network,
|
||||
clients can use the
|
||||
[ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
|
||||
headers to indicate to the server what version of the data they have cached
|
||||
so the server can send a 304 response with no data if the resource hasn't
|
||||
changed since the client last requested. Using the
|
||||
[Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept)
|
||||
header, clients can also opt for the more space-efficient and faster to
|
||||
deserialize [bincode](https://github.com/servo/bincode) format instead of the
|
||||
JSON default.
|
||||
|
||||
Related projects:
|
||||
|
||||
* [`BazaarRealmClient`](https://github.com/thallada/BazaarRealmClient): DLL that
|
||||
- [`BazaarRealmClient`](https://github.com/thallada/BazaarRealmClient): DLL that
|
||||
handles requests and responses to this API
|
||||
* [`BazaarRealmPlugin`](https://github.com/thallada/BazaarRealmPlugin):
|
||||
- [`BazaarRealmPlugin`](https://github.com/thallada/BazaarRealmPlugin):
|
||||
[SKSE](https://skse.silverlock.org/) plugin for the mod that modifies data
|
||||
within the Skyrim game engine
|
||||
* [`BazaarRealmMod`](https://github.com/thallada/BazaarRealmMod): Papyrus
|
||||
- [`BazaarRealmMod`](https://github.com/thallada/BazaarRealmMod): Papyrus
|
||||
scripts, ESP plugin, and all other resources for the mod
|
||||
|
||||
## Development Setup
|
||||
## Docker Setup
|
||||
|
||||
The easiest way to get the server up and running is using Docker.
|
||||
|
||||
1. Download and install [Docker Desktop](https://www.docker.com/get-started)
|
||||
2. In PowerShell, cmd.exe, or a terminal run `docker pull postgres:alpine` then `docker pull thallada/bazaarrealm:latest`
|
||||
3. Run (replacing `<password>` with a secure generated password):
|
||||
|
||||
```
|
||||
docker run -d --name postgres --network=bazaarrealm --network-alias=db --env POSTGRES_DB=bazaarrealm --env POSTGRES_USER=bazaarrealm --env POSTGRES_PASSWORD=<password> postgres:alpine
|
||||
```
|
||||
|
||||
4. Run (replacing `<password>` with what you generated in previous step):
|
||||
|
||||
```
|
||||
docker run -d --name bazaarrealm -p 3030:3030 --network=bazaarrealm --network-alias=api --env DATABASE_URL=postgresql://bazaarrealm:<password>@db/bazaarrealm --env HOST=http://localhost:3030 thallada/bazaarrealm:latest
|
||||
```
|
||||
|
||||
5. The server should now be available at `http://localhost:3030`.
|
||||
|
||||
## Docker-Compose Setup
|
||||
|
||||
An alternative way to set up the API, is to use `docker-compose` which can
|
||||
orchestrate setting up the database and web server containers for you. This
|
||||
method is more useful if you would like to make changes to the API code and
|
||||
test them out.
|
||||
|
||||
1. Download and install [Docker Desktop](https://www.docker.com/get-started)
|
||||
2. Git clone this repo into a folder of your choosing: `git clone https://github.com/thallada/BazaarRealmAPI.git`
|
||||
3. Create a new file `.env.docker` in the checked out `bazaar_realm_api`
|
||||
folder with the contents (replacing `<password>` with a secure generated
|
||||
password):
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://bazaarrealm:<password>@db/bazaarrealm"
|
||||
RUST_LOG="bazaar_realm_api=debug,warp=info"
|
||||
HOST="http://localhost:3030"
|
||||
PORT=3030
|
||||
POSTGRES_DB=bazaarrealm
|
||||
POSTGRES_USER=bazaarrealm
|
||||
POSTGRES_PASSWORD=<password>
|
||||
```
|
||||
|
||||
3. In the checked out repo, run: `docker-compose build`
|
||||
4. Once that completes, run: `docker-compose up`
|
||||
|
||||
## Manual Development Setup
|
||||
|
||||
If you would prefer to run the server outside Docker on your host machine, do
|
||||
the following steps to get everything setup.
|
||||
|
||||
1. Install and run postgres.
|
||||
2. Create postgres user and database (and add uuid extension while you're there
|
||||
2. Create postgres user and database (and add uuid extension while you're there
|
||||
):
|
||||
|
||||
```
|
||||
createuser bazaarrealm
|
||||
createdb bazaarrealm
|
||||
@ -57,27 +123,44 @@ postgres=# ALTER DATABASE bazaarrealm OWNER TO bazaarrealm;
|
||||
\password bazaarrealm
|
||||
postgres=# CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
```
|
||||
3. Save password somewhere safe and then and add a `.env` file to the project
|
||||
|
||||
3. Save password somewhere safe and then and add a `.env` file to the project
|
||||
directory with the contents:
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://bazaarrealm:<password>@localhost/bazaarrealm
|
||||
RUST_LOG="bazaar_realm_api=debug"
|
||||
HOST="http://localhost:3030"
|
||||
PORT=3030
|
||||
```
|
||||
4. Create a new file at `src/db/refinery.toml` with the contents:
|
||||
|
||||
4. Install
|
||||
[`sqlx_cli`](https://github.com/launchbadge/sqlx/tree/master/sqlx-cli) with
|
||||
`cargo install --version=0.1.0-beta.1 sqlx-cli --no-default-features --features postgres`
|
||||
5. Run `sqlx migrate --source db/migrations run` which will run all the database
|
||||
migrations.
|
||||
6. Run `./devserver.sh` to run the dev server (by default it listens at
|
||||
`127.0.0.1:3030`). Note that this runs the server in debug mode and shouldn't
|
||||
be used to serve requests from the mod. You can build the release version of
|
||||
the server with `cargo build --release`.
|
||||
|
||||
## TLS setup
|
||||
|
||||
If you would like to access the server over HTTPS, you can use [Let's
|
||||
Encrypt](https://letsencrypt.org/) to generate a SSL certificate and key and
|
||||
provide it to the API. Once you use [certbot](https://certbot.eff.org/) to
|
||||
generate the certificate and key for your domain in
|
||||
`/etc/letsencrypt/live/<domain>/`, run the api server with:
|
||||
|
||||
```
|
||||
[main]
|
||||
db_type = "Postgres"
|
||||
db_host = "localhost"
|
||||
db_port = "5432"
|
||||
db_user = "bazaarrealm"
|
||||
db_pass = "<database-password-here>"
|
||||
db_name = "bazaarrealm"
|
||||
docker run -d --name bazaarrealm --network=host --env DATABASE_URL=postgresql://bazaarrealm:<password>@localhost/bazaarrealm --env PORT=443 --HOST=https://<domain> --env TLS_CERT=/etc/letsencrypt/live/<domain>/fullchain.pem --env TLS_KEY=/etc/letsencrypt/live/<domain>/privkey.pem -v /etc/letsencrypt/:/etc/letsencrypt/ thallada/bazaarrealm:latest
|
||||
```
|
||||
5. Run `cargo run -- -m` which will compile the app in debug mode and run the
|
||||
database migrations.
|
||||
6. Run `./devserver.sh` to run the dev server (by default it listens at
|
||||
`127.0.0.1:3030`).
|
||||
|
||||
This command assumes that you are on Linux and you have a running postgres
|
||||
database already set up outside of docker. See Manual Development Setup for
|
||||
database setup instructions.
|
||||
|
||||
The server should be accessible at your domain: `https://<domain>`.
|
||||
|
||||
## Testing Data
|
||||
|
||||
@ -91,8 +174,8 @@ same one in all future requests.
|
||||
```
|
||||
http POST "http://localhost:3030/v1/owners" @test_data\owner.json api-key:"13e2f39c-033f-442f-b42a-7ad640d2e439"
|
||||
http POST "http://localhost:3030/v1/shops" @test_data\shop.json api-key:"13e2f39c-033f-442f-b42a-7ad640d2e439"
|
||||
http POST "http://localhost:3030/v1/interior_ref_lists" @test_data\interior_ref_list.json api-key:"13e2f39c-033f-442f-b42a-7ad640d2e439"
|
||||
http POST "http://localhost:3030/v1/merchandise_lists" @test_data\merchandise_list.json api-key:"13e2f39c-033f-442f-b42a-7ad640d2e439"
|
||||
http PATCH "http://localhost:3030/v1/shops/1/interior_ref_list" @test_data\interior_ref_list.json api-key:"13e2f39c-033f-442f-b42a-7ad640d2e439"
|
||||
http PATCH "http://localhost:3030/v1/shops/1/merchandise_list" @test_data\merchandise_list.json api-key:"13e2f39c-033f-442f-b42a-7ad640d2e439"
|
||||
# Then, you can test the GET endpoints
|
||||
http GET "http://localhost:3030/v1/owners"
|
||||
http GET "http://localhost:3030/v1/shops"
|
||||
@ -100,6 +183,20 @@ http GET "http://localhost:3030/v1/interior_ref_lists"
|
||||
http GET "http://localhost:3030/v1/merchandise_lists"
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Migrations are handled by `sqlx`. When the server initially starts, it will
|
||||
connect to the database and check if there are any migrations in
|
||||
`db/migrations` that have not yet been applied. It will apply any at that
|
||||
time and then continue starting the server.
|
||||
|
||||
A new migration can be created by running: `sqlx migrate add <name>`.
|
||||
|
||||
To allow the docker container for the API to get built in CI without a
|
||||
database, the `sqlx-data.json` file needs to be re-generated every time the
|
||||
database schema changes or any query is updated. It can be generated with `cargo
|
||||
sqlx prepare`.
|
||||
|
||||
## Authentication
|
||||
|
||||
I don't want to require users of Bazaar Realm to have to remember a password,
|
||||
@ -109,14 +206,6 @@ unique UUID identifier instead. This is the api key that the
|
||||
The api key is stored in the save game files for the player character and is
|
||||
required to be sent with any API request that modifies data.
|
||||
|
||||
Yes, it's not most secure solution, but I'm not convinced security is a huge
|
||||
concern here. As long as users don't share their API key or the save game
|
||||
files that contain it, their data should be secure.
|
||||
|
||||
## Todo
|
||||
|
||||
* Add update endpoints.
|
||||
* Add endpoints for the other models.
|
||||
* Make self-contained docker container that can run the app without any setup.
|
||||
* Add rate-limiting per IP address. The `tower` crate has a service that might
|
||||
be useful for this.
|
||||
Yes, it's not the most secure solution, but I'm not convinced security is a
|
||||
huge concern here. As long as users don't share their API key or the save
|
||||
game files that contain it, their data should be secure.
|
||||
|
71
db/migrations/20201111205247_initial.sql
Normal file
71
db/migrations/20201111205247_initial.sql
Normal file
@ -0,0 +1,71 @@
|
||||
CREATE TABLE IF NOT EXISTS "owners" (
|
||||
"id" SERIAL PRIMARY KEY NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"api_key" UUID NOT NULL UNIQUE,
|
||||
"ip_address" inet,
|
||||
"mod_version" INTEGER NOT NULL,
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "owners_unique_name_and_api_key" ON "owners" ("name", "api_key");
|
||||
CREATE TABLE "shops" (
|
||||
"id" SERIAL PRIMARY KEY NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"description" TEXT,
|
||||
"gold" INTEGER NOT NULL DEFAULT 0
|
||||
CONSTRAINT "shop_gold_gt_zero" CHECK (gold >= 0),
|
||||
"shop_type" VARCHAR(255) NOT NULL DEFAULT 'general_store',
|
||||
"vendor_keywords" TEXT[] NOT NULL DEFAULT '{"VendorItemKey", "VendorNoSale"}',
|
||||
"vendor_keywords_exclude" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "shops_unique_name_and_owner_id" ON "shops" ("name", "owner_id");
|
||||
CREATE TABLE "interior_ref_lists" (
|
||||
"id" SERIAL PRIMARY KEY NOT NULL,
|
||||
"shop_id" INTEGER REFERENCES "shops"(id) NOT NULL UNIQUE,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"ref_list" jsonb NOT NULL,
|
||||
"shelves" jsonb NOT NULL,
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE TABLE "merchandise_lists" (
|
||||
"id" SERIAL PRIMARY KEY NOT NULL,
|
||||
"shop_id" INTEGER REFERENCES "shops"(id) NOT NULL UNIQUE,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"form_list" jsonb NOT NULL
|
||||
CONSTRAINT "merchandise_quantity_gt_zero" CHECK (NOT jsonb_path_exists(form_list, '$[*].quantity ? (@ < 1)')),
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE INDEX "merchandise_lists_mod_name_and_local_form_id" ON "merchandise_lists" USING GIN (form_list jsonb_path_ops);
|
||||
CREATE TABLE "vendors" (
|
||||
"id" SERIAL PRIMARY KEY NOT NULL,
|
||||
"shop_id" INTEGER REFERENCES "shops"(id) NOT NULL UNIQUE,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"body_preset" INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "vendors_unique_name_and_owner_id" ON "vendors" ("name", "owner_id", "shop_id");
|
||||
CREATE TABLE "transactions" (
|
||||
"id" SERIAL PRIMARY KEY NOT NULL,
|
||||
"shop_id" INTEGER REFERENCES "shops"(id) NOT NULL,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"mod_name" VARCHAR(260) NOT NULL,
|
||||
"local_form_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"form_type" INTEGER NOT NULL,
|
||||
"is_food" BOOLEAN NOT NULL,
|
||||
"price" INTEGER NOT NULL,
|
||||
"is_sell" BOOLEAN NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"keywords" TEXT[] NOT NULL DEFAULT '{}',
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE INDEX "transactions_shop_id" ON "transactions" ("shop_id");
|
||||
CREATE INDEX "transactions_owner_id" ON "transactions" ("owner_id");
|
||||
CREATE INDEX "transactions_mod_name_and_local_form_id" ON "transactions" ("mod_name", "local_form_id");
|
7
db/reset_db.pgsql
Normal file
7
db/reset_db.pgsql
Normal file
@ -0,0 +1,7 @@
|
||||
DROP TABLE owners CASCADE;
|
||||
DROP TABLE shops CASCADE;
|
||||
DROP TABLE interior_ref_lists CASCADE;
|
||||
DROP TABLE merchandise_lists CASCADE;
|
||||
DROP TABLE transactions CASCADE;
|
||||
DROP TABLE vendors CASCADE;
|
||||
DROP TABLE _sqlx_migrations CASCADE;
|
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@ -0,0 +1,22 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
volumes:
|
||||
- cargo:/home/rust/.cargo
|
||||
- target:/home/rust/src/target
|
||||
env_file:
|
||||
- .env.docker
|
||||
ports:
|
||||
- "3030:3030"
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: postgres:alpine
|
||||
env_file:
|
||||
- .env.docker
|
||||
|
||||
volumes:
|
||||
cargo: {}
|
||||
target: {}
|
@ -1,35 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "owners" (
|
||||
"id" BIGSERIAL PRIMARY KEY NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"api_key" UUID NOT NULL UNIQUE,
|
||||
"ip_address" inet,
|
||||
"mod_version" INTEGER NOT NULL,
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "owners_unique_name_and_api_key" ON "owners" ("name", "api_key");
|
||||
CREATE TABLE "shops" (
|
||||
"id" BIGSERIAL PRIMARY KEY NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"description" TEXT,
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "shops_unique_name_and_owner_id" ON "shops" ("name", "owner_id");
|
||||
CREATE TABLE "interior_ref_lists" (
|
||||
"id" BIGSERIAL PRIMARY KEY NOT NULL,
|
||||
"shop_id" INTEGER REFERENCES "shops"(id) NOT NULL UNIQUE,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"ref_list" jsonb NOT NULL,
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
||||
CREATE TABLE "merchandise_lists" (
|
||||
"id" BIGSERIAL PRIMARY KEY NOT NULL,
|
||||
"shop_id" INTEGER REFERENCES "shops"(id) NOT NULL UNIQUE,
|
||||
"owner_id" INTEGER REFERENCES "owners"(id) NOT NULL,
|
||||
"form_list" jsonb NOT NULL,
|
||||
"created_at" timestamp(3) NOT NULL,
|
||||
"updated_at" timestamp(3) NOT NULL
|
||||
);
|
2188
sqlx-data.json
Normal file
2188
sqlx-data.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -24,8 +24,8 @@ where
|
||||
|
||||
impl<K, V> Cache<K, V>
|
||||
where
|
||||
K: Eq + Hash + Debug,
|
||||
V: Clone,
|
||||
K: Eq + Hash + Debug + Send,
|
||||
V: Clone + Send,
|
||||
{
|
||||
pub fn new(name: &str, capacity: usize) -> Self {
|
||||
Cache {
|
||||
@ -48,7 +48,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get<G, F>(&self, key: K, getter: G) -> Result<V>
|
||||
pub async fn get<G, F>(&'static self, key: K, getter: G) -> Result<V>
|
||||
where
|
||||
G: Fn() -> F,
|
||||
F: Future<Output = Result<V>>,
|
||||
@ -62,8 +62,13 @@ where
|
||||
|
||||
self.log_with_key(&key, "get: miss");
|
||||
let value = getter().await?;
|
||||
let mut guard = self.lru_mutex.lock().await;
|
||||
guard.put(key, value.clone());
|
||||
|
||||
let to_cache = value.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut guard = self.lru_mutex.lock().await;
|
||||
self.log_with_key(&key, "get: update cache");
|
||||
guard.put(key, to_cache);
|
||||
});
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
@ -85,10 +90,10 @@ where
|
||||
|
||||
impl<K> Cache<K, CachedResponse>
|
||||
where
|
||||
K: Eq + Hash + Debug,
|
||||
K: Eq + Hash + Debug + Send,
|
||||
{
|
||||
pub async fn get_response<G, F, R>(
|
||||
&self,
|
||||
&'static self,
|
||||
key: K,
|
||||
getter: G,
|
||||
) -> Result<CachedResponse, Rejection>
|
||||
@ -106,21 +111,27 @@ where
|
||||
|
||||
self.log_with_key(&key, "get_response: miss");
|
||||
let reply = getter().await.map_err(reject_anyhow);
|
||||
let cached_response = match reply {
|
||||
Ok(reply) => CachedResponse::from_reply(reply)
|
||||
.await
|
||||
.map_err(reject_anyhow)?,
|
||||
Ok(match reply {
|
||||
Ok(reply) => {
|
||||
let cached_response = CachedResponse::from_reply(reply)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let to_cache = cached_response.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut guard = self.lru_mutex.lock().await;
|
||||
self.log_with_key(&key, "get_response: update cache");
|
||||
guard.put(key, to_cache);
|
||||
});
|
||||
cached_response
|
||||
}
|
||||
Err(rejection) => {
|
||||
self.log_with_key(&key, "get_response: getter returned rejection, not caching");
|
||||
let reply = unpack_problem(rejection).await?;
|
||||
CachedResponse::from_reply(reply)
|
||||
.await
|
||||
.map_err(reject_anyhow)?
|
||||
}
|
||||
};
|
||||
let mut guard = self.lru_mutex.lock().await;
|
||||
guard.put(key, cached_response.clone());
|
||||
|
||||
Ok(cached_response)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_response(&self, key: K) -> Option<CachedResponse> {
|
||||
|
@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use http::header::ETAG;
|
||||
use http::{HeaderMap, HeaderValue, Response, StatusCode, Version};
|
||||
use hyper::body::{to_bytes, Body, Bytes};
|
||||
use warp::Reply;
|
||||
@ -24,6 +25,17 @@ impl CachedResponse {
|
||||
body: to_bytes(response.body_mut()).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn not_modified(etag: HeaderValue) -> Self {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(ETAG, etag);
|
||||
Self {
|
||||
status: StatusCode::NOT_MODIFIED,
|
||||
version: Version::HTTP_11,
|
||||
headers,
|
||||
body: Bytes::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Reply for CachedResponse {
|
||||
|
@ -9,19 +9,39 @@ mod cached_response;
|
||||
pub use cache::Cache;
|
||||
pub use cached_response::CachedResponse;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CACHES: Caches = Caches::initialize();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Caches {
|
||||
pub owner_ids_by_api_key: Cache<Uuid, i32>,
|
||||
pub shop: Cache<i32, CachedResponse>,
|
||||
pub shop_bin: Cache<i32, CachedResponse>,
|
||||
pub owner: Cache<i32, CachedResponse>,
|
||||
pub owner_bin: Cache<i32, CachedResponse>,
|
||||
pub interior_ref_list: Cache<i32, CachedResponse>,
|
||||
pub interior_ref_list_bin: Cache<i32, CachedResponse>,
|
||||
pub merchandise_list: Cache<i32, CachedResponse>,
|
||||
pub merchandise_list_bin: Cache<i32, CachedResponse>,
|
||||
pub transaction: Cache<i32, CachedResponse>,
|
||||
pub transaction_bin: Cache<i32, CachedResponse>,
|
||||
pub list_shops: Cache<ListParams, CachedResponse>,
|
||||
pub list_shops_bin: Cache<ListParams, CachedResponse>,
|
||||
pub list_owners: Cache<ListParams, CachedResponse>,
|
||||
pub list_owners_bin: Cache<ListParams, CachedResponse>,
|
||||
pub list_interior_ref_lists: Cache<ListParams, CachedResponse>,
|
||||
pub list_interior_ref_lists_bin: Cache<ListParams, CachedResponse>,
|
||||
pub list_merchandise_lists: Cache<ListParams, CachedResponse>,
|
||||
pub list_merchandise_lists_bin: Cache<ListParams, CachedResponse>,
|
||||
pub list_transactions: Cache<ListParams, CachedResponse>,
|
||||
pub list_transactions_bin: Cache<ListParams, CachedResponse>,
|
||||
pub list_transactions_by_shop_id: Cache<(i32, ListParams), CachedResponse>,
|
||||
pub list_transactions_by_shop_id_bin: Cache<(i32, ListParams), CachedResponse>,
|
||||
pub interior_ref_list_by_shop_id: Cache<i32, CachedResponse>,
|
||||
pub interior_ref_list_by_shop_id_bin: Cache<i32, CachedResponse>,
|
||||
pub merchandise_list_by_shop_id: Cache<i32, CachedResponse>,
|
||||
pub merchandise_list_by_shop_id_bin: Cache<i32, CachedResponse>,
|
||||
}
|
||||
|
||||
impl Caches {
|
||||
@ -29,15 +49,31 @@ impl Caches {
|
||||
Caches {
|
||||
owner_ids_by_api_key: Cache::new("owner_ids_by_api_key", 100).log_keys(false),
|
||||
shop: Cache::new("shop", 100),
|
||||
shop_bin: Cache::new("shop_bin", 100),
|
||||
owner: Cache::new("owner", 100),
|
||||
owner_bin: Cache::new("owner_bin", 100),
|
||||
interior_ref_list: Cache::new("interior_ref_list", 100),
|
||||
interior_ref_list_bin: Cache::new("interior_ref_list_bin", 100),
|
||||
merchandise_list: Cache::new("merchandise_list", 100),
|
||||
merchandise_list_bin: Cache::new("merchandise_list_bin", 100),
|
||||
transaction: Cache::new("transaction", 100),
|
||||
transaction_bin: Cache::new("transaction_bin", 100),
|
||||
list_shops: Cache::new("list_shops", 100),
|
||||
list_shops_bin: Cache::new("list_shops_bin", 100),
|
||||
list_owners: Cache::new("list_owners", 100),
|
||||
list_owners_bin: Cache::new("list_owners_bin", 100),
|
||||
list_interior_ref_lists: Cache::new("list_interior_ref_lists", 100),
|
||||
list_interior_ref_lists_bin: Cache::new("list_interior_ref_lists_bin", 100),
|
||||
list_merchandise_lists: Cache::new("list_merchandise_lists", 100),
|
||||
list_merchandise_lists_bin: Cache::new("list_merchandise_lists_bin", 100),
|
||||
list_transactions: Cache::new("list_transaction", 100),
|
||||
list_transactions_bin: Cache::new("list_transaction_bin", 100),
|
||||
list_transactions_by_shop_id: Cache::new("list_transaction_by_shop_id", 100),
|
||||
list_transactions_by_shop_id_bin: Cache::new("list_transaction_by_shop_id_bin", 100),
|
||||
interior_ref_list_by_shop_id: Cache::new("interior_ref_list_by_shop_id", 100),
|
||||
interior_ref_list_by_shop_id_bin: Cache::new("interior_ref_list_by_shop_id_bin", 100),
|
||||
merchandise_list_by_shop_id: Cache::new("merchandise_list_by_shop_id", 100),
|
||||
merchandise_list_by_shop_id_bin: Cache::new("merchandise_list_by_shop_id_bin", 100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,76 +0,0 @@
|
||||
use barrel::{backend::Pg, types, Migration};
|
||||
|
||||
pub fn migration() -> String {
|
||||
let mut m = Migration::new();
|
||||
|
||||
m.create_table("owners", |t| {
|
||||
t.add_column("id", types::primary().indexed(true));
|
||||
t.add_column("name", types::varchar(255));
|
||||
t.add_column("api_key", types::uuid().indexed(true));
|
||||
t.add_column("ip_address", types::custom("inet").nullable(true));
|
||||
t.add_column("mod_version", types::integer());
|
||||
t.add_column("created_at", types::custom("timestamp(3)"));
|
||||
t.add_column("updated_at", types::custom("timestamp(3)"));
|
||||
t.add_index(
|
||||
"owners_unique_name_and_api_key",
|
||||
types::index(vec!["name", "api_key"]).unique(true),
|
||||
);
|
||||
});
|
||||
|
||||
m.create_table("shops", |t| {
|
||||
t.add_column("id", types::primary().indexed(true));
|
||||
t.add_column("name", types::varchar(255));
|
||||
t.add_column("owner_id", types::foreign("owners", "id").indexed(true));
|
||||
t.add_column("description", types::text().nullable(true));
|
||||
// removing these until I figure out the plan for buying and selling
|
||||
// t.add_column("is_not_sell_buy", types::boolean().default(true));
|
||||
// t.add_column("sell_buy_list_id", types::integer().default(0));
|
||||
// t.add_column("vendor_id", types::integer());
|
||||
// t.add_column("vendor_gold", types::integer());
|
||||
t.add_column("created_at", types::custom("timestamp(3)"));
|
||||
t.add_column("updated_at", types::custom("timestamp(3)"));
|
||||
t.add_index(
|
||||
"shops_unique_name_and_owner_id",
|
||||
types::index(vec!["name", "owner_id"]).unique(true),
|
||||
);
|
||||
});
|
||||
|
||||
m.create_table("merchandise_lists", |t| {
|
||||
t.add_column("id", types::primary().indexed(true));
|
||||
t.add_column(
|
||||
"shop_id",
|
||||
types::foreign("shops", "id").indexed(true).unique(true),
|
||||
);
|
||||
t.add_column("owner_id", types::foreign("owners", "id").indexed(true));
|
||||
t.add_column("form_list", types::custom("jsonb"));
|
||||
t.add_column("created_at", types::custom("timestamp(3)"));
|
||||
t.add_column("updated_at", types::custom("timestamp(3)"));
|
||||
});
|
||||
|
||||
// m.create_table("transactions", |t| {
|
||||
// t.add_column("id", types::primary().indexed(true));
|
||||
// t.add_column("shop_id", types::foreign("shops", "id").indexed(true));
|
||||
// t.add_column("owner_id", types::foreign("owners", "id").indexed(true));
|
||||
// t.add_column("merchandise_list_id", types::foreign("merchandise_lists", "id"));
|
||||
// t.add_column("customer_name", types::varchar(255));
|
||||
// t.add_column("is_customer_npc", types::boolean());
|
||||
// t.add_column("is_customer_buying", types::boolean());
|
||||
// t.add_column("quantity", types::integer());
|
||||
// t.add_column("is_void", types::boolean());
|
||||
// t.add_column("created_at", types::custom("timestamp(3)"));
|
||||
// });
|
||||
|
||||
m.create_table("interior_ref_lists", |t| {
|
||||
t.add_column("id", types::primary().indexed(true));
|
||||
t.add_column(
|
||||
"shop_id",
|
||||
types::foreign("shops", "id").indexed(true).unique(true),
|
||||
);
|
||||
t.add_column("owner_id", types::foreign("owners", "id").indexed(true));
|
||||
t.add_column("ref_list", types::custom("jsonb"));
|
||||
t.add_column("created_at", types::custom("timestamp(3)"));
|
||||
t.add_column("updated_at", types::custom("timestamp(3)"));
|
||||
});
|
||||
|
||||
m.make::<Pg>()
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
use refinery::include_migration_mods;
|
||||
|
||||
include_migration_mods!("src/db/migrations");
|
@ -1,16 +0,0 @@
|
||||
use refinery::config::Config;
|
||||
|
||||
mod migrations;
|
||||
|
||||
pub async fn migrate() {
|
||||
let mut config = Config::from_file_location("src/db/refinery.toml").unwrap();
|
||||
|
||||
match migrations::runner().run_async(&mut config).await {
|
||||
Ok(report) => {
|
||||
dbg!(report.applied_migrations());
|
||||
}
|
||||
Err(error) => {
|
||||
dbg!(error);
|
||||
}
|
||||
};
|
||||
}
|
293
src/handlers/interior_ref_list.rs
Normal file
293
src/handlers/interior_ref_list.rs
Normal file
@ -0,0 +1,293 @@
|
||||
use anyhow::Result;
|
||||
use http::StatusCode;
|
||||
use hyper::body::Bytes;
|
||||
use mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::{CachedResponse, CACHES};
|
||||
use crate::models::{InteriorRefList, ListParams, PostedInteriorRefList};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, DeserializedBody,
|
||||
ETagReply, Json, TypedCache,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<i32, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.interior_ref_list_bin,
|
||||
&CACHES.interior_ref_list,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(id, || async {
|
||||
let interior_ref_list = InteriorRefList::get(&env.db, id).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&interior_ref_list)?)
|
||||
}
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&interior_ref_list)?)
|
||||
}
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn get_by_shop_id(
|
||||
shop_id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<i32, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.interior_ref_list_by_shop_id_bin,
|
||||
&CACHES.interior_ref_list_by_shop_id,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(shop_id, || async {
|
||||
let interior_ref_list = InteriorRefList::get_by_shop_id(&env.db, shop_id).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&interior_ref_list)?)
|
||||
}
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&interior_ref_list)?)
|
||||
}
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
list_params: ListParams,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<ListParams, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.list_interior_ref_lists_bin,
|
||||
&CACHES.list_interior_ref_lists,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(list_params.clone(), || async {
|
||||
let interior_ref_lists = InteriorRefList::list(&env.db, &list_params).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(ETagReply::<Bincode>::from_serializable(
|
||||
&interior_ref_lists,
|
||||
)?),
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&interior_ref_lists)?)
|
||||
}
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: mut interior_ref_list,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedInteriorRefList>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
interior_ref_list.owner_id = Some(owner_id);
|
||||
let saved_interior_ref_list = InteriorRefList::create(interior_ref_list, &env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = saved_interior_ref_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&saved_interior_ref_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => Box::new(
|
||||
ETagReply::<Json>::from_serializable(&saved_interior_ref_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.list_interior_ref_lists.clear().await;
|
||||
CACHES.list_interior_ref_lists_bin.clear().await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(saved_interior_ref_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id_bin
|
||||
.delete_response(saved_interior_ref_list.shop_id)
|
||||
.await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
id: i32,
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: interior_ref_list,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedInteriorRefList>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let updated_interior_ref_list =
|
||||
InteriorRefList::update(interior_ref_list, &env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_interior_ref_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&updated_interior_ref_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => Box::new(
|
||||
ETagReply::<Json>::from_serializable(&updated_interior_ref_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.interior_ref_list.delete_response(id).await;
|
||||
CACHES.interior_ref_list_bin.delete_response(id).await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(updated_interior_ref_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id_bin
|
||||
.delete_response(updated_interior_ref_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_interior_ref_lists.clear().await;
|
||||
CACHES.list_interior_ref_lists_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update_by_shop_id(
|
||||
shop_id: i32,
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: interior_ref_list,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedInteriorRefList>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let updated_interior_ref_list =
|
||||
InteriorRefList::update_by_shop_id(interior_ref_list, &env.db, owner_id, shop_id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_interior_ref_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&updated_interior_ref_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => Box::new(
|
||||
ETagReply::<Json>::from_serializable(&updated_interior_ref_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES
|
||||
.interior_ref_list
|
||||
.delete_response(updated_interior_ref_list.id)
|
||||
.await;
|
||||
CACHES
|
||||
.interior_ref_list_bin
|
||||
.delete_response(updated_interior_ref_list.id)
|
||||
.await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(updated_interior_ref_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id_bin
|
||||
.delete_response(updated_interior_ref_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_interior_ref_lists.clear().await;
|
||||
CACHES.list_interior_ref_lists_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let interior_ref_list = InteriorRefList::get(&env.db, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
InteriorRefList::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
tokio::spawn(async move {
|
||||
CACHES.interior_ref_list.delete_response(id).await;
|
||||
CACHES.interior_ref_list_bin.delete_response(id).await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(interior_ref_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id_bin
|
||||
.delete_response(interior_ref_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_interior_ref_lists.clear().await;
|
||||
CACHES.list_interior_ref_lists_bin.clear().await;
|
||||
});
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
290
src/handlers/merchandise_list.rs
Normal file
290
src/handlers/merchandise_list.rs
Normal file
@ -0,0 +1,290 @@
|
||||
use anyhow::Result;
|
||||
use http::StatusCode;
|
||||
use hyper::body::Bytes;
|
||||
use mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::{CachedResponse, CACHES};
|
||||
use crate::models::{ListParams, MerchandiseList, PostedMerchandiseList};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, DeserializedBody,
|
||||
ETagReply, Json, TypedCache,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<i32, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.merchandise_list_bin,
|
||||
&CACHES.merchandise_list,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(id, || async {
|
||||
let merchandise_list = MerchandiseList::get(&env.db, id).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&merchandise_list)?)
|
||||
}
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&merchandise_list)?)
|
||||
}
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn get_by_shop_id(
|
||||
shop_id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<i32, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.merchandise_list_by_shop_id_bin,
|
||||
&CACHES.merchandise_list_by_shop_id,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(shop_id, || async {
|
||||
let merchandise_list = MerchandiseList::get_by_shop_id(&env.db, shop_id).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&merchandise_list)?)
|
||||
}
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&merchandise_list)?)
|
||||
}
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
list_params: ListParams,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<ListParams, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.list_merchandise_lists_bin,
|
||||
&CACHES.list_merchandise_lists,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(list_params.clone(), || async {
|
||||
let merchandise_lists = MerchandiseList::list(&env.db, &list_params).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&merchandise_lists)?)
|
||||
}
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&merchandise_lists)?)
|
||||
}
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: mut merchandise_list,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedMerchandiseList>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
merchandise_list.owner_id = Some(owner_id);
|
||||
let saved_merchandise_list = MerchandiseList::create(merchandise_list, &env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = saved_merchandise_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&saved_merchandise_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => Box::new(
|
||||
ETagReply::<Json>::from_serializable(&saved_merchandise_list).map_err(reject_anyhow)?,
|
||||
),
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.list_merchandise_lists.clear().await;
|
||||
CACHES.list_merchandise_lists_bin.clear().await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(saved_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id_bin
|
||||
.delete_response(saved_merchandise_list.shop_id)
|
||||
.await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
id: i32,
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: merchandise_list,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedMerchandiseList>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let updated_merchandise_list = MerchandiseList::update(merchandise_list, &env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_merchandise_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&updated_merchandise_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => Box::new(
|
||||
ETagReply::<Json>::from_serializable(&updated_merchandise_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.merchandise_list.delete_response(id).await;
|
||||
CACHES.merchandise_list_bin.delete_response(id).await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id_bin
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_merchandise_lists.clear().await;
|
||||
CACHES.list_merchandise_lists_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update_by_shop_id(
|
||||
shop_id: i32,
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: merchandise_list,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedMerchandiseList>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let updated_merchandise_list =
|
||||
MerchandiseList::update_by_shop_id(merchandise_list, &env.db, owner_id, shop_id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_merchandise_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&updated_merchandise_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => Box::new(
|
||||
ETagReply::<Json>::from_serializable(&updated_merchandise_list)
|
||||
.map_err(reject_anyhow)?,
|
||||
),
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES
|
||||
.merchandise_list
|
||||
.delete_response(updated_merchandise_list.id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_bin
|
||||
.delete_response(updated_merchandise_list.id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id_bin
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_merchandise_lists.clear().await;
|
||||
CACHES.list_merchandise_lists_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let merchandise_list = MerchandiseList::get(&env.db, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
MerchandiseList::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
tokio::spawn(async move {
|
||||
CACHES.merchandise_list.delete_response(id).await;
|
||||
CACHES.merchandise_list_bin.delete_response(id).await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id_bin
|
||||
.delete_response(merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_merchandise_lists.clear().await;
|
||||
CACHES.list_merchandise_lists_bin.clear().await;
|
||||
});
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
@ -1,24 +1,37 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use http::StatusCode;
|
||||
use ipnetwork::IpNetwork;
|
||||
use sqlx::types::Json;
|
||||
use std::net::SocketAddr;
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{json, with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
use std::marker::PhantomData;
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::models::{
|
||||
InteriorRefList, ListParams, MerchandiseList, MerchandiseParams, Model, Owner, Shop,
|
||||
UpdateableModel,
|
||||
};
|
||||
use super::problem::{reject_anyhow, unauthorized_no_api_key, unauthorized_no_owner};
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use http::header::{HeaderValue, CONTENT_TYPE, ETAG, SERVER};
|
||||
use http::StatusCode;
|
||||
use http_api_problem::HttpApiProblem;
|
||||
use hyper::body::Bytes;
|
||||
use mime::{FromStrError, Mime};
|
||||
use seahash::hash;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use tracing::{debug, error, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
use warp::reply::Response;
|
||||
use warp::Reply;
|
||||
|
||||
pub mod interior_ref_list;
|
||||
pub mod merchandise_list;
|
||||
pub mod owner;
|
||||
pub mod shop;
|
||||
pub mod transaction;
|
||||
|
||||
use super::caches::{Cache, CachedResponse, CACHES};
|
||||
use super::problem::{unauthorized_no_api_key, unauthorized_no_owner};
|
||||
use super::Environment;
|
||||
|
||||
pub static SERVER_STRING: &str = "BazaarRealmAPI/0.1.0";
|
||||
|
||||
#[instrument(level = "debug", skip(env, api_key))]
|
||||
pub async fn authenticate(env: &Environment, api_key: Option<Uuid>) -> Result<i32> {
|
||||
if let Some(api_key) = api_key {
|
||||
env.caches
|
||||
CACHES
|
||||
.owner_ids_by_api_key
|
||||
.get(api_key, || async {
|
||||
Ok(
|
||||
@ -40,611 +53,210 @@ pub async fn authenticate(env: &Environment, api_key: Option<Uuid>) -> Result<i3
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_shop(id: i32, env: Environment) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.shop
|
||||
.get_response(id, || async {
|
||||
let shop = Shop::get(&env.db, id).await?;
|
||||
let reply = json(&shop);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
// Similar to `warp::reply::Json`, but stores hash of body content for the ETag header created in `into_response`.
|
||||
// Also, it does not store a serialize `Result`. Instead it returns the error to the caller immediately in `from_serializable`.
|
||||
// It's purpose is to avoid serializing the body content twice and to encapsulate ETag logic in one place.
|
||||
pub struct ETagReply<T> {
|
||||
body: Vec<u8>,
|
||||
etag: String,
|
||||
content_type: PhantomData<T>,
|
||||
}
|
||||
|
||||
pub async fn list_shops(
|
||||
list_params: ListParams,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.list_shops
|
||||
.get_response(list_params.clone(), || async {
|
||||
let shops = Shop::list(&env.db, &list_params).await?;
|
||||
let reply = json(&shops);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
pub trait DataReply: Reply + Sized {
|
||||
fn from_serializable<T: Serialize>(val: &T) -> Result<Self>;
|
||||
}
|
||||
|
||||
pub async fn create_shop(
|
||||
shop: Shop,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let shop_with_owner_id = Shop {
|
||||
owner_id: Some(owner_id),
|
||||
..shop
|
||||
};
|
||||
let saved_shop = shop_with_owner_id
|
||||
.create(&env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
pub struct Json {}
|
||||
pub struct Bincode {}
|
||||
|
||||
// also save empty interior_ref_list and merchandise_list rows
|
||||
if let Some(shop_id) = saved_shop.id {
|
||||
let interior_ref_list = InteriorRefList {
|
||||
id: None,
|
||||
shop_id,
|
||||
owner_id: Some(owner_id),
|
||||
ref_list: Json::default(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
interior_ref_list
|
||||
.create(&env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let merchandise_list = MerchandiseList {
|
||||
id: None,
|
||||
shop_id,
|
||||
owner_id: Some(owner_id),
|
||||
form_list: Json::default(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
merchandise_list
|
||||
.create(&env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
}
|
||||
|
||||
let url = saved_shop.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply = json(&saved_shop);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.list_shops.clear().await;
|
||||
Ok(reply)
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ContentType {
|
||||
Json,
|
||||
Bincode,
|
||||
}
|
||||
|
||||
pub async fn update_shop(
|
||||
id: i32,
|
||||
shop: Shop,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let shop_with_id_and_owner_id = if shop.owner_id.is_some() {
|
||||
// allows an owner to transfer ownership of shop to another owner
|
||||
Shop {
|
||||
id: Some(id),
|
||||
..shop
|
||||
impl Reply for ETagReply<Json> {
|
||||
fn into_response(self) -> Response {
|
||||
let mut res = Response::new(self.body.into());
|
||||
res.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
res.headers_mut()
|
||||
.insert(SERVER, HeaderValue::from_static(SERVER_STRING));
|
||||
if let Ok(val) = HeaderValue::from_str(&self.etag) {
|
||||
res.headers_mut().insert(ETAG, val);
|
||||
} else {
|
||||
// This should never happen in practice since etag values should only be hex-encoded strings
|
||||
warn!("omitting etag header with invalid ASCII characters")
|
||||
}
|
||||
} else {
|
||||
Shop {
|
||||
id: Some(id),
|
||||
owner_id: Some(owner_id),
|
||||
..shop
|
||||
}
|
||||
};
|
||||
let updated_shop = shop_with_id_and_owner_id
|
||||
.update(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_shop.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply = json(&updated_shop);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.shop.delete_response(id).await;
|
||||
env.caches.list_shops.clear().await;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn delete_shop(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
Shop::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
env.caches.shop.delete_response(id).await;
|
||||
env.caches.list_shops.clear().await;
|
||||
env.caches
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(id)
|
||||
.await;
|
||||
env.caches
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(id)
|
||||
.await;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn get_owner(id: i32, env: Environment) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.owner
|
||||
.get_response(id, || async {
|
||||
let owner = Owner::get(&env.db, id).await?;
|
||||
let reply = json(&owner);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_owners(
|
||||
list_params: ListParams,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.list_owners
|
||||
.get_response(list_params.clone(), || async {
|
||||
let owners = Owner::list(&env.db, &list_params).await?;
|
||||
let reply = json(&owners);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_owner(
|
||||
owner: Owner,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
api_key: Option<Uuid>,
|
||||
real_ip: Option<IpNetwork>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
if let Some(api_key) = api_key {
|
||||
let owner_with_ip_and_key = match remote_addr {
|
||||
Some(addr) => Owner {
|
||||
api_key: Some(api_key),
|
||||
ip_address: Some(IpNetwork::from(addr.ip())),
|
||||
..owner
|
||||
},
|
||||
None => Owner {
|
||||
api_key: Some(api_key),
|
||||
ip_address: real_ip,
|
||||
..owner
|
||||
},
|
||||
};
|
||||
let saved_owner = owner_with_ip_and_key
|
||||
.create(&env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = saved_owner.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply = json(&saved_owner);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.list_owners.clear().await;
|
||||
Ok(reply)
|
||||
} else {
|
||||
Err(reject_anyhow(unauthorized_no_api_key()))
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_owner(
|
||||
id: i32,
|
||||
owner: Owner,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let owner_with_id = Owner {
|
||||
id: Some(id),
|
||||
..owner
|
||||
};
|
||||
let updated_owner = owner_with_id
|
||||
.update(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_owner.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply = json(&updated_owner);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.owner.delete_response(id).await;
|
||||
env.caches.list_owners.clear().await;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn delete_owner(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
Owner::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
env.caches.owner.delete_response(id).await;
|
||||
env.caches
|
||||
.owner_ids_by_api_key
|
||||
.delete(api_key.expect("api-key has been validated during authenticate"))
|
||||
.await;
|
||||
env.caches.list_owners.clear().await;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn get_interior_ref_list(id: i32, env: Environment) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.interior_ref_list
|
||||
.get_response(id, || async {
|
||||
let interior_ref_list = InteriorRefList::get(&env.db, id).await?;
|
||||
let reply = json(&interior_ref_list);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
impl DataReply for ETagReply<Json> {
|
||||
fn from_serializable<T: Serialize>(val: &T) -> Result<Self> {
|
||||
let bytes = serde_json::to_vec(val).map_err(|err| {
|
||||
error!("Failed to serialize database value to JSON: {}", err);
|
||||
anyhow!(HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
)
|
||||
.set_detail(format!(
|
||||
"Failed to serialize database value to JSON: {}",
|
||||
err
|
||||
)))
|
||||
})?;
|
||||
let etag = format!("{:x}", hash(&bytes));
|
||||
Ok(Self {
|
||||
body: bytes,
|
||||
etag,
|
||||
content_type: PhantomData,
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_interior_ref_lists(
|
||||
list_params: ListParams,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.list_interior_ref_lists
|
||||
.get_response(list_params.clone(), || async {
|
||||
let interior_ref_lists = InteriorRefList::list(&env.db, &list_params).await?;
|
||||
let reply = json(&interior_ref_lists);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_interior_ref_list(
|
||||
interior_ref_list: InteriorRefList,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let ref_list_with_owner_id = InteriorRefList {
|
||||
owner_id: Some(owner_id),
|
||||
..interior_ref_list
|
||||
};
|
||||
let saved_interior_ref_list = ref_list_with_owner_id
|
||||
.create(&env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = saved_interior_ref_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply = json(&saved_interior_ref_list);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.list_interior_ref_lists.clear().await;
|
||||
env.caches
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(saved_interior_ref_list.shop_id)
|
||||
.await;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update_interior_ref_list(
|
||||
id: i32,
|
||||
interior_ref_list: InteriorRefList,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let interior_ref_list_with_id_and_owner_id = if interior_ref_list.owner_id.is_some() {
|
||||
InteriorRefList {
|
||||
id: Some(id),
|
||||
..interior_ref_list
|
||||
impl Reply for ETagReply<Bincode> {
|
||||
fn into_response(self) -> Response {
|
||||
let mut res = Response::new(self.body.into());
|
||||
res.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
res.headers_mut()
|
||||
.insert(SERVER, HeaderValue::from_static(SERVER_STRING));
|
||||
if let Ok(val) = HeaderValue::from_str(&self.etag) {
|
||||
res.headers_mut().insert(ETAG, val);
|
||||
} else {
|
||||
// This should never happen in practice since etag values should only be hex-encoded strings
|
||||
warn!("omitting etag header with invalid ASCII characters")
|
||||
}
|
||||
} else {
|
||||
InteriorRefList {
|
||||
id: Some(id),
|
||||
owner_id: Some(owner_id),
|
||||
..interior_ref_list
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
impl DataReply for ETagReply<Bincode> {
|
||||
fn from_serializable<T: Serialize>(val: &T) -> Result<Self> {
|
||||
let bytes = bincode::serialize(val).map_err(|err| {
|
||||
error!("Failed to serialize database value to bincode: {}", err);
|
||||
anyhow!(HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
)
|
||||
.set_detail(format!(
|
||||
"Failed to serialize database value to bincode: {}",
|
||||
err
|
||||
)))
|
||||
})?;
|
||||
let etag = format!("{:x}", hash(&bytes));
|
||||
Ok(Self {
|
||||
body: bytes,
|
||||
etag,
|
||||
content_type: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_etag(etag: Option<String>, response: CachedResponse) -> CachedResponse {
|
||||
if let Some(request_etag) = etag {
|
||||
if let Some(response_etag) = response.headers.get("etag") {
|
||||
if request_etag == *response_etag {
|
||||
return CachedResponse::not_modified(response_etag.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
let updated_interior_ref_list = interior_ref_list_with_id_and_owner_id
|
||||
.update(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_interior_ref_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply = json(&updated_interior_ref_list);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.interior_ref_list.delete_response(id).await;
|
||||
env.caches
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(updated_interior_ref_list.shop_id)
|
||||
.await;
|
||||
env.caches.list_interior_ref_lists.clear().await;
|
||||
Ok(reply)
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn update_interior_ref_list_by_shop_id(
|
||||
shop_id: i32,
|
||||
interior_ref_list: InteriorRefList,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let interior_ref_list_with_owner_id = InteriorRefList {
|
||||
owner_id: Some(owner_id),
|
||||
..interior_ref_list
|
||||
};
|
||||
let updated_interior_ref_list = interior_ref_list_with_owner_id
|
||||
.update_by_shop_id(&env.db, owner_id, shop_id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_interior_ref_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply = json(&updated_interior_ref_list);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches
|
||||
.interior_ref_list
|
||||
.delete_response(
|
||||
updated_interior_ref_list
|
||||
.id
|
||||
.expect("saved interior_ref_list has no id"),
|
||||
)
|
||||
.await;
|
||||
env.caches
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(updated_interior_ref_list.shop_id)
|
||||
.await;
|
||||
env.caches.list_interior_ref_lists.clear().await;
|
||||
Ok(reply)
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct AcceptHeader {
|
||||
mimes: Vec<Mime>,
|
||||
}
|
||||
|
||||
pub async fn delete_interior_ref_list(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let interior_ref_list = InteriorRefList::get(&env.db, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
InteriorRefList::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
env.caches.interior_ref_list.delete_response(id).await;
|
||||
env.caches.list_interior_ref_lists.clear().await;
|
||||
env.caches
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(interior_ref_list.shop_id)
|
||||
.await;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
impl FromStr for AcceptHeader {
|
||||
type Err = Error;
|
||||
|
||||
pub async fn get_interior_ref_list_by_shop_id(
|
||||
shop_id: i32,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.interior_ref_list_by_shop_id
|
||||
.get_response(shop_id, || async {
|
||||
let interior_ref_list = InteriorRefList::get_by_shop_id(&env.db, shop_id).await?;
|
||||
let reply = json(&interior_ref_list);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
mimes: s
|
||||
.split(',')
|
||||
.map(|part| part.trim().parse::<Mime>())
|
||||
.collect::<std::result::Result<Vec<Mime>, FromStrError>>()?,
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: probably need a way to get by shop id instead
|
||||
pub async fn get_merchandise_list(id: i32, env: Environment) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.merchandise_list
|
||||
.get_response(id, || async {
|
||||
let merchandise_list = MerchandiseList::get(&env.db, id).await?;
|
||||
let reply = json(&merchandise_list);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
impl AcceptHeader {
|
||||
pub fn accepts_bincode(&self) -> bool {
|
||||
self.mimes.contains(&mime::APPLICATION_OCTET_STREAM)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_merchandise_lists(
|
||||
list_params: ListParams,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.list_merchandise_lists
|
||||
.get_response(list_params.clone(), || async {
|
||||
let merchandise_lists = MerchandiseList::list(&env.db, &list_params).await?;
|
||||
let reply = json(&merchandise_lists);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
pub struct DeserializedBody<T> {
|
||||
body: T,
|
||||
content_type: ContentType,
|
||||
}
|
||||
|
||||
pub async fn create_merchandise_list(
|
||||
merchandise_list: MerchandiseList,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let ref_list_with_owner_id = MerchandiseList {
|
||||
owner_id: Some(owner_id),
|
||||
..merchandise_list
|
||||
};
|
||||
let saved_merchandise_list = ref_list_with_owner_id
|
||||
.create(&env.db)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = saved_merchandise_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply = json(&saved_merchandise_list);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.list_merchandise_lists.clear().await;
|
||||
env.caches
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(saved_merchandise_list.shop_id)
|
||||
.await;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update_merchandise_list(
|
||||
id: i32,
|
||||
merchandise_list: MerchandiseList,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let merchandise_list_with_id_and_owner_id = if merchandise_list.owner_id.is_some() {
|
||||
MerchandiseList {
|
||||
id: Some(id),
|
||||
..merchandise_list
|
||||
impl<T: DeserializeOwned> DeserializedBody<T> {
|
||||
pub fn from_bytes(bytes: Bytes, content_type: Option<Mime>) -> Result<Self> {
|
||||
match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
debug!(
|
||||
content_type = ?ContentType::Bincode,
|
||||
"deserializing body as bincode"
|
||||
);
|
||||
Ok(Self {
|
||||
content_type: ContentType::Bincode,
|
||||
body: bincode::deserialize(&bytes)?,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
content_type = ?ContentType::Json,
|
||||
"deserializing body as json"
|
||||
);
|
||||
Ok(Self {
|
||||
content_type: ContentType::Json,
|
||||
body: serde_json::from_slice(&bytes)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MerchandiseList {
|
||||
id: Some(id),
|
||||
owner_id: Some(owner_id),
|
||||
..merchandise_list
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TypedCache<'a, K, V>
|
||||
where
|
||||
K: Eq + Hash + Debug,
|
||||
V: Clone,
|
||||
{
|
||||
cache: &'a Cache<K, V>,
|
||||
content_type: ContentType,
|
||||
}
|
||||
|
||||
impl<'a, K, V> TypedCache<'a, K, V>
|
||||
where
|
||||
K: Eq + Hash + Debug,
|
||||
V: Clone,
|
||||
{
|
||||
pub fn pick_cache(
|
||||
accept: Option<AcceptHeader>,
|
||||
bincode_cache: &'a Cache<K, V>,
|
||||
json_cache: &'a Cache<K, V>,
|
||||
) -> Self {
|
||||
match accept {
|
||||
Some(accept) if accept.accepts_bincode() => {
|
||||
debug!(
|
||||
content_type = ?ContentType::Bincode,
|
||||
"serializing body as bincode"
|
||||
);
|
||||
Self {
|
||||
content_type: ContentType::Bincode,
|
||||
cache: bincode_cache,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
debug!(content_type = ?ContentType::Json, "serializing body as json");
|
||||
Self {
|
||||
content_type: ContentType::Json,
|
||||
cache: json_cache,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let updated_merchandise_list = merchandise_list_with_id_and_owner_id
|
||||
.update(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_merchandise_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply = json(&updated_merchandise_list);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches.merchandise_list.delete_response(id).await;
|
||||
env.caches
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
env.caches.list_merchandise_lists.clear().await;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update_merchandise_list_by_shop_id(
|
||||
shop_id: i32,
|
||||
merchandise_list: MerchandiseList,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let merchandise_list_with_owner_id = MerchandiseList {
|
||||
owner_id: Some(owner_id),
|
||||
..merchandise_list
|
||||
};
|
||||
let updated_merchandise_list = merchandise_list_with_owner_id
|
||||
.update_by_shop_id(&env.db, owner_id, shop_id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_merchandise_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply = json(&updated_merchandise_list);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches
|
||||
.merchandise_list
|
||||
.delete_response(
|
||||
updated_merchandise_list
|
||||
.id
|
||||
.expect("saved merchandise_list has no id"),
|
||||
)
|
||||
.await;
|
||||
env.caches
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
env.caches.list_merchandise_lists.clear().await;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn delete_merchandise_list(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let merchandise_list = MerchandiseList::get(&env.db, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
MerchandiseList::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
env.caches.merchandise_list.delete_response(id).await;
|
||||
env.caches
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(merchandise_list.shop_id)
|
||||
.await;
|
||||
env.caches.list_merchandise_lists.clear().await;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn get_merchandise_list_by_shop_id(
|
||||
shop_id: i32,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
env.caches
|
||||
.merchandise_list_by_shop_id
|
||||
.get_response(shop_id, || async {
|
||||
let merchandise_list = MerchandiseList::get_by_shop_id(&env.db, shop_id).await?;
|
||||
let reply = json(&merchandise_list);
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn buy_merchandise(
|
||||
shop_id: i32,
|
||||
merchandise_params: MerchandiseParams,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
// TODO: create transaction
|
||||
let updated_merchandise_list = MerchandiseList::update_merchandise_quantity(
|
||||
&env.db,
|
||||
shop_id,
|
||||
&(merchandise_params.mod_name),
|
||||
merchandise_params.local_form_id,
|
||||
merchandise_params.quantity_delta,
|
||||
)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_merchandise_list
|
||||
.url(&env.api_url)
|
||||
.map_err(reject_anyhow)?;
|
||||
let reply = json(&updated_merchandise_list);
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
env.caches
|
||||
.merchandise_list
|
||||
.delete_response(
|
||||
updated_merchandise_list
|
||||
.id
|
||||
.expect("saved merchandise_list has no id"),
|
||||
)
|
||||
.await;
|
||||
env.caches
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
env.caches.list_merchandise_lists.clear().await;
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
171
src/handlers/owner.rs
Normal file
171
src/handlers/owner.rs
Normal file
@ -0,0 +1,171 @@
|
||||
use anyhow::Result;
|
||||
use http::StatusCode;
|
||||
use hyper::body::Bytes;
|
||||
use ipnetwork::IpNetwork;
|
||||
use mime::Mime;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::{CachedResponse, CACHES};
|
||||
use crate::models::{FullPostedOwner, ListParams, Owner, PostedOwner};
|
||||
use crate::problem::{reject_anyhow, unauthorized_no_api_key};
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, DeserializedBody,
|
||||
ETagReply, Json, TypedCache,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<i32, CachedResponse>::pick_cache(accept, &CACHES.owner_bin, &CACHES.owner);
|
||||
let response = cache
|
||||
.get_response(id, || async {
|
||||
let owner = Owner::get(&env.db, id).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(ETagReply::<Bincode>::from_serializable(&owner)?),
|
||||
ContentType::Json => Box::new(ETagReply::<Json>::from_serializable(&owner)?),
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
list_params: ListParams,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<ListParams, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.list_owners_bin,
|
||||
&CACHES.list_owners,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(list_params.clone(), || async {
|
||||
let owners = Owner::list(&env.db, &list_params).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(ETagReply::<Bincode>::from_serializable(&owners)?),
|
||||
ContentType::Json => Box::new(ETagReply::<Json>::from_serializable(&owners)?),
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
bytes: Bytes,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
api_key: Option<Uuid>,
|
||||
real_ip: Option<IpNetwork>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
if let Some(api_key) = api_key {
|
||||
let DeserializedBody {
|
||||
body: owner,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedOwner>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner = FullPostedOwner {
|
||||
name: owner.name,
|
||||
mod_version: owner.mod_version,
|
||||
api_key,
|
||||
ip_address: match remote_addr {
|
||||
Some(addr) => Some(IpNetwork::from(addr.ip())),
|
||||
None => real_ip,
|
||||
},
|
||||
};
|
||||
let saved_owner = Owner::create(owner, &env.db).await.map_err(reject_anyhow)?;
|
||||
let url = saved_owner.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&saved_owner).map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&saved_owner).map_err(reject_anyhow)?)
|
||||
}
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.list_owners.clear().await;
|
||||
CACHES.list_owners_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
} else {
|
||||
Err(reject_anyhow(unauthorized_no_api_key()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
id: i32,
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: owner,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedOwner>::from_bytes(bytes, content_type).map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let updated_owner = Owner::update(owner, &env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_owner.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&updated_owner).map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&updated_owner).map_err(reject_anyhow)?)
|
||||
}
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.owner.delete_response(id).await;
|
||||
CACHES.owner_bin.delete_response(id).await;
|
||||
CACHES.list_owners.clear().await;
|
||||
CACHES.list_owners_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
Owner::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
tokio::spawn(async move {
|
||||
let api_key = api_key.expect("api-key has been validated during authenticate");
|
||||
CACHES.owner.delete_response(id).await;
|
||||
CACHES.owner_bin.delete_response(id).await;
|
||||
CACHES.owner_ids_by_api_key.delete(api_key).await;
|
||||
CACHES.list_owners.clear().await;
|
||||
CACHES.list_owners_bin.clear().await;
|
||||
});
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
202
src/handlers/shop.rs
Normal file
202
src/handlers/shop.rs
Normal file
@ -0,0 +1,202 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use http::StatusCode;
|
||||
use hyper::body::Bytes;
|
||||
use mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::{CachedResponse, CACHES};
|
||||
use crate::models::{
|
||||
InteriorRefList, ListParams, MerchandiseList, PostedInteriorRefList, PostedMerchandiseList,
|
||||
PostedShop, Shop,
|
||||
};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, DeserializedBody,
|
||||
ETagReply, Json, TypedCache,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<i32, CachedResponse>::pick_cache(accept, &CACHES.shop_bin, &CACHES.shop);
|
||||
let response = cache
|
||||
.get_response(id, || async {
|
||||
let shop = Shop::get(&env.db, id).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(ETagReply::<Bincode>::from_serializable(&shop)?),
|
||||
ContentType::Json => Box::new(ETagReply::<Json>::from_serializable(&shop)?),
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
list_params: ListParams,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<ListParams, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.list_shops_bin,
|
||||
&CACHES.list_shops,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(list_params.clone(), || async {
|
||||
let shops = Shop::list(&env.db, &list_params).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(ETagReply::<Bincode>::from_serializable(&shops)?),
|
||||
ContentType::Json => Box::new(ETagReply::<Json>::from_serializable(&shops)?),
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: mut shop,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedShop>::from_bytes(bytes, content_type).map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
shop.owner_id = Some(owner_id);
|
||||
let mut tx = env
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|error| reject_anyhow(anyhow!(error)))?;
|
||||
let saved_shop = Shop::create(shop, &mut tx).await.map_err(reject_anyhow)?;
|
||||
|
||||
// also save empty interior_ref_list and merchandise_list rows
|
||||
let interior_ref_list = PostedInteriorRefList {
|
||||
shop_id: saved_shop.id,
|
||||
owner_id: Some(owner_id),
|
||||
ref_list: sqlx::types::Json::default(),
|
||||
shelves: sqlx::types::Json::default(),
|
||||
};
|
||||
InteriorRefList::create(interior_ref_list, &mut tx)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let merchandise_list = PostedMerchandiseList {
|
||||
shop_id: saved_shop.id,
|
||||
owner_id: Some(owner_id),
|
||||
form_list: sqlx::types::Json::default(),
|
||||
};
|
||||
MerchandiseList::create(merchandise_list, &mut tx)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|error| reject_anyhow(anyhow!(error)))?;
|
||||
|
||||
let url = saved_shop.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&saved_shop).map_err(reject_anyhow)?)
|
||||
}
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&saved_shop).map_err(reject_anyhow)?)
|
||||
}
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.list_shops.clear().await;
|
||||
CACHES.list_shops_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
id: i32,
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: mut shop,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedShop>::from_bytes(bytes, content_type).map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
shop.owner_id = match shop.owner_id {
|
||||
// allows an owner to transfer ownership of shop to another owner
|
||||
Some(posted_owner_id) => Some(posted_owner_id),
|
||||
None => Some(owner_id),
|
||||
};
|
||||
let updated_shop = Shop::update(shop, &env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let url = updated_shop.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&updated_shop).map_err(reject_anyhow)?)
|
||||
}
|
||||
ContentType::Json => {
|
||||
Box::new(ETagReply::<Json>::from_serializable(&updated_shop).map_err(reject_anyhow)?)
|
||||
}
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
CACHES.shop.delete_response(id).await;
|
||||
CACHES.shop_bin.delete_response(id).await;
|
||||
CACHES.list_shops.clear().await;
|
||||
CACHES.list_shops_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
Shop::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
tokio::spawn(async move {
|
||||
CACHES.shop.delete_response(id).await;
|
||||
CACHES.shop_bin.delete_response(id).await;
|
||||
CACHES.list_shops.clear().await;
|
||||
CACHES.list_shops_bin.clear().await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id
|
||||
.delete_response(id)
|
||||
.await;
|
||||
CACHES
|
||||
.interior_ref_list_by_shop_id_bin
|
||||
.delete_response(id)
|
||||
.await;
|
||||
CACHES.merchandise_list_by_shop_id.delete_response(id).await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id_bin
|
||||
.delete_response(id)
|
||||
.await;
|
||||
});
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
238
src/handlers/transaction.rs
Normal file
238
src/handlers/transaction.rs
Normal file
@ -0,0 +1,238 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use http::StatusCode;
|
||||
use http_api_problem::HttpApiProblem;
|
||||
use hyper::body::Bytes;
|
||||
use mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{reject, Rejection, Reply};
|
||||
|
||||
use crate::caches::{CachedResponse, CACHES};
|
||||
use crate::models::{ListParams, MerchandiseList, PostedTransaction, Shop, Transaction};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, DeserializedBody,
|
||||
ETagReply, Json, TypedCache,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<i32, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.transaction_bin,
|
||||
&CACHES.transaction,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(id, || async {
|
||||
let transaction = Transaction::get(&env.db, id).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&transaction)?)
|
||||
}
|
||||
ContentType::Json => Box::new(ETagReply::<Json>::from_serializable(&transaction)?),
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
list_params: ListParams,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<ListParams, CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.list_transactions_bin,
|
||||
&CACHES.list_transactions,
|
||||
);
|
||||
let response = cache
|
||||
.get_response(list_params.clone(), || async {
|
||||
let transactions = Transaction::list(&env.db, &list_params).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&transactions)?)
|
||||
}
|
||||
ContentType::Json => Box::new(ETagReply::<Json>::from_serializable(&transactions)?),
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn list_by_shop_id(
|
||||
shop_id: i32,
|
||||
list_params: ListParams,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let TypedCache {
|
||||
content_type,
|
||||
cache,
|
||||
} = TypedCache::<(i32, ListParams), CachedResponse>::pick_cache(
|
||||
accept,
|
||||
&CACHES.list_transactions_by_shop_id_bin,
|
||||
&CACHES.list_transactions_by_shop_id,
|
||||
);
|
||||
let response = cache
|
||||
.get_response((shop_id, list_params.clone()), || async {
|
||||
let transactions = Transaction::list_by_shop_id(&env.db, shop_id, &list_params).await?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => {
|
||||
Box::new(ETagReply::<Bincode>::from_serializable(&transactions)?)
|
||||
}
|
||||
ContentType::Json => Box::new(ETagReply::<Json>::from_serializable(&transactions)?),
|
||||
};
|
||||
let reply = with_status(reply, StatusCode::OK);
|
||||
Ok(reply)
|
||||
})
|
||||
.await?;
|
||||
Ok(check_etag(etag, response))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
bytes: Bytes,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let DeserializedBody {
|
||||
body: mut transaction,
|
||||
content_type,
|
||||
} = DeserializedBody::<PostedTransaction>::from_bytes(bytes, content_type)
|
||||
.map_err(reject_anyhow)?;
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
transaction.owner_id = Some(owner_id);
|
||||
let mut tx = env
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|error| reject_anyhow(anyhow!(error)))?;
|
||||
let saved_transaction = Transaction::create(transaction, &mut tx)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
if !Shop::accepts_keywords(
|
||||
&mut tx,
|
||||
saved_transaction.shop_id,
|
||||
&saved_transaction.keywords,
|
||||
)
|
||||
.await
|
||||
.map_err(reject_anyhow)?
|
||||
{
|
||||
return Err(reject::custom(
|
||||
HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_title("Unacceptable Merchandise Type")
|
||||
.set_detail("Shop does not accept that kind of merchandise"),
|
||||
));
|
||||
}
|
||||
let (quantity_delta, shop_gold_delta) = match saved_transaction.is_sell {
|
||||
true => (saved_transaction.quantity, saved_transaction.price * -1),
|
||||
false => (saved_transaction.quantity * -1, saved_transaction.price),
|
||||
};
|
||||
let updated_merchandise_list = MerchandiseList::update_merchandise_quantity(
|
||||
&mut tx,
|
||||
saved_transaction.shop_id,
|
||||
&(saved_transaction.mod_name),
|
||||
saved_transaction.local_form_id,
|
||||
&(saved_transaction.name),
|
||||
saved_transaction.form_type,
|
||||
saved_transaction.is_food,
|
||||
saved_transaction.price,
|
||||
quantity_delta,
|
||||
&saved_transaction.keywords,
|
||||
)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
Shop::update_gold(&mut tx, saved_transaction.shop_id, shop_gold_delta)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|error| reject_anyhow(anyhow!(error)))?;
|
||||
let url = saved_transaction.url(&env.api_url).map_err(reject_anyhow)?;
|
||||
let reply: Box<dyn Reply> = match content_type {
|
||||
ContentType::Bincode => Box::new(
|
||||
ETagReply::<Bincode>::from_serializable(&saved_transaction).map_err(reject_anyhow)?,
|
||||
),
|
||||
ContentType::Json => Box::new(
|
||||
ETagReply::<Json>::from_serializable(&saved_transaction).map_err(reject_anyhow)?,
|
||||
),
|
||||
};
|
||||
let reply = with_header(reply, "Location", url.as_str());
|
||||
let reply = with_status(reply, StatusCode::CREATED);
|
||||
tokio::spawn(async move {
|
||||
// TODO: will this make these caches effectively useless?
|
||||
CACHES
|
||||
.merchandise_list
|
||||
.delete_response(updated_merchandise_list.id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_bin
|
||||
.delete_response(updated_merchandise_list.id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.merchandise_list_by_shop_id_bin
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_transactions.clear().await;
|
||||
CACHES.list_transactions_bin.clear().await;
|
||||
CACHES.list_transactions_by_shop_id.clear().await;
|
||||
CACHES.list_transactions_by_shop_id_bin.clear().await;
|
||||
CACHES.list_merchandise_lists.clear().await;
|
||||
CACHES.list_merchandise_lists_bin.clear().await;
|
||||
CACHES
|
||||
.shop
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES
|
||||
.shop_bin
|
||||
.delete_response(updated_merchandise_list.shop_id)
|
||||
.await;
|
||||
CACHES.list_shops.clear().await;
|
||||
CACHES.list_shops_bin.clear().await;
|
||||
});
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
// Does NOT reverse the transaction side-effects!
|
||||
pub async fn delete(
|
||||
id: i32,
|
||||
api_key: Option<Uuid>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
Transaction::delete(&env.db, owner_id, id)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
tokio::spawn(async move {
|
||||
CACHES.transaction.delete_response(id).await;
|
||||
CACHES.transaction_bin.delete_response(id).await;
|
||||
CACHES.list_transactions.clear().await;
|
||||
CACHES.list_transactions_bin.clear().await;
|
||||
CACHES.list_transactions_by_shop_id.clear().await;
|
||||
CACHES.list_transactions_by_shop_id_bin.clear().await;
|
||||
});
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
234
src/main.rs
234
src/main.rs
@ -1,116 +1,100 @@
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Clap;
|
||||
use dotenv::dotenv;
|
||||
use http::StatusCode;
|
||||
use hyper::server::Server;
|
||||
use http::header::SERVER;
|
||||
use hyper::{body::Bytes, server::Server};
|
||||
use listenfd::ListenFd;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{migrate, Pool, Postgres};
|
||||
use std::convert::Infallible;
|
||||
use std::env;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use url::Url;
|
||||
use warp::http::Response;
|
||||
use warp::Filter;
|
||||
|
||||
mod caches;
|
||||
mod db;
|
||||
mod handlers;
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
mod models;
|
||||
mod problem;
|
||||
|
||||
use caches::Caches;
|
||||
use models::interior_ref_list::PostedInteriorRefList;
|
||||
use models::merchandise_list::{MerchandiseParams, PostedMerchandiseList};
|
||||
use models::owner::PostedOwner;
|
||||
use models::shop::PostedShop;
|
||||
use handlers::SERVER_STRING;
|
||||
use models::ListParams;
|
||||
|
||||
#[derive(Clap)]
|
||||
#[clap(version = "0.1.0", author = "Tyler Hallada <tyler@hallada.net>")]
|
||||
struct Opts {
|
||||
#[clap(short, long)]
|
||||
migrate: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Environment {
|
||||
pub db: PgPool,
|
||||
pub caches: Caches,
|
||||
pub db: Pool<Postgres>,
|
||||
pub api_url: Url,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
async fn new(api_url: Url) -> Result<Environment> {
|
||||
Ok(Environment {
|
||||
db: PgPool::connect(&env::var("DATABASE_URL")?).await?,
|
||||
caches: Caches::initialize(),
|
||||
db: PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&env::var("DATABASE_URL")?)
|
||||
.await?,
|
||||
api_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorMessage {
|
||||
code: u16,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn with_env(env: Environment) -> impl Filter<Extract = (Environment,), Error = Infallible> + Clone {
|
||||
warp::any().map(move || env.clone())
|
||||
}
|
||||
|
||||
fn json_body<T>() -> impl Filter<Extract = (T,), Error = warp::Rejection> + Clone
|
||||
where
|
||||
T: Send + DeserializeOwned,
|
||||
{
|
||||
warp::body::content_length_limit(1024 * 64).and(warp::body::json())
|
||||
fn extract_body_bytes() -> impl Filter<Extract = (Bytes,), Error = warp::Rejection> + Clone {
|
||||
warp::body::content_length_limit(1024 * 1024).and(warp::body::bytes())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
openssl_probe::init_ssl_cert_env_vars();
|
||||
dotenv().ok();
|
||||
let env_log_filter =
|
||||
env::var("RUST_LOG").unwrap_or_else(|_| "warp=info,bazaar_realm_api=info".to_owned());
|
||||
|
||||
let (non_blocking_writer, _guard) = tracing_appender::non_blocking(std::io::stdout());
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_log_filter)
|
||||
.with_span_events(FmtSpan::CLOSE)
|
||||
.with_writer(non_blocking_writer)
|
||||
.init();
|
||||
let opts: Opts = Opts::parse();
|
||||
|
||||
if opts.migrate {
|
||||
info!("going to migrate now!");
|
||||
db::migrate().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let host = env::var("HOST").expect("`HOST` environment variable not defined");
|
||||
let host_url = Url::parse(&host).expect("Cannot parse URL from `HOST` environment variable");
|
||||
let api_url = host_url.join("/v1/")?;
|
||||
let env = Environment::new(api_url).await?;
|
||||
|
||||
migrate!("db/migrations").run(&env.db).await?;
|
||||
|
||||
let status_handler = warp::path::path("status")
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.map(|| StatusCode::OK); // TODO: return what api versions this server supports instead
|
||||
.map(|| Response::builder().header(SERVER, SERVER_STRING).body("Ok"));
|
||||
let get_owner_handler = warp::path("owners").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::get_owner),
|
||||
.and_then(handlers::owner::get),
|
||||
);
|
||||
let create_owner_handler = warp::path("owners").and(
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(json_body::<PostedOwner>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::addr::remote())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("x-real-ip"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::create_owner),
|
||||
.and_then(handlers::owner::create),
|
||||
);
|
||||
let delete_owner_handler = warp::path("owners").and(
|
||||
warp::path::param()
|
||||
@ -118,38 +102,44 @@ async fn main() -> Result<()> {
|
||||
.and(warp::delete())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::delete_owner),
|
||||
.and_then(handlers::owner::delete),
|
||||
);
|
||||
let update_owner_handler = warp::path("owners").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::patch())
|
||||
.and(json_body::<PostedOwner>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::update_owner),
|
||||
.and_then(handlers::owner::update),
|
||||
);
|
||||
let list_owners_handler = warp::path("owners").and(
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(warp::query::<ListParams>())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::list_owners),
|
||||
.and_then(handlers::owner::list),
|
||||
);
|
||||
let get_shop_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::get_shop),
|
||||
.and_then(handlers::shop::get),
|
||||
);
|
||||
let create_shop_handler = warp::path("shops").and(
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(json_body::<PostedShop>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::create_shop),
|
||||
.and_then(handlers::shop::create),
|
||||
);
|
||||
let delete_shop_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
@ -157,38 +147,44 @@ async fn main() -> Result<()> {
|
||||
.and(warp::delete())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::delete_shop),
|
||||
.and_then(handlers::shop::delete),
|
||||
);
|
||||
let update_shop_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::patch())
|
||||
.and(json_body::<PostedShop>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::update_shop),
|
||||
.and_then(handlers::shop::update),
|
||||
);
|
||||
let list_shops_handler = warp::path("shops").and(
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(warp::query::<ListParams>())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::list_shops),
|
||||
.and_then(handlers::shop::list),
|
||||
);
|
||||
let get_interior_ref_list_handler = warp::path("interior_ref_lists").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::get_interior_ref_list),
|
||||
.and_then(handlers::interior_ref_list::get),
|
||||
);
|
||||
let create_interior_ref_list_handler = warp::path("interior_ref_lists").and(
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(json_body::<PostedInteriorRefList>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::create_interior_ref_list),
|
||||
.and_then(handlers::interior_ref_list::create),
|
||||
);
|
||||
let delete_interior_ref_list_handler = warp::path("interior_ref_lists").and(
|
||||
warp::path::param()
|
||||
@ -196,56 +192,65 @@ async fn main() -> Result<()> {
|
||||
.and(warp::delete())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::delete_interior_ref_list),
|
||||
.and_then(handlers::interior_ref_list::delete),
|
||||
);
|
||||
let update_interior_ref_list_handler = warp::path("interior_ref_lists").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::patch())
|
||||
.and(json_body::<PostedInteriorRefList>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::update_interior_ref_list),
|
||||
.and_then(handlers::interior_ref_list::update),
|
||||
);
|
||||
let update_interior_ref_list_by_shop_id_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
.and(warp::path("interior_ref_list"))
|
||||
.and(warp::path::end())
|
||||
.and(warp::patch())
|
||||
.and(json_body::<PostedInteriorRefList>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::update_interior_ref_list_by_shop_id),
|
||||
.and_then(handlers::interior_ref_list::update_by_shop_id),
|
||||
);
|
||||
let list_interior_ref_lists_handler = warp::path("interior_ref_lists").and(
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(warp::query::<ListParams>())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::list_interior_ref_lists),
|
||||
.and_then(handlers::interior_ref_list::list),
|
||||
);
|
||||
let get_interior_ref_list_by_shop_id_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
.and(warp::path("interior_ref_list"))
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::get_interior_ref_list_by_shop_id),
|
||||
.and_then(handlers::interior_ref_list::get_by_shop_id),
|
||||
);
|
||||
let get_merchandise_list_handler = warp::path("merchandise_lists").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::get_merchandise_list),
|
||||
.and_then(handlers::merchandise_list::get),
|
||||
);
|
||||
let create_merchandise_list_handler = warp::path("merchandise_lists").and(
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(json_body::<PostedMerchandiseList>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::create_merchandise_list),
|
||||
.and_then(handlers::merchandise_list::create),
|
||||
);
|
||||
let delete_merchandise_list_handler = warp::path("merchandise_lists").and(
|
||||
warp::path::param()
|
||||
@ -253,51 +258,93 @@ async fn main() -> Result<()> {
|
||||
.and(warp::delete())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::delete_merchandise_list),
|
||||
.and_then(handlers::merchandise_list::delete),
|
||||
);
|
||||
let update_merchandise_list_handler = warp::path("merchandise_lists").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::patch())
|
||||
.and(json_body::<PostedMerchandiseList>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::update_merchandise_list),
|
||||
.and_then(handlers::merchandise_list::update),
|
||||
);
|
||||
let update_merchandise_list_by_shop_id_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
.and(warp::path("merchandise_list"))
|
||||
.and(warp::path::end())
|
||||
.and(warp::patch())
|
||||
.and(json_body::<PostedMerchandiseList>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::update_merchandise_list_by_shop_id),
|
||||
.and_then(handlers::merchandise_list::update_by_shop_id),
|
||||
);
|
||||
let list_merchandise_lists_handler = warp::path("merchandise_lists").and(
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(warp::query::<ListParams>())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::list_merchandise_lists),
|
||||
.and_then(handlers::merchandise_list::list),
|
||||
);
|
||||
let get_merchandise_list_by_shop_id_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
.and(warp::path("merchandise_list"))
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::get_merchandise_list_by_shop_id),
|
||||
.and_then(handlers::merchandise_list::get_by_shop_id),
|
||||
);
|
||||
let buy_merchandise_handler = warp::path("shops").and(
|
||||
let get_transaction_handler = warp::path("transactions").and(
|
||||
warp::path::param()
|
||||
.and(warp::path("merchandise_list"))
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::transaction::get),
|
||||
);
|
||||
let create_transaction_handler = warp::path("transactions").and(
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(warp::query::<MerchandiseParams>())
|
||||
.and(extract_body_bytes())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(warp::header::optional("content-type"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::transaction::create),
|
||||
);
|
||||
let delete_transaction_handler = warp::path("transactions").and(
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::delete())
|
||||
.and(warp::header::optional("api-key"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::buy_merchandise),
|
||||
.and_then(handlers::transaction::delete),
|
||||
);
|
||||
let list_transactions_handler = warp::path("transactions").and(
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(warp::query::<ListParams>())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::transaction::list),
|
||||
);
|
||||
let list_transactions_by_shop_id_handler = warp::path("shops").and(
|
||||
warp::path::param()
|
||||
.and(warp::path("transactions"))
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(warp::query::<ListParams>())
|
||||
.and(warp::header::optional("if-none-match"))
|
||||
.and(warp::header::optional("accept"))
|
||||
.and(with_env(env.clone()))
|
||||
.and_then(handlers::transaction::list_by_shop_id),
|
||||
);
|
||||
|
||||
let routes = warp::path("v1")
|
||||
@ -317,7 +364,7 @@ async fn main() -> Result<()> {
|
||||
get_merchandise_list_by_shop_id_handler,
|
||||
update_interior_ref_list_by_shop_id_handler,
|
||||
update_merchandise_list_by_shop_id_handler,
|
||||
buy_merchandise_handler,
|
||||
list_transactions_by_shop_id_handler,
|
||||
get_interior_ref_list_handler,
|
||||
delete_interior_ref_list_handler,
|
||||
update_interior_ref_list_handler,
|
||||
@ -328,12 +375,31 @@ async fn main() -> Result<()> {
|
||||
update_merchandise_list_handler,
|
||||
create_merchandise_list_handler,
|
||||
list_merchandise_lists_handler,
|
||||
get_transaction_handler,
|
||||
delete_transaction_handler,
|
||||
create_transaction_handler,
|
||||
list_transactions_handler,
|
||||
// warp::any().map(|| StatusCode::NOT_FOUND),
|
||||
))
|
||||
.recover(problem::unpack_problem)
|
||||
.with(warp::compression::gzip())
|
||||
.with(warp::trace::request());
|
||||
|
||||
if let Ok(tls_cert) = env::var("TLS_CERT") {
|
||||
if let Ok(tls_key) = env::var("TLS_KEY") {
|
||||
let port = env::var("PORT")
|
||||
.unwrap_or_else(|_| "443".to_owned())
|
||||
.parse()?;
|
||||
warp::serve(routes)
|
||||
.tls()
|
||||
.cert_path(tls_cert)
|
||||
.key_path(tls_key)
|
||||
.run(([0, 0, 0, 0], port))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let svc = warp::service(routes);
|
||||
let make_svc = hyper::service::make_service_fn(|_: _| {
|
||||
let svc = svc.clone();
|
||||
@ -344,10 +410,12 @@ async fn main() -> Result<()> {
|
||||
let server = if let Some(l) = listenfd.take_tcp_listener(0)? {
|
||||
Server::from_tcp(l)?
|
||||
} else {
|
||||
Server::bind(&([127, 0, 0, 1], 3030).into())
|
||||
let port = env::var("PORT")
|
||||
.unwrap_or_else(|_| "3030".to_owned())
|
||||
.parse()?;
|
||||
Server::bind(&([0, 0, 0, 0], port).into())
|
||||
};
|
||||
|
||||
// warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
|
||||
server.serve(make_svc).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,26 +1,20 @@
|
||||
use anyhow::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Done, Executor, Postgres};
|
||||
use tracing::instrument;
|
||||
use url::Url;
|
||||
|
||||
use super::ListParams;
|
||||
use super::{Model, PostedModel, UpdateableModel};
|
||||
use crate::problem::forbidden_permission;
|
||||
|
||||
// sqlx queries for this model need to be `query_as_unchecked!` because `query_as!` does not
|
||||
// support user-defined types (`ref_list` Json field).
|
||||
// See for more info: https://github.com/thallada/rust_sqlx_bug/blob/master/src/main.rs
|
||||
// This may be fixed in sqlx 0.4.
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(sqlx::FromRow, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct InteriorRef {
|
||||
pub base_mod_name: String,
|
||||
pub base_local_form_id: i32,
|
||||
pub base_local_form_id: u32,
|
||||
pub ref_mod_name: Option<String>,
|
||||
pub ref_local_form_id: i32,
|
||||
pub ref_local_form_id: u32,
|
||||
pub position_x: f32,
|
||||
pub position_y: f32,
|
||||
pub position_z: f32,
|
||||
@ -30,12 +24,31 @@ pub struct InteriorRef {
|
||||
pub scale: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(sqlx::FromRow, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Shelf {
|
||||
pub shelf_type: u32,
|
||||
pub position_x: f32,
|
||||
pub position_y: f32,
|
||||
pub position_z: f32,
|
||||
pub angle_x: f32,
|
||||
pub angle_y: f32,
|
||||
pub angle_z: f32,
|
||||
pub scale: u16,
|
||||
pub page: u32,
|
||||
pub filter_form_type: Option<u32>,
|
||||
pub filter_is_food: bool,
|
||||
pub search: Option<String>,
|
||||
pub sort_on: Option<String>,
|
||||
pub sort_asc: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct InteriorRefList {
|
||||
pub id: i32,
|
||||
pub shop_id: i32,
|
||||
pub owner_id: i32,
|
||||
pub ref_list: Json<Vec<InteriorRef>>,
|
||||
pub shelves: Json<Vec<Shelf>>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@ -45,51 +58,66 @@ pub struct PostedInteriorRefList {
|
||||
pub shop_id: i32,
|
||||
pub owner_id: Option<i32>,
|
||||
pub ref_list: Json<Vec<InteriorRef>>,
|
||||
pub shelves: Json<Vec<Shelf>>,
|
||||
}
|
||||
|
||||
impl PostedModel for PostedInteriorRefList {}
|
||||
|
||||
#[async_trait]
|
||||
impl Model for InteriorRefList {
|
||||
fn resource_name() -> &'static str {
|
||||
impl InteriorRefList {
|
||||
pub fn resource_name() -> &'static str {
|
||||
"interior_ref_list"
|
||||
}
|
||||
|
||||
fn pk(&self) -> i32 {
|
||||
pub fn pk(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn url(&self, api_url: &Url) -> Result<Url> {
|
||||
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), self.pk()))?)
|
||||
}
|
||||
|
||||
// TODO: this model will probably never need to be accessed through it's ID, should these methods be removed/unimplemented?
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn get(db: &PgPool, id: i32) -> Result<Self> {
|
||||
sqlx::query_as_unchecked!(Self, "SELECT * FROM interior_ref_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, id: i32) -> Result<Self> {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
ref_list as "ref_list: Json<Vec<InteriorRef>>",
|
||||
shelves as "shelves: Json<Vec<Shelf>>"
|
||||
FROM interior_ref_lists WHERE id = $1"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn create(posted: PostedInteriorRefList, db: &PgPool) -> Result<Self> {
|
||||
// TODO:
|
||||
// * Decide if I'll need to make the same changes to merchandise and transactions
|
||||
// - answer depends on how many rows of each I expect to insert in one go
|
||||
// * should probably omit ref_list from response
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
#[instrument(level = "debug", skip(interior_ref_list, db))]
|
||||
pub async fn create(
|
||||
interior_ref_list: PostedInteriorRefList,
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
) -> Result<Self> {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"INSERT INTO interior_ref_lists
|
||||
(shop_id, owner_id, ref_list, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, now(), now())
|
||||
RETURNING *",
|
||||
posted.shop_id,
|
||||
posted.owner_id,
|
||||
posted.ref_list,
|
||||
r#"INSERT INTO interior_ref_lists
|
||||
(shop_id, owner_id, ref_list, shelves, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now(), now())
|
||||
RETURNING id, shop_id, owner_id, created_at, updated_at,
|
||||
ref_list as "ref_list: Json<Vec<InteriorRef>>",
|
||||
shelves as "shelves: Json<Vec<Shelf>>""#,
|
||||
interior_ref_list.shop_id,
|
||||
interior_ref_list.owner_id,
|
||||
serde_json::json!(interior_ref_list.ref_list),
|
||||
serde_json::json!(interior_ref_list.shelves),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
|
||||
pub async fn delete(
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<u64> {
|
||||
let interior_ref_list =
|
||||
sqlx::query!("SELECT owner_id FROM interior_ref_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
@ -98,7 +126,8 @@ impl Model for InteriorRefList {
|
||||
return Ok(
|
||||
sqlx::query!("DELETE FROM interior_ref_lists WHERE id = $1", id)
|
||||
.execute(db)
|
||||
.await?,
|
||||
.await?
|
||||
.rows_affected(),
|
||||
);
|
||||
} else {
|
||||
return Err(forbidden_permission());
|
||||
@ -106,14 +135,19 @@ impl Model for InteriorRefList {
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
|
||||
pub async fn list(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
list_params: &ListParams,
|
||||
) -> Result<Vec<Self>> {
|
||||
let result = if let Some(order_by) = list_params.get_order_by() {
|
||||
sqlx::query_as_unchecked!(
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM interior_ref_lists
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
ref_list as "ref_list: Json<Vec<InteriorRef>>",
|
||||
shelves as "shelves: Json<Vec<Shelf>>" FROM interior_ref_lists
|
||||
ORDER BY $1
|
||||
LIMIT $2
|
||||
OFFSET $3",
|
||||
OFFSET $3"#,
|
||||
order_by,
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
@ -121,11 +155,13 @@ impl Model for InteriorRefList {
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as_unchecked!(
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM interior_ref_lists
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
ref_list as "ref_list: Json<Vec<InteriorRef>>",
|
||||
shelves as "shelves: Json<Vec<Shelf>>" FROM interior_ref_lists
|
||||
LIMIT $1
|
||||
OFFSET $2",
|
||||
OFFSET $2"#,
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
)
|
||||
@ -134,31 +170,32 @@ impl Model for InteriorRefList {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for InteriorRefList {
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn update(
|
||||
posted: PostedInteriorRefList,
|
||||
db: &PgPool,
|
||||
#[instrument(level = "debug", skip(interior_ref_list, db))]
|
||||
pub async fn update(
|
||||
interior_ref_list: PostedInteriorRefList,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<Self> {
|
||||
let interior_ref_list =
|
||||
let existing_interior_ref_list =
|
||||
sqlx::query!("SELECT owner_id FROM interior_ref_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if interior_ref_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
if existing_interior_ref_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"UPDATE interior_ref_lists SET
|
||||
r#"UPDATE interior_ref_lists SET
|
||||
ref_list = $2,
|
||||
shelves = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
RETURNING id, shop_id, owner_id, created_at, updated_at,
|
||||
ref_list as "ref_list: Json<Vec<InteriorRef>>",
|
||||
shelves as "shelves: Json<Vec<Shelf>>""#,
|
||||
id,
|
||||
posted.ref_list,
|
||||
serde_json::json!(interior_ref_list.ref_list),
|
||||
serde_json::json!(interior_ref_list.shelves),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@ -166,15 +203,18 @@ impl UpdateableModel for InteriorRefList {
|
||||
return Err(forbidden_permission());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InteriorRefList {
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn get_by_shop_id(db: &PgPool, shop_id: i32) -> Result<Self> {
|
||||
sqlx::query_as_unchecked!(
|
||||
pub async fn get_by_shop_id(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
shop_id: i32,
|
||||
) -> Result<Self> {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM interior_ref_lists
|
||||
WHERE shop_id = $1",
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
ref_list as "ref_list: Json<Vec<InteriorRef>>",
|
||||
shelves as "shelves: Json<Vec<Shelf>>" FROM interior_ref_lists
|
||||
WHERE shop_id = $1"#,
|
||||
shop_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
@ -182,29 +222,33 @@ impl InteriorRefList {
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
#[instrument(level = "debug", skip(interior_ref_list, db))]
|
||||
pub async fn update_by_shop_id(
|
||||
posted: PostedInteriorRefList,
|
||||
db: &PgPool,
|
||||
interior_ref_list: PostedInteriorRefList,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
shop_id: i32,
|
||||
) -> Result<Self> {
|
||||
let interior_ref_list = sqlx::query!(
|
||||
let existing_interior_ref_list = sqlx::query!(
|
||||
"SELECT owner_id FROM interior_ref_lists WHERE shop_id = $1",
|
||||
shop_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if interior_ref_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
if existing_interior_ref_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"UPDATE interior_ref_lists SET
|
||||
r#"UPDATE interior_ref_lists SET
|
||||
ref_list = $2,
|
||||
shelves = $3,
|
||||
updated_at = now()
|
||||
WHERE shop_id = $1
|
||||
RETURNING *",
|
||||
RETURNING id, shop_id, owner_id, created_at, updated_at,
|
||||
ref_list as "ref_list: Json<Vec<InteriorRef>>",
|
||||
shelves as "shelves: Json<Vec<Shelf>>""#,
|
||||
shop_id,
|
||||
posted.ref_list,
|
||||
serde_json::json!(interior_ref_list.ref_list),
|
||||
serde_json::json!(interior_ref_list.shelves),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
|
@ -1,30 +1,27 @@
|
||||
use anyhow::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use chrono::prelude::*;
|
||||
use http::StatusCode;
|
||||
use http_api_problem::HttpApiProblem;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::postgres::PgPool;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Done, Executor, Postgres};
|
||||
use tracing::instrument;
|
||||
use url::Url;
|
||||
|
||||
use super::ListParams;
|
||||
use super::{Model, PostedModel, UpdateableModel};
|
||||
use crate::problem::forbidden_permission;
|
||||
|
||||
// sqlx queries for this model need to be `query_as_unchecked!` because `query_as!` does not
|
||||
// support user-defined types (`form_list` Json field).
|
||||
// See for more info: https://github.com/thallada/rust_sqlx_bug/blob/master/src/main.rs
|
||||
// This may be fixed in sqlx 0.4.
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Merchandise {
|
||||
pub mod_name: String,
|
||||
pub local_form_id: i32,
|
||||
pub local_form_id: u32,
|
||||
pub name: String,
|
||||
pub quantity: i32,
|
||||
pub form_type: i32,
|
||||
pub quantity: u32,
|
||||
pub form_type: u32,
|
||||
pub is_food: bool,
|
||||
pub price: i32,
|
||||
pub price: u32,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@ -44,52 +41,61 @@ pub struct PostedMerchandiseList {
|
||||
pub form_list: Json<Vec<Merchandise>>,
|
||||
}
|
||||
|
||||
impl PostedModel for PostedMerchandiseList {}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Deserialize)]
|
||||
pub struct MerchandiseParams {
|
||||
pub mod_name: String,
|
||||
pub local_form_id: i32,
|
||||
pub quantity_delta: i32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Model for MerchandiseList {
|
||||
fn resource_name() -> &'static str {
|
||||
impl MerchandiseList {
|
||||
pub fn resource_name() -> &'static str {
|
||||
"merchandise_list"
|
||||
}
|
||||
|
||||
fn pk(&self) -> i32 {
|
||||
pub fn pk(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn url(&self, api_url: &Url) -> Result<Url> {
|
||||
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), self.pk()))?)
|
||||
}
|
||||
|
||||
// TODO: this model will probably never need to be accessed through it's ID, should these methods be removed/unimplemented?
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn get(db: &PgPool, id: i32) -> Result<Self> {
|
||||
sqlx::query_as_unchecked!(Self, "SELECT * FROM merchandise_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, id: i32) -> Result<Self> {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
form_list as "form_list: Json<Vec<Merchandise>>"
|
||||
FROM merchandise_lists
|
||||
WHERE id = $1"#,
|
||||
id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn create(posted: PostedMerchandiseList, db: &PgPool) -> Result<Self> {
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
#[instrument(level = "debug", skip(merchandise_list, db))]
|
||||
pub async fn create(
|
||||
merchandise_list: PostedMerchandiseList,
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
) -> Result<Self> {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"INSERT INTO merchandise_lists
|
||||
r#"INSERT INTO merchandise_lists
|
||||
(shop_id, owner_id, form_list, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, now(), now())
|
||||
RETURNING *",
|
||||
posted.shop_id,
|
||||
posted.owner_id,
|
||||
posted.form_list,
|
||||
RETURNING id, shop_id, owner_id, created_at, updated_at,
|
||||
form_list as "form_list: Json<Vec<Merchandise>>""#,
|
||||
merchandise_list.shop_id,
|
||||
merchandise_list.owner_id,
|
||||
serde_json::json!(merchandise_list.form_list),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
|
||||
pub async fn delete(
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<u64> {
|
||||
let merchandise_list =
|
||||
sqlx::query!("SELECT owner_id FROM merchandise_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
@ -98,7 +104,8 @@ impl Model for MerchandiseList {
|
||||
return Ok(
|
||||
sqlx::query!("DELETE FROM merchandise_lists WHERE id = $1", id)
|
||||
.execute(db)
|
||||
.await?,
|
||||
.await?
|
||||
.rows_affected(),
|
||||
);
|
||||
} else {
|
||||
return Err(forbidden_permission());
|
||||
@ -106,14 +113,19 @@ impl Model for MerchandiseList {
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
|
||||
pub async fn list(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
list_params: &ListParams,
|
||||
) -> Result<Vec<Self>> {
|
||||
let result = if let Some(order_by) = list_params.get_order_by() {
|
||||
sqlx::query_as_unchecked!(
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM merchandise_lists
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
form_list as "form_list: Json<Vec<Merchandise>>"
|
||||
FROM merchandise_lists
|
||||
ORDER BY $1
|
||||
LIMIT $2
|
||||
OFFSET $3",
|
||||
OFFSET $3"#,
|
||||
order_by,
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
@ -121,11 +133,13 @@ impl Model for MerchandiseList {
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as_unchecked!(
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM merchandise_lists
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
form_list as "form_list: Json<Vec<Merchandise>>"
|
||||
FROM merchandise_lists
|
||||
LIMIT $1
|
||||
OFFSET $2",
|
||||
OFFSET $2"#,
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
)
|
||||
@ -134,31 +148,29 @@ impl Model for MerchandiseList {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for MerchandiseList {
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn update(
|
||||
posted: PostedMerchandiseList,
|
||||
db: &PgPool,
|
||||
#[instrument(level = "debug", skip(merchandise_list, db))]
|
||||
pub async fn update(
|
||||
merchandise_list: PostedMerchandiseList,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<Self> {
|
||||
let merchandise_list =
|
||||
let existing_merchandise_list =
|
||||
sqlx::query!("SELECT owner_id FROM merchandise_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if merchandise_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
if existing_merchandise_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"UPDATE merchandise_lists SET
|
||||
r#"UPDATE merchandise_lists SET
|
||||
form_list = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
RETURNING id, shop_id, owner_id, created_at, updated_at,
|
||||
form_list as "form_list: Json<Vec<Merchandise>>""#,
|
||||
id,
|
||||
posted.form_list,
|
||||
serde_json::json!(merchandise_list.form_list),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@ -166,15 +178,18 @@ impl UpdateableModel for MerchandiseList {
|
||||
return Err(forbidden_permission());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MerchandiseList {
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn get_by_shop_id(db: &PgPool, shop_id: i32) -> Result<Self> {
|
||||
sqlx::query_as_unchecked!(
|
||||
pub async fn get_by_shop_id(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
shop_id: i32,
|
||||
) -> Result<Self> {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM merchandise_lists
|
||||
WHERE shop_id = $1",
|
||||
r#"SELECT id, shop_id, owner_id, created_at, updated_at,
|
||||
form_list as "form_list: Json<Vec<Merchandise>>"
|
||||
FROM merchandise_lists
|
||||
WHERE shop_id = $1"#,
|
||||
shop_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
@ -182,29 +197,30 @@ impl MerchandiseList {
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
#[instrument(level = "debug", skip(merchandise_list, db))]
|
||||
pub async fn update_by_shop_id(
|
||||
posted: PostedMerchandiseList,
|
||||
db: &PgPool,
|
||||
merchandise_list: PostedMerchandiseList,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
shop_id: i32,
|
||||
) -> Result<Self> {
|
||||
let merchandise_list = sqlx::query!(
|
||||
let existing_merchandise_list = sqlx::query!(
|
||||
"SELECT owner_id FROM merchandise_lists WHERE shop_id = $1",
|
||||
shop_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if merchandise_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
if existing_merchandise_list.owner_id == owner_id {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"UPDATE merchandise_lists SET
|
||||
r#"UPDATE merchandise_lists SET
|
||||
form_list = $2,
|
||||
updated_at = now()
|
||||
WHERE shop_id = $1
|
||||
RETURNING *",
|
||||
RETURNING id, shop_id, owner_id, created_at, updated_at,
|
||||
form_list as "form_list: Json<Vec<Merchandise>>""#,
|
||||
shop_id,
|
||||
posted.form_list,
|
||||
serde_json::json!(merchandise_list.form_list),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@ -215,24 +231,46 @@ impl MerchandiseList {
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn update_merchandise_quantity(
|
||||
db: &PgPool,
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
shop_id: i32,
|
||||
mod_name: &str,
|
||||
local_form_id: i32,
|
||||
name: &str,
|
||||
form_type: i32,
|
||||
is_food: bool,
|
||||
price: i32,
|
||||
quantity_delta: i32,
|
||||
keywords: &[String],
|
||||
) -> Result<Self> {
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
let add_item = json!([{
|
||||
"mod_name": mod_name,
|
||||
"local_form_id": local_form_id,
|
||||
"name": name,
|
||||
"quantity": quantity_delta,
|
||||
"form_type": form_type,
|
||||
"is_food": is_food,
|
||||
"price": price,
|
||||
"keywords": keywords,
|
||||
}]);
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"UPDATE
|
||||
r#"UPDATE
|
||||
merchandise_lists
|
||||
SET
|
||||
form_list =
|
||||
jsonb_set(
|
||||
form_list,
|
||||
array[elem_index::text, 'quantity'],
|
||||
to_jsonb(quantity::int + $4),
|
||||
true
|
||||
)
|
||||
form_list = CASE
|
||||
WHEN elem_index IS NULL AND quantity IS NULL AND $4 > 0
|
||||
THEN form_list || $5
|
||||
WHEN elem_index IS NOT NULL AND quantity IS NOT NULL AND quantity::int + $4 = 0
|
||||
THEN form_list - elem_index::int
|
||||
WHEN elem_index IS NOT NULL AND quantity IS NOT NULL
|
||||
THEN jsonb_set(
|
||||
form_list,
|
||||
array[elem_index::text, 'quantity'],
|
||||
to_jsonb(quantity::int + $4),
|
||||
true
|
||||
)
|
||||
ELSE NULL
|
||||
END
|
||||
FROM (
|
||||
SELECT
|
||||
pos - 1 as elem_index,
|
||||
@ -244,16 +282,44 @@ impl MerchandiseList {
|
||||
shop_id = $1 AND
|
||||
elem->>'mod_name' = $2::text AND
|
||||
elem->>'local_form_id' = $3::text
|
||||
UNION ALL
|
||||
SELECT
|
||||
NULL as elem_index, NULL as quantity
|
||||
LIMIT 1
|
||||
) sub
|
||||
WHERE
|
||||
shop_id = $1
|
||||
RETURNING merchandise_lists.*",
|
||||
RETURNING
|
||||
merchandise_lists.id,
|
||||
merchandise_lists.shop_id,
|
||||
merchandise_lists.owner_id,
|
||||
merchandise_lists.created_at,
|
||||
merchandise_lists.updated_at,
|
||||
merchandise_lists.form_list as "form_list: Json<Vec<Merchandise>>""#,
|
||||
shop_id,
|
||||
mod_name,
|
||||
local_form_id,
|
||||
&local_form_id.to_string(),
|
||||
quantity_delta,
|
||||
add_item,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let anyhow_error = anyhow!(error);
|
||||
if let Some(db_error) =
|
||||
anyhow_error.downcast_ref::<sqlx::postgres::PgDatabaseError>()
|
||||
{
|
||||
if db_error.code() == "23502" && db_error.column() == Some("form_list") {
|
||||
return anyhow!(HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::NOT_FOUND
|
||||
)
|
||||
.set_detail(format!(
|
||||
"Cannot find merchandise to buy with mod_name: {} and local_form_id: {:#010X}",
|
||||
mod_name, local_form_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
anyhow_error
|
||||
})?)
|
||||
}
|
||||
}
|
||||
|
@ -7,12 +7,14 @@ pub mod merchandise_list;
|
||||
pub mod model;
|
||||
pub mod owner;
|
||||
pub mod shop;
|
||||
pub mod transaction;
|
||||
|
||||
pub use interior_ref_list::InteriorRefList;
|
||||
pub use merchandise_list::{MerchandiseList, MerchandiseParams};
|
||||
pub use model::{Model, PostedModel, UpdateableModel};
|
||||
pub use owner::Owner;
|
||||
pub use shop::Shop;
|
||||
pub use interior_ref_list::{InteriorRefList, PostedInteriorRefList};
|
||||
pub use merchandise_list::{MerchandiseList, PostedMerchandiseList};
|
||||
pub use model::{Model, UpdateableModel};
|
||||
pub use owner::{FullPostedOwner, Owner, PostedOwner};
|
||||
pub use shop::{PostedShop, Shop};
|
||||
pub use transaction::{PostedTransaction, Transaction};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Deserialize)]
|
||||
pub enum Order {
|
||||
|
@ -5,20 +5,33 @@ use url::Url;
|
||||
|
||||
use super::ListParams;
|
||||
|
||||
pub trait PostedModel {}
|
||||
|
||||
// TODO: I stopped using this because I needed to accept a transaction instead of a &PgPool for these methods on certain models.
|
||||
// It would be nice to find a way to impl this trait for all my models so I don't have to keep redoing the `url` function on
|
||||
// each. But, maybe I'm trying to use Traits in an OOP way and that's bad, idk.
|
||||
//
|
||||
// @NyxCode on discord: "on 0.4, you can use impl Executor<'_, Database = Postgres>. I use it everywhere, and it works for
|
||||
// &PgPool, &mut PgConnection and &mut Transaction"
|
||||
//
|
||||
// I attempted to use `impl Executor<Database = Postgres>` in 0.3.5 but it created a recursive type error :(
|
||||
#[async_trait]
|
||||
pub trait Model
|
||||
where
|
||||
Self: std::marker::Sized,
|
||||
{
|
||||
fn resource_name() -> &'static str;
|
||||
fn pk(&self) -> i32;
|
||||
fn pk(&self) -> Option<i32>;
|
||||
fn url(&self, api_url: &Url) -> Result<Url> {
|
||||
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), self.pk()))?)
|
||||
if let Some(pk) = self.pk() {
|
||||
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), pk))?)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Cannot get URL for {} with no primary key",
|
||||
Self::resource_name()
|
||||
))
|
||||
}
|
||||
}
|
||||
async fn get(db: &PgPool, id: i32) -> Result<Self>;
|
||||
async fn create(posted: dyn PostedModel, db: &PgPool) -> Result<Self>;
|
||||
async fn create(self, db: &PgPool) -> Result<Self>;
|
||||
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64>;
|
||||
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>>;
|
||||
}
|
||||
@ -28,5 +41,5 @@ pub trait UpdateableModel
|
||||
where
|
||||
Self: std::marker::Sized,
|
||||
{
|
||||
async fn update(posted: dyn PostedModel, db: &PgPool, owner_id: i32, id: i32) -> Result<Self>;
|
||||
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self>;
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
use anyhow::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::prelude::*;
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use sqlx::{Done, Executor, Postgres};
|
||||
use tracing::instrument;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ListParams;
|
||||
use super::{Model, PostedModel, UpdateableModel};
|
||||
use crate::problem::forbidden_permission;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@ -27,68 +26,82 @@ pub struct Owner {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PostedOwner {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub api_key: Option<Uuid>,
|
||||
#[serde(skip_serializing)]
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub mod_version: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl PostedModel for PostedOwner {}
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FullPostedOwner {
|
||||
pub name: String,
|
||||
pub api_key: Uuid,
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub mod_version: i32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Model for Owner {
|
||||
fn resource_name() -> &'static str {
|
||||
impl Owner {
|
||||
pub fn resource_name() -> &'static str {
|
||||
"owner"
|
||||
}
|
||||
|
||||
fn pk(&self) -> i32 {
|
||||
pub fn pk(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn url(&self, api_url: &Url) -> Result<Url> {
|
||||
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), self.pk()))?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn get(db: &PgPool, id: i32) -> Result<Self> {
|
||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, id: i32) -> Result<Self> {
|
||||
sqlx::query_as!(Self, "SELECT * FROM owners WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn create(posted: PostedOwner, db: &PgPool) -> Result<Self> {
|
||||
#[instrument(level = "debug", skip(owner, db))]
|
||||
pub async fn create(
|
||||
owner: FullPostedOwner,
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
) -> Result<Self> {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"INSERT INTO owners
|
||||
(name, api_key, ip_address, mod_version, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now(), now())
|
||||
RETURNING *",
|
||||
posted.name,
|
||||
posted.api_key,
|
||||
posted.ip_address,
|
||||
posted.mod_version,
|
||||
owner.name,
|
||||
owner.api_key,
|
||||
owner.ip_address,
|
||||
owner.mod_version,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
|
||||
pub async fn delete(
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<u64> {
|
||||
let owner = sqlx::query!("SELECT id FROM owners WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if owner.id == owner_id {
|
||||
Ok(sqlx::query!("DELETE FROM owners WHERE id = $1", id)
|
||||
.execute(db)
|
||||
.await?)
|
||||
.await?
|
||||
.rows_affected())
|
||||
} else {
|
||||
return Err(forbidden_permission());
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
|
||||
pub async fn list(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
list_params: &ListParams,
|
||||
) -> Result<Vec<Self>> {
|
||||
let result = if let Some(order_by) = list_params.get_order_by() {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
@ -116,16 +129,18 @@ impl Model for Owner {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for Owner {
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn update(posted: PostedOwner, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
|
||||
let owner = sqlx::query!("SELECT id FROM owners WHERE id = $1", id)
|
||||
#[instrument(level = "debug", skip(owner, db))]
|
||||
pub async fn update(
|
||||
owner: PostedOwner,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<Self> {
|
||||
let existing_owner = sqlx::query!("SELECT id FROM owners WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if owner.id == owner_id {
|
||||
if existing_owner.id == owner_id {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"UPDATE owners SET
|
||||
@ -135,8 +150,8 @@ impl UpdateableModel for Owner {
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
id,
|
||||
posted.name,
|
||||
posted.mod_version,
|
||||
owner.name,
|
||||
owner.mod_version,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
|
@ -1,12 +1,11 @@
|
||||
use anyhow::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use sqlx::{Done, Executor, Postgres};
|
||||
use tracing::instrument;
|
||||
use url::Url;
|
||||
|
||||
use super::ListParams;
|
||||
use super::{Model, PostedModel, UpdateableModel};
|
||||
use crate::problem::forbidden_permission;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@ -15,11 +14,10 @@ pub struct Shop {
|
||||
pub name: String,
|
||||
pub owner_id: i32,
|
||||
pub description: Option<String>,
|
||||
// removing these until I figure out the plan for buying and selling
|
||||
// pub is_not_sell_buy: bool,
|
||||
// pub sell_buy_list_id: i32,
|
||||
// pub vendor_id: i32,
|
||||
// pub vendor_gold: i32,
|
||||
pub gold: i32,
|
||||
pub shop_type: String,
|
||||
pub vendor_keywords: Vec<String>,
|
||||
pub vendor_keywords_exclude: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@ -29,60 +27,83 @@ pub struct PostedShop {
|
||||
pub name: String,
|
||||
pub owner_id: Option<i32>,
|
||||
pub description: Option<String>,
|
||||
pub gold: Option<i32>,
|
||||
pub shop_type: Option<String>,
|
||||
pub vendor_keywords: Option<Vec<String>>,
|
||||
pub vendor_keywords_exclude: Option<bool>,
|
||||
}
|
||||
|
||||
impl PostedModel for PostedShop {}
|
||||
|
||||
#[async_trait]
|
||||
impl Model for Shop {
|
||||
fn resource_name() -> &'static str {
|
||||
impl Shop {
|
||||
pub fn resource_name() -> &'static str {
|
||||
"shop"
|
||||
}
|
||||
|
||||
fn pk(&self) -> i32 {
|
||||
pub fn pk(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn url(&self, api_url: &Url) -> Result<Url> {
|
||||
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), self.pk()))?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn get(db: &PgPool, id: i32) -> Result<Self> {
|
||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, id: i32) -> Result<Self> {
|
||||
sqlx::query_as!(Self, "SELECT * FROM shops WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn create(posted: PostedShop, db: &PgPool) -> Result<Self> {
|
||||
#[instrument(level = "debug", skip(shop, db))]
|
||||
pub async fn create(
|
||||
shop: PostedShop,
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
) -> Result<Self> {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"INSERT INTO shops
|
||||
(name, owner_id, description, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, now(), now())
|
||||
(name, owner_id, description, gold, shop_type, vendor_keywords,
|
||||
vendor_keywords_exclude, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
||||
RETURNING *",
|
||||
posted.name,
|
||||
posted.owner_id,
|
||||
posted.description,
|
||||
shop.name,
|
||||
shop.owner_id,
|
||||
shop.description,
|
||||
shop.gold.unwrap_or(0),
|
||||
shop.shop_type.unwrap_or("general_store".to_string()),
|
||||
&shop
|
||||
.vendor_keywords
|
||||
.unwrap_or_else(|| vec!["VendorItemKey".to_string(), "VendorNoSale".to_string()]),
|
||||
shop.vendor_keywords_exclude.unwrap_or(true),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn delete(db: &PgPool, owner_id: i32, id: i32) -> Result<u64> {
|
||||
pub async fn delete(
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<u64> {
|
||||
let shop = sqlx::query!("SELECT owner_id FROM shops WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if shop.owner_id == owner_id {
|
||||
return Ok(sqlx::query!("DELETE FROM shops WHERE shops.id = $1", id)
|
||||
.execute(db)
|
||||
.await?);
|
||||
.await?
|
||||
.rows_affected());
|
||||
} else {
|
||||
return Err(forbidden_permission());
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
async fn list(db: &PgPool, list_params: &ListParams) -> Result<Vec<Self>> {
|
||||
pub async fn list(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
list_params: &ListParams,
|
||||
) -> Result<Vec<Self>> {
|
||||
let result = if let Some(order_by) = list_params.get_order_by() {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
@ -110,29 +131,39 @@ impl Model for Shop {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for Shop {
|
||||
#[instrument(level = "debug", skip(posted, db))]
|
||||
async fn update(posted: PostedShop, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
|
||||
let shop = sqlx::query!("SELECT owner_id FROM shops WHERE id = $1", id)
|
||||
#[instrument(level = "debug", skip(shop, db))]
|
||||
pub async fn update(
|
||||
shop: PostedShop,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<Self> {
|
||||
let existing_shop = sqlx::query!("SELECT owner_id FROM shops WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if shop.owner_id == owner_id {
|
||||
if existing_shop.owner_id == owner_id {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"UPDATE shops SET
|
||||
name = $2,
|
||||
owner_id = $3,
|
||||
description = $4,
|
||||
gold = $5,
|
||||
shop_type = $6,
|
||||
vendor_keywords = $7,
|
||||
vendor_keywords_exclude = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
id,
|
||||
posted.name,
|
||||
posted.owner_id,
|
||||
posted.description,
|
||||
shop.name,
|
||||
shop.owner_id,
|
||||
shop.description,
|
||||
shop.gold,
|
||||
shop.shop_type,
|
||||
&shop.vendor_keywords.unwrap_or_else(|| vec![]),
|
||||
shop.vendor_keywords_exclude,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@ -140,4 +171,48 @@ impl UpdateableModel for Shop {
|
||||
return Err(forbidden_permission());
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn accepts_keywords(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
id: i32,
|
||||
keywords: &[String],
|
||||
) -> Result<bool> {
|
||||
// Macro not available, see: https://github.com/launchbadge/sqlx/issues/428
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1 FROM shops
|
||||
WHERE id = $1
|
||||
AND ((
|
||||
vendor_keywords_exclude = true AND
|
||||
NOT vendor_keywords && $2
|
||||
) OR (
|
||||
vendor_keywords_exclude = false AND
|
||||
vendor_keywords && $2
|
||||
))
|
||||
)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(keywords)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn update_gold(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
id: i32,
|
||||
gold_delta: i32,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
"UPDATE shops SET
|
||||
gold = gold + $2
|
||||
WHERE id = $1",
|
||||
id,
|
||||
gold_delta,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
185
src/models/transaction.rs
Normal file
185
src/models/transaction.rs
Normal file
@ -0,0 +1,185 @@
|
||||
use anyhow::{Error, Result};
|
||||
use chrono::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Done, Executor, Postgres};
|
||||
use tracing::instrument;
|
||||
use url::Url;
|
||||
|
||||
use super::ListParams;
|
||||
use crate::problem::forbidden_permission;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Transaction {
|
||||
pub id: i32,
|
||||
pub shop_id: i32,
|
||||
pub owner_id: i32,
|
||||
pub mod_name: String,
|
||||
pub local_form_id: i32,
|
||||
pub name: String,
|
||||
pub form_type: i32,
|
||||
pub is_food: bool,
|
||||
pub price: i32,
|
||||
pub is_sell: bool,
|
||||
pub quantity: i32,
|
||||
pub amount: i32,
|
||||
pub keywords: Vec<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PostedTransaction {
|
||||
pub shop_id: i32,
|
||||
pub owner_id: Option<i32>,
|
||||
pub mod_name: String,
|
||||
pub local_form_id: i32,
|
||||
pub name: String,
|
||||
pub form_type: i32,
|
||||
pub is_food: bool,
|
||||
pub price: i32,
|
||||
pub is_sell: bool,
|
||||
pub quantity: i32,
|
||||
pub amount: i32,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
impl Transaction {
|
||||
pub fn resource_name() -> &'static str {
|
||||
"transaction"
|
||||
}
|
||||
|
||||
pub fn pk(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn url(&self, api_url: &Url) -> Result<Url> {
|
||||
Ok(api_url.join(&format!("{}s/{}", Self::resource_name(), self.pk()))?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, id: i32) -> Result<Self> {
|
||||
sqlx::query_as!(Self, "SELECT * FROM transactions WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn create(
|
||||
transaction: PostedTransaction,
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
) -> Result<Self> {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"INSERT INTO transactions
|
||||
(shop_id, owner_id, mod_name, local_form_id, name, form_type, is_food, price,
|
||||
is_sell, quantity, amount, keywords, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now())
|
||||
RETURNING *",
|
||||
transaction.shop_id,
|
||||
transaction.owner_id,
|
||||
transaction.mod_name,
|
||||
transaction.local_form_id,
|
||||
transaction.name,
|
||||
transaction.form_type,
|
||||
transaction.is_food,
|
||||
transaction.price,
|
||||
transaction.is_sell,
|
||||
transaction.quantity,
|
||||
transaction.amount,
|
||||
&transaction.keywords,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn delete(
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
id: i32,
|
||||
) -> Result<u64> {
|
||||
let transaction = sqlx::query!("SELECT owner_id FROM transactions WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if transaction.owner_id == owner_id {
|
||||
return Ok(sqlx::query!("DELETE FROM transactions WHERE id = $1", id)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected());
|
||||
} else {
|
||||
return Err(forbidden_permission());
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn list(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
list_params: &ListParams,
|
||||
) -> Result<Vec<Self>> {
|
||||
let result = if let Some(order_by) = list_params.get_order_by() {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM transactions
|
||||
ORDER BY $1
|
||||
LIMIT $2
|
||||
OFFSET $3",
|
||||
order_by,
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM transactions
|
||||
LIMIT $1
|
||||
OFFSET $2",
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn list_by_shop_id(
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
shop_id: i32,
|
||||
list_params: &ListParams,
|
||||
) -> Result<Vec<Self>> {
|
||||
let result = if let Some(order_by) = list_params.get_order_by() {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM transactions
|
||||
WHERE shop_id = $1
|
||||
ORDER BY $2
|
||||
LIMIT $3
|
||||
OFFSET $4",
|
||||
shop_id,
|
||||
order_by,
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM transactions
|
||||
WHERE shop_id = $1
|
||||
LIMIT $2
|
||||
OFFSET $3",
|
||||
shop_id,
|
||||
list_params.limit.unwrap_or(10),
|
||||
list_params.offset.unwrap_or(0),
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use http::StatusCode;
|
||||
use http_api_problem::HttpApiProblem;
|
||||
@ -31,6 +33,7 @@ pub fn from_anyhow(error: anyhow::Error) -> HttpApiProblem {
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
// TODO: should probably decentralize all this error handling to the places where they are relevant
|
||||
if let Some(sqlx_error) = error.downcast_ref::<sqlx::error::Error>() {
|
||||
match sqlx_error {
|
||||
sqlx::error::Error::RowNotFound => {
|
||||
@ -48,12 +51,28 @@ pub fn from_anyhow(error: anyhow::Error) -> HttpApiProblem {
|
||||
dbg!(&code);
|
||||
if let Some(constraint) = pg_error.constraint() {
|
||||
dbg!(&constraint);
|
||||
if code == "23503" && constraint == "shops_owner_id_fkey" {
|
||||
if code == "23503"
|
||||
&& (constraint == "shops_owner_id_fkey"
|
||||
|| constraint == "interior_ref_lists_owner_id_fkey"
|
||||
|| constraint == "merchandise_lists_owner_id_fkey"
|
||||
|| constraint == "transactions_owner_id_fkey")
|
||||
{
|
||||
// foreign_key_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
// TODO: better message when this is triggered by a non-cascading DELETE
|
||||
.set_detail("Owner does not exist");
|
||||
} else if code == "23503"
|
||||
&& (constraint == "interior_ref_lists_shop_id_fkey"
|
||||
|| constraint == "merchandise_lists_shop_id_fkey"
|
||||
|| constraint == "transactions_shop_id_fkey")
|
||||
{
|
||||
// foreign_key_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Shop does not exist");
|
||||
} else if code == "23505" && constraint == "owners_api_key_key" {
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
@ -72,20 +91,82 @@ pub fn from_anyhow(error: anyhow::Error) -> HttpApiProblem {
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Owner already has a shop with that name");
|
||||
} else if code == "23505" && constraint == "interior_ref_lists_shop_id_key" {
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Interior ref list already exists for that shop");
|
||||
} else if code == "23505" && constraint == "merchandise_lists_shop_id_key" {
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Merchandise list already exists for that shop");
|
||||
} else if code == "23514" && constraint == "merchandise_quantity_gt_zero" {
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Quantity of merchandise must be greater than zero");
|
||||
}
|
||||
}
|
||||
// Might possibly link sensitive info:
|
||||
// let mut problem = HttpApiProblem::with_title_and_type_from_status(
|
||||
// StatusCode::INTERNAL_SERVER_ERROR,
|
||||
// )
|
||||
// .set_title("Database Error")
|
||||
// .set_detail(format!(
|
||||
// "{}. {}",
|
||||
// pg_error.message(),
|
||||
// pg_error.detail().unwrap_or("")
|
||||
// ));
|
||||
// problem
|
||||
// .set_value("code".to_string(), &code.to_string())
|
||||
// .unwrap();
|
||||
// return problem;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(json_error) = error.downcast_ref::<serde_json::Error>() {
|
||||
return HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_title("Json Body Deserialization Error")
|
||||
.set_detail(format!("{}", json_error));
|
||||
}
|
||||
|
||||
if let Some(bincode_error) = error.downcast_ref::<bincode::Error>() {
|
||||
return match bincode_error.borrow() {
|
||||
bincode::ErrorKind::Io(io_error) => {
|
||||
HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_title("Bincode Body Deserialization Error")
|
||||
.set_detail(format!("io error ({:?}): {}", io_error.kind(), io_error))
|
||||
}
|
||||
error => HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_title("Bincode Body Deserialization Error")
|
||||
.set_detail(format!("{}", error)),
|
||||
};
|
||||
}
|
||||
|
||||
error!("Recovering unhandled error: {:?}", error);
|
||||
// TODO: this leaks internal info, should not stringify error
|
||||
HttpApiProblem::new(format!("Internal Server Error: {:?}", error))
|
||||
.set_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
HttpApiProblem::with_title_and_type_from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
pub async fn unpack_problem(rejection: Rejection) -> Result<impl Reply, Rejection> {
|
||||
if rejection.is_not_found() {
|
||||
let reply = warp::reply::json(&HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::NOT_FOUND,
|
||||
));
|
||||
let reply = warp::reply::with_status(reply, StatusCode::NOT_FOUND);
|
||||
let reply = warp::reply::with_header(
|
||||
reply,
|
||||
warp::http::header::CONTENT_TYPE,
|
||||
http_api_problem::PROBLEM_JSON_MEDIA_TYPE,
|
||||
);
|
||||
|
||||
return Ok(reply);
|
||||
}
|
||||
|
||||
if let Some(problem) = rejection.find::<HttpApiProblem>() {
|
||||
let code = problem.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -9,7 +9,8 @@
|
||||
"quantity": 1,
|
||||
"form_type": 32,
|
||||
"is_food": false,
|
||||
"price": 1
|
||||
"price": 1,
|
||||
"keywords": ["VendorItemMisc"]
|
||||
},
|
||||
{
|
||||
"mod_name": "Skyrim.esm",
|
||||
@ -18,7 +19,8 @@
|
||||
"quantity": 2,
|
||||
"form_type": 23,
|
||||
"is_food": false,
|
||||
"price": 2
|
||||
"price": 2,
|
||||
"keywords": ["VendorItemScroll"]
|
||||
},
|
||||
{
|
||||
"mod_name": "Skyrim.esm",
|
||||
@ -27,7 +29,8 @@
|
||||
"quantity": 3,
|
||||
"form_type": 46,
|
||||
"is_food": true,
|
||||
"price": 3
|
||||
"price": 3,
|
||||
"keywords": ["VendorItemIngredient"]
|
||||
},
|
||||
{
|
||||
"mod_name": "Skyrim.esm",
|
||||
@ -36,7 +39,8 @@
|
||||
"quantity": 4,
|
||||
"form_type": 41,
|
||||
"is_food": false,
|
||||
"price": 4
|
||||
"price": 4,
|
||||
"keywords": ["VendorItemWeapon"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
13
test_data/transaction.json
Normal file
13
test_data/transaction.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"shop_id": 1,
|
||||
"mod_name": "Skyrim.esm",
|
||||
"local_form_id": 5,
|
||||
"name": "New Thing",
|
||||
"form_type": 41,
|
||||
"is_food": false,
|
||||
"price": 100,
|
||||
"is_sell": false,
|
||||
"quantity": 1,
|
||||
"amount": 100,
|
||||
"keywords": ["VendorItemMisc"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user