Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7a3480a1 | |||
| 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
|
||||
|
||||
821
Cargo.lock
generated
821
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@@ -8,23 +8,25 @@ 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.3", 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 = { git = "https://github.com/launchbadge/sqlx", branch = "master", default-features = false, features = [ "runtime-tokio", "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.16"
|
||||
ipnetwork = "0.17"
|
||||
url = "2.1"
|
||||
async-trait = "0.1"
|
||||
seahash = "4.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.2"
|
||||
tracing-futures = "0.2"
|
||||
|
||||
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
|
||||
96
README.md
96
README.md
@@ -1,20 +1,21 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
@@ -27,19 +28,45 @@ database](https://www.postgresql.org).
|
||||
|
||||
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. 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"
|
||||
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,26 +84,23 @@ 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"
|
||||
```
|
||||
4. Create a new file at `src/db/refinery.toml` with the contents:
|
||||
```
|
||||
[main]
|
||||
db_type = "Postgres"
|
||||
db_host = "localhost"
|
||||
db_port = "5432"
|
||||
db_user = "bazaarrealm"
|
||||
db_pass = "<database-password-here>"
|
||||
db_name = "bazaarrealm"
|
||||
```
|
||||
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
|
||||
|
||||
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. `cd db` to enter the `db` sub-directory of this repo.
|
||||
6. Run `sqlx migrate run` which will run all the database migrations.
|
||||
7. `cd ..` to return to the top-level directory of this repo.
|
||||
8. Run `./devserver.sh` to run the dev server (by default it listens at
|
||||
`127.0.0.1:3030`).
|
||||
|
||||
## Testing Data
|
||||
@@ -91,8 +115,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"
|
||||
@@ -115,8 +139,8 @@ 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.
|
||||
- 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.
|
||||
|
||||
56
db/migrations/20201111205247_initial.sql
Normal file
56
db/migrations/20201111205247_initial.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
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,
|
||||
"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,
|
||||
"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 "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,
|
||||
"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");
|
||||
6
db/reset_db.pgsql
Normal file
6
db/reset_db.pgsql
Normal file
@@ -0,0 +1,6 @@
|
||||
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 refinery_schema_history CASCADE;
|
||||
24
docker-compose-prod.yml
Normal file
24
docker-compose-prod.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
volumes:
|
||||
- cargo:/home/rust/.cargo
|
||||
- target:/home/rust/src/target
|
||||
env_file:
|
||||
- .env.docker
|
||||
ports:
|
||||
- "443:443"
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: postgres:alpine
|
||||
volumes:
|
||||
- /var/lib/postgresql/data:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- .env.docker
|
||||
|
||||
volumes:
|
||||
cargo: {}
|
||||
target: {}
|
||||
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: {}
|
||||
2143
sqlx-data.json
Normal file
2143
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 mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::CACHES;
|
||||
use crate::models::{InteriorRefList, ListParams, PostedInteriorRefList, UnsavedInteriorRefList};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, ETagReply, Json,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => {
|
||||
(ContentType::Bincode, &CACHES.interior_ref_list_bin)
|
||||
}
|
||||
_ => (ContentType::Json, &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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => (
|
||||
ContentType::Bincode,
|
||||
&CACHES.interior_ref_list_by_shop_id_bin,
|
||||
),
|
||||
_ => (ContentType::Json, &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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => {
|
||||
(ContentType::Bincode, &CACHES.list_interior_ref_lists_bin)
|
||||
}
|
||||
_ => (ContentType::Json, &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(
|
||||
interior_ref_list: PostedInteriorRefList,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let unsaved_interior_ref_list = UnsavedInteriorRefList {
|
||||
owner_id,
|
||||
shop_id: interior_ref_list.shop_id,
|
||||
ref_list: interior_ref_list.ref_list,
|
||||
};
|
||||
let saved_interior_ref_list = InteriorRefList::create(unsaved_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,
|
||||
interior_ref_list: PostedInteriorRefList,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
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,
|
||||
interior_ref_list: PostedInteriorRefList,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
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)
|
||||
}
|
||||
289
src/handlers/merchandise_list.rs
Normal file
289
src/handlers/merchandise_list.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use anyhow::Result;
|
||||
use http::StatusCode;
|
||||
use mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::CACHES;
|
||||
use crate::models::{ListParams, MerchandiseList, PostedMerchandiseList, UnsavedMerchandiseList};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, ETagReply, Json,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => {
|
||||
(ContentType::Bincode, &CACHES.merchandise_list_bin)
|
||||
}
|
||||
_ => (ContentType::Json, &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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => {
|
||||
(ContentType::Bincode, &CACHES.merchandise_list_bin)
|
||||
}
|
||||
_ => (ContentType::Json, &CACHES.merchandise_list),
|
||||
};
|
||||
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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => {
|
||||
(ContentType::Bincode, &CACHES.list_merchandise_lists_bin)
|
||||
}
|
||||
_ => (ContentType::Json, &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(
|
||||
merchandise_list: PostedMerchandiseList,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let unsaved_merchandise_list = UnsavedMerchandiseList {
|
||||
owner_id,
|
||||
shop_id: merchandise_list.shop_id,
|
||||
form_list: merchandise_list.form_list,
|
||||
};
|
||||
let saved_merchandise_list = MerchandiseList::create(unsaved_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,
|
||||
merchandise_list: PostedMerchandiseList,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
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,
|
||||
merchandise_list: PostedMerchandiseList,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
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,32 @@
|
||||
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::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};
|
||||
use http::StatusCode;
|
||||
use http_api_problem::HttpApiProblem;
|
||||
use mime::{FromStrError, Mime};
|
||||
use seahash::hash;
|
||||
use serde::Serialize;
|
||||
use tracing::{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::{CachedResponse, CACHES};
|
||||
use super::problem::{unauthorized_no_api_key, unauthorized_no_owner};
|
||||
use super::Environment;
|
||||
|
||||
#[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 +48,133 @@ 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"));
|
||||
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"),
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
} else {
|
||||
MerchandiseList {
|
||||
id: Some(id),
|
||||
owner_id: Some(owner_id),
|
||||
..merchandise_list
|
||||
}
|
||||
};
|
||||
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)
|
||||
impl AcceptHeader {
|
||||
pub fn accepts_bincode(&self) -> bool {
|
||||
self.mimes.contains(&mime::APPLICATION_OCTET_STREAM)
|
||||
}
|
||||
}
|
||||
|
||||
170
src/handlers/owner.rs
Normal file
170
src/handlers/owner.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use anyhow::Result;
|
||||
use http::StatusCode;
|
||||
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::CACHES;
|
||||
use crate::models::{ListParams, Owner, PostedOwner, UnsavedOwner};
|
||||
use crate::problem::{reject_anyhow, unauthorized_no_api_key};
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, ETagReply, Json,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => (ContentType::Bincode, &CACHES.owner_bin),
|
||||
_ => (ContentType::Json, &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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => (ContentType::Bincode, &CACHES.list_owners_bin),
|
||||
_ => (ContentType::Json, &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(
|
||||
owner: PostedOwner,
|
||||
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 content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
let unsaved_owner = UnsavedOwner {
|
||||
api_key,
|
||||
ip_address: match remote_addr {
|
||||
Some(addr) => Some(IpNetwork::from(addr.ip())),
|
||||
None => real_ip,
|
||||
},
|
||||
name: owner.name,
|
||||
mod_version: owner.mod_version,
|
||||
};
|
||||
let saved_owner = Owner::create(unsaved_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,
|
||||
owner: PostedOwner,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
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)
|
||||
}
|
||||
208
src/handlers/shop.rs
Normal file
208
src/handlers/shop.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use http::StatusCode;
|
||||
use mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::CACHES;
|
||||
use crate::models::{
|
||||
InteriorRefList, ListParams, MerchandiseList, PostedShop, Shop, UnsavedInteriorRefList,
|
||||
UnsavedMerchandiseList, UnsavedShop,
|
||||
};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, ETagReply, Json,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => (ContentType::Bincode, &CACHES.shop_bin),
|
||||
_ => (ContentType::Json, &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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => (ContentType::Bincode, &CACHES.list_shops_bin),
|
||||
_ => (ContentType::Json, &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(
|
||||
shop: PostedShop,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let unsaved_shop = UnsavedShop {
|
||||
name: shop.name,
|
||||
description: shop.description,
|
||||
owner_id,
|
||||
};
|
||||
let mut tx = env
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|error| reject_anyhow(anyhow!(error)))?;
|
||||
let saved_shop = Shop::create(unsaved_shop, &mut tx)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
|
||||
// also save empty interior_ref_list and merchandise_list rows
|
||||
let interior_ref_list = UnsavedInteriorRefList {
|
||||
shop_id: saved_shop.id,
|
||||
owner_id,
|
||||
ref_list: sqlx::types::Json::default(),
|
||||
};
|
||||
InteriorRefList::create(interior_ref_list, &mut tx)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let merchandise_list = UnsavedMerchandiseList {
|
||||
shop_id: saved_shop.id,
|
||||
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,
|
||||
shop: PostedShop,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let posted_shop = PostedShop {
|
||||
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),
|
||||
},
|
||||
..shop
|
||||
};
|
||||
let updated_shop = Shop::update(posted_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)
|
||||
}
|
||||
214
src/handlers/transaction.rs
Normal file
214
src/handlers/transaction.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use http::StatusCode;
|
||||
use mime::Mime;
|
||||
use uuid::Uuid;
|
||||
use warp::reply::{with_header, with_status};
|
||||
use warp::{Rejection, Reply};
|
||||
|
||||
use crate::caches::CACHES;
|
||||
use crate::models::{
|
||||
ListParams, MerchandiseList, PostedTransaction, Transaction, UnsavedTransaction,
|
||||
};
|
||||
use crate::problem::reject_anyhow;
|
||||
use crate::Environment;
|
||||
|
||||
use super::{
|
||||
authenticate, check_etag, AcceptHeader, Bincode, ContentType, DataReply, ETagReply, Json,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
id: i32,
|
||||
etag: Option<String>,
|
||||
accept: Option<AcceptHeader>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => (ContentType::Bincode, &CACHES.transaction_bin),
|
||||
_ => (ContentType::Json, &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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => {
|
||||
(ContentType::Bincode, &CACHES.list_transactions_bin)
|
||||
}
|
||||
_ => (ContentType::Json, &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 (content_type, cache) = match accept {
|
||||
Some(accept) if accept.accepts_bincode() => (
|
||||
ContentType::Bincode,
|
||||
&CACHES.list_transactions_by_shop_id_bin,
|
||||
),
|
||||
_ => (ContentType::Json, &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(
|
||||
transaction: PostedTransaction,
|
||||
api_key: Option<Uuid>,
|
||||
content_type: Option<Mime>,
|
||||
env: Environment,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let content_type = match content_type {
|
||||
Some(content_type) if content_type == mime::APPLICATION_OCTET_STREAM => {
|
||||
ContentType::Bincode
|
||||
}
|
||||
_ => ContentType::Json,
|
||||
};
|
||||
let owner_id = authenticate(&env, api_key).await.map_err(reject_anyhow)?;
|
||||
let unsaved_transaction = UnsavedTransaction {
|
||||
shop_id: transaction.shop_id,
|
||||
owner_id,
|
||||
mod_name: transaction.mod_name,
|
||||
local_form_id: transaction.local_form_id,
|
||||
name: transaction.name,
|
||||
form_type: transaction.form_type,
|
||||
is_food: transaction.is_food,
|
||||
price: transaction.price,
|
||||
is_sell: transaction.is_sell,
|
||||
quantity: transaction.quantity,
|
||||
amount: transaction.amount,
|
||||
};
|
||||
let mut tx = env
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|error| reject_anyhow(anyhow!(error)))?;
|
||||
let saved_transaction = Transaction::create(unsaved_transaction, &mut tx)
|
||||
.await
|
||||
.map_err(reject_anyhow)?;
|
||||
let quantity_delta = match transaction.is_sell {
|
||||
true => transaction.quantity,
|
||||
false => transaction.quantity * -1,
|
||||
};
|
||||
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,
|
||||
)
|
||||
.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;
|
||||
});
|
||||
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)?;
|
||||
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)
|
||||
}
|
||||
205
src/main.rs
205
src/main.rs
@@ -1,55 +1,45 @@
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Clap;
|
||||
use dotenv::dotenv;
|
||||
use http::StatusCode;
|
||||
use hyper::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::Filter;
|
||||
|
||||
mod caches;
|
||||
mod db;
|
||||
mod handlers;
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
mod models;
|
||||
mod problem;
|
||||
|
||||
use caches::Caches;
|
||||
use models::interior_ref_list::InteriorRefList;
|
||||
use models::merchandise_list::{MerchandiseList, MerchandiseParams};
|
||||
use models::owner::Owner;
|
||||
use models::shop::Shop;
|
||||
use models::ListParams;
|
||||
|
||||
#[derive(Clap)]
|
||||
#[clap(version = "0.1.0", author = "Tyler Hallada <tyler@hallada.net>")]
|
||||
struct Opts {
|
||||
#[clap(short, long)]
|
||||
migrate: bool,
|
||||
}
|
||||
use models::{
|
||||
ListParams, PostedInteriorRefList, PostedMerchandiseList, PostedOwner, PostedShop,
|
||||
PostedTransaction,
|
||||
};
|
||||
|
||||
#[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::builder()
|
||||
.max_size(5)
|
||||
.build(&env::var("DATABASE_URL")?)
|
||||
db: PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&env::var("DATABASE_URL")?)
|
||||
.await?,
|
||||
caches: Caches::initialize(),
|
||||
api_url,
|
||||
})
|
||||
}
|
||||
@@ -69,31 +59,29 @@ fn json_body<T>() -> impl Filter<Extract = (T,), Error = warp::Rejection> + Clon
|
||||
where
|
||||
T: Send + DeserializeOwned,
|
||||
{
|
||||
warp::body::content_length_limit(1024 * 64).and(warp::body::json())
|
||||
warp::body::content_length_limit(1024 * 1024).and(warp::body::json())
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_log_filter)
|
||||
.with_span_events(FmtSpan::CLOSE)
|
||||
.with_writer(std::io::stdout)
|
||||
.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())
|
||||
@@ -102,18 +90,21 @@ async fn main() -> Result<()> {
|
||||
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::<Owner>())
|
||||
.and(json_body::<PostedOwner>())
|
||||
.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()
|
||||
@@ -121,38 +112,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::<Owner>())
|
||||
.and(json_body::<PostedOwner>())
|
||||
.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::<Shop>())
|
||||
.and(json_body::<PostedShop>())
|
||||
.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()
|
||||
@@ -160,38 +157,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::<Shop>())
|
||||
.and(json_body::<PostedShop>())
|
||||
.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::<InteriorRefList>())
|
||||
.and(json_body::<PostedInteriorRefList>())
|
||||
.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()
|
||||
@@ -199,56 +202,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::<InteriorRefList>())
|
||||
.and(json_body::<PostedInteriorRefList>())
|
||||
.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::<InteriorRefList>())
|
||||
.and(json_body::<PostedInteriorRefList>())
|
||||
.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::<MerchandiseList>())
|
||||
.and(json_body::<PostedMerchandiseList>())
|
||||
.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()
|
||||
@@ -256,51 +268,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::<MerchandiseList>())
|
||||
.and(json_body::<PostedMerchandiseList>())
|
||||
.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::<MerchandiseList>())
|
||||
.and(json_body::<PostedMerchandiseList>())
|
||||
.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(json_body::<PostedTransaction>())
|
||||
.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")
|
||||
@@ -320,7 +374,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,
|
||||
@@ -331,13 +385,20 @@ 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());
|
||||
|
||||
let svc = warp::service(routes);
|
||||
let svc = warp::service(routes)
|
||||
.tls()
|
||||
.cert_path("cert.pem")
|
||||
.key_path("key.pem");
|
||||
let make_svc = hyper::service::make_service_fn(|_: _| {
|
||||
let svc = svc.clone();
|
||||
async move { Ok::<_, Infallible>(svc) }
|
||||
@@ -347,7 +408,7 @@ 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())
|
||||
Server::bind(&([0, 0, 0, 0], 3030).into())
|
||||
};
|
||||
|
||||
// warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
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, 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)]
|
||||
pub struct InteriorRef {
|
||||
pub base_mod_name: String,
|
||||
@@ -32,55 +26,75 @@ pub struct InteriorRef {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct InteriorRefList {
|
||||
pub id: Option<i32>,
|
||||
pub id: i32,
|
||||
pub shop_id: i32,
|
||||
pub owner_id: i32,
|
||||
pub ref_list: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UnsavedInteriorRefList {
|
||||
pub shop_id: i32,
|
||||
pub owner_id: i32,
|
||||
pub ref_list: Json<Vec<InteriorRef>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PostedInteriorRefList {
|
||||
pub shop_id: i32,
|
||||
pub owner_id: Option<i32>,
|
||||
pub ref_list: Json<Vec<InteriorRef>>,
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[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) -> Option<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)
|
||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, id: i32) -> Result<Self> {
|
||||
sqlx::query_as!(Self, "SELECT * FROM interior_ref_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, db))]
|
||||
async fn create(self, 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: UnsavedInteriorRefList,
|
||||
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 *",
|
||||
self.shop_id,
|
||||
self.owner_id,
|
||||
self.ref_list,
|
||||
interior_ref_list.shop_id,
|
||||
interior_ref_list.owner_id,
|
||||
serde_json::json!(interior_ref_list.ref_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 interior_ref_list =
|
||||
sqlx::query!("SELECT owner_id FROM interior_ref_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
@@ -89,7 +103,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());
|
||||
@@ -97,9 +112,12 @@ 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
|
||||
ORDER BY $1
|
||||
@@ -112,7 +130,7 @@ impl Model for InteriorRefList {
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as_unchecked!(
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM interior_ref_lists
|
||||
LIMIT $1
|
||||
@@ -125,18 +143,20 @@ impl Model for InteriorRefList {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for InteriorRefList {
|
||||
#[instrument(level = "debug", skip(self, db))]
|
||||
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
|
||||
let interior_ref_list =
|
||||
#[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 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
|
||||
ref_list = $2,
|
||||
@@ -144,7 +164,7 @@ impl UpdateableModel for InteriorRefList {
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
id,
|
||||
self.ref_list,
|
||||
serde_json::json!(interior_ref_list.ref_list),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@@ -152,12 +172,13 @@ 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",
|
||||
@@ -168,16 +189,21 @@ impl InteriorRefList {
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, db))]
|
||||
pub async fn update_by_shop_id(self, db: &PgPool, owner_id: i32, shop_id: i32) -> Result<Self> {
|
||||
let interior_ref_list = sqlx::query!(
|
||||
#[instrument(level = "debug", skip(interior_ref_list, db))]
|
||||
pub async fn update_by_shop_id(
|
||||
interior_ref_list: PostedInteriorRefList,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
shop_id: i32,
|
||||
) -> Result<Self> {
|
||||
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
|
||||
ref_list = $2,
|
||||
@@ -185,7 +211,7 @@ impl InteriorRefList {
|
||||
WHERE shop_id = $1
|
||||
RETURNING *",
|
||||
shop_id,
|
||||
self.ref_list,
|
||||
serde_json::json!(interior_ref_list.ref_list),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
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, 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,
|
||||
@@ -29,58 +25,75 @@ pub struct Merchandise {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MerchandiseList {
|
||||
pub id: Option<i32>,
|
||||
pub id: i32,
|
||||
pub shop_id: i32,
|
||||
pub owner_id: i32,
|
||||
pub form_list: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UnsavedMerchandiseList {
|
||||
pub shop_id: i32,
|
||||
pub owner_id: i32,
|
||||
pub form_list: Json<Vec<Merchandise>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PostedMerchandiseList {
|
||||
pub shop_id: i32,
|
||||
pub owner_id: Option<i32>,
|
||||
pub form_list: Json<Vec<Merchandise>>,
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[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) -> Option<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)
|
||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, id: i32) -> Result<Self> {
|
||||
sqlx::query_as!(Self, "SELECT * FROM merchandise_lists WHERE id = $1", id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, db))]
|
||||
async fn create(self, db: &PgPool) -> Result<Self> {
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
#[instrument(level = "debug", skip(merchandise_list, db))]
|
||||
pub async fn create(
|
||||
merchandise_list: UnsavedMerchandiseList,
|
||||
db: impl Executor<'_, Database = Postgres>,
|
||||
) -> Result<Self> {
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"INSERT INTO merchandise_lists
|
||||
(shop_id, owner_id, form_list, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, now(), now())
|
||||
RETURNING *",
|
||||
self.shop_id,
|
||||
self.owner_id,
|
||||
self.form_list,
|
||||
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)
|
||||
@@ -89,7 +102,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());
|
||||
@@ -97,9 +111,12 @@ 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
|
||||
ORDER BY $1
|
||||
@@ -112,7 +129,7 @@ impl Model for MerchandiseList {
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as_unchecked!(
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM merchandise_lists
|
||||
LIMIT $1
|
||||
@@ -125,18 +142,20 @@ impl Model for MerchandiseList {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for MerchandiseList {
|
||||
#[instrument(level = "debug", skip(self, db))]
|
||||
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self> {
|
||||
let merchandise_list =
|
||||
#[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 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
|
||||
form_list = $2,
|
||||
@@ -144,7 +163,7 @@ impl UpdateableModel for MerchandiseList {
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
id,
|
||||
self.form_list,
|
||||
serde_json::json!(merchandise_list.form_list),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@@ -152,12 +171,13 @@ 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",
|
||||
@@ -168,16 +188,21 @@ impl MerchandiseList {
|
||||
.map_err(Error::new)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(db))]
|
||||
pub async fn update_by_shop_id(self, db: &PgPool, owner_id: i32, shop_id: i32) -> Result<Self> {
|
||||
let merchandise_list = sqlx::query!(
|
||||
#[instrument(level = "debug", skip(merchandise_list, db))]
|
||||
pub async fn update_by_shop_id(
|
||||
merchandise_list: PostedMerchandiseList,
|
||||
db: impl Executor<'_, Database = Postgres> + Copy,
|
||||
owner_id: i32,
|
||||
shop_id: i32,
|
||||
) -> Result<Self> {
|
||||
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
|
||||
form_list = $2,
|
||||
@@ -185,7 +210,7 @@ impl MerchandiseList {
|
||||
WHERE shop_id = $1
|
||||
RETURNING *",
|
||||
shop_id,
|
||||
self.form_list,
|
||||
serde_json::json!(merchandise_list.form_list),
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@@ -196,24 +221,44 @@ 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,
|
||||
) -> 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,
|
||||
}]);
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"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,
|
||||
@@ -225,16 +270,38 @@ 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.*",
|
||||
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 interior_ref_list::{InteriorRefList, PostedInteriorRefList, UnsavedInteriorRefList};
|
||||
pub use merchandise_list::{MerchandiseList, PostedMerchandiseList, UnsavedMerchandiseList};
|
||||
pub use model::{Model, UpdateableModel};
|
||||
pub use owner::Owner;
|
||||
pub use shop::Shop;
|
||||
pub use owner::{Owner, PostedOwner, UnsavedOwner};
|
||||
pub use shop::{PostedShop, Shop, UnsavedShop};
|
||||
pub use transaction::{PostedTransaction, Transaction, UnsavedTransaction};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Deserialize)]
|
||||
pub enum Order {
|
||||
|
||||
@@ -5,6 +5,14 @@ use url::Url;
|
||||
|
||||
use super::ListParams;
|
||||
|
||||
// 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
|
||||
@@ -34,4 +42,4 @@ where
|
||||
Self: std::marker::Sized,
|
||||
{
|
||||
async fn update(self, db: &PgPool, owner_id: i32, id: i32) -> Result<Self>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,109 @@
|
||||
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, UpdateableModel};
|
||||
use crate::problem::forbidden_permission;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Owner {
|
||||
pub id: Option<i32>,
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub api_key: Option<Uuid>,
|
||||
pub api_key: Uuid,
|
||||
#[serde(skip_serializing)]
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub mod_version: i32,
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Model for Owner {
|
||||
fn resource_name() -> &'static str {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UnsavedOwner {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub api_key: Uuid,
|
||||
#[serde(skip_serializing)]
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub mod_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PostedOwner {
|
||||
pub name: String,
|
||||
pub mod_version: i32,
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
pub fn resource_name() -> &'static str {
|
||||
"owner"
|
||||
}
|
||||
|
||||
fn pk(&self) -> Option<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(self, db))]
|
||||
async fn create(self, db: &PgPool) -> Result<Self> {
|
||||
#[instrument(level = "debug", skip(owner, db))]
|
||||
pub async fn create(
|
||||
owner: UnsavedOwner,
|
||||
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 *",
|
||||
self.name,
|
||||
self.api_key,
|
||||
self.ip_address,
|
||||
self.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,
|
||||
@@ -102,16 +131,18 @@ impl Model for Owner {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for Owner {
|
||||
#[instrument(level = "debug", skip(self, db))]
|
||||
async fn update(self, 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
|
||||
@@ -121,8 +152,8 @@ impl UpdateableModel for Owner {
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
id,
|
||||
self.name,
|
||||
self.mod_version,
|
||||
owner.name,
|
||||
owner.mod_version,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
@@ -130,4 +161,4 @@ impl UpdateableModel for Owner {
|
||||
return Err(forbidden_permission());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,101 @@
|
||||
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, UpdateableModel};
|
||||
use crate::problem::forbidden_permission;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Shop {
|
||||
pub id: Option<i32>,
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub owner_id: Option<i32>,
|
||||
pub description: 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 created_at: Option<NaiveDateTime>,
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
pub owner_id: i32,
|
||||
pub description: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Model for Shop {
|
||||
fn resource_name() -> &'static str {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UnsavedShop {
|
||||
pub name: String,
|
||||
pub owner_id: i32,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PostedShop {
|
||||
pub name: String,
|
||||
pub owner_id: Option<i32>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl Shop {
|
||||
pub fn resource_name() -> &'static str {
|
||||
"shop"
|
||||
}
|
||||
|
||||
fn pk(&self) -> Option<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(self, db))]
|
||||
async fn create(self, db: &PgPool) -> Result<Self> {
|
||||
#[instrument(level = "debug", skip(shop, db))]
|
||||
pub async fn create(
|
||||
shop: UnsavedShop,
|
||||
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())
|
||||
RETURNING *",
|
||||
self.name,
|
||||
self.owner_id,
|
||||
self.description,
|
||||
shop.name,
|
||||
shop.owner_id,
|
||||
shop.description,
|
||||
)
|
||||
.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,
|
||||
@@ -101,16 +123,18 @@ impl Model for Shop {
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpdateableModel for Shop {
|
||||
#[instrument(level = "debug", skip(self, db))]
|
||||
async fn update(self, 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
|
||||
@@ -121,9 +145,9 @@ impl UpdateableModel for Shop {
|
||||
WHERE id = $1
|
||||
RETURNING *",
|
||||
id,
|
||||
self.name,
|
||||
self.owner_id,
|
||||
self.description,
|
||||
shop.name,
|
||||
shop.owner_id,
|
||||
shop.description,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
|
||||
197
src/models/transaction.rs
Normal file
197
src/models/transaction.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
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 created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UnsavedTransaction {
|
||||
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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
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: UnsavedTransaction,
|
||||
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, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 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,
|
||||
)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -36,51 +36,44 @@ pub fn from_anyhow(error: anyhow::Error) -> HttpApiProblem {
|
||||
sqlx::error::Error::RowNotFound => {
|
||||
return HttpApiProblem::with_title_and_type_from_status(StatusCode::NOT_FOUND)
|
||||
}
|
||||
sqlx::error::Error::Database(db_error) => {
|
||||
error!(
|
||||
"Database error: {}. {}",
|
||||
db_error.message(),
|
||||
db_error.details().unwrap_or("")
|
||||
);
|
||||
dbg!(&db_error);
|
||||
if let Some(code) = db_error.code() {
|
||||
dbg!(&code);
|
||||
if let Some(constraint) = db_error.constraint_name() {
|
||||
dbg!(&constraint);
|
||||
if code == "23503" && constraint == "shops_owner_id_fkey" {
|
||||
// foreign_key_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Owner does not exist");
|
||||
} else if code == "23505" && constraint == "owners_api_key_key" {
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Owner with Api-Key already exists");
|
||||
} else if code == "23505" && constraint == "owners_unique_name_and_api_key"
|
||||
{
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Duplicate owner with same name and Api-Key exists");
|
||||
} else if code == "23505" && constraint == "shops_unique_name_and_owner_id"
|
||||
{
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.set_detail("Owner already has a shop with that name");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pg_error) = error.downcast_ref::<sqlx::postgres::PgDatabaseError>() {
|
||||
error!(
|
||||
"Database error: {}. {}",
|
||||
pg_error.message(),
|
||||
pg_error.detail().unwrap_or("")
|
||||
);
|
||||
dbg!(&pg_error);
|
||||
let code = pg_error.code();
|
||||
dbg!(&code);
|
||||
if let Some(constraint) = pg_error.constraint() {
|
||||
dbg!(&constraint);
|
||||
if code == "23503" && constraint == "shops_owner_id_fkey" {
|
||||
// foreign_key_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_detail("Owner does not exist");
|
||||
} else if code == "23505" && constraint == "owners_api_key_key" {
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_detail("Owner with Api-Key already exists");
|
||||
} else if code == "23505" && constraint == "owners_unique_name_and_api_key" {
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_detail("Duplicate owner with same name and Api-Key exists");
|
||||
} else if code == "23505" && constraint == "shops_unique_name_and_owner_id" {
|
||||
// unique_violation
|
||||
return HttpApiProblem::with_title_and_type_from_status(StatusCode::BAD_REQUEST)
|
||||
.set_detail("Owner already has a shop with that name");
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error!("Recovering unhandled error: {:?}", error);
|
||||
// TODO: this leaks internal info, should not stringify error
|
||||
HttpApiProblem::new(format!("Internal Server Error: {:?}", error))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
12
test_data/transaction.json
Normal file
12
test_data/transaction.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
Reference in New Issue
Block a user